Skip to content

Errors and Retries

awaitly provides unified error handling: automatic type inference, structured error types, and resilience patterns like retries and timeouts.

Errors from step() propagate automatically to the workflow result. Never wrap steps in try/catch - this defeats typed error propagation:

// ❌ WRONG - try/catch defeats typed error propagation
const result = await workflow.run(async ({ step, deps }) => {
try {
const payment = await step('makePayment', () => deps.makePayment());
} catch (error) {
await step('handleFailed', () => deps.handleFailed(error));
}
});
// ✅ CORRECT - errors propagate to workflow result
const result = await workflow.run(async ({ step, deps }) => {
const payment = await step('makePayment', () => deps.makePayment());
return payment;
});
// Handle errors at the boundary
if (!result.ok) {
switch (result.error.type ?? result.error) {
case 'PAYMENT_FAILED':
await handleFailedPayment(result.error);
break;
}
}

When you create a workflow, TypeScript computes the error union from your dependencies:

const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { /* ... */ };
const fetchPosts = async (id: string): AsyncResult<Post[], 'FETCH_ERROR'> => { /* ... */ };
const sendEmail = async (to: string): AsyncResult<void, 'EMAIL_FAILED'> => { /* ... */ };
const workflow = createWorkflow('workflow', { fetchUser, fetchPosts, sendEmail });
const result = await workflow.run(async ({ step, deps }) => { /* ... */ });
// result.error is: 'NOT_FOUND' | 'FETCH_ERROR' | 'EMAIL_FAILED' | UnexpectedError

Add a new dependency? The error union updates automatically.

If code throws an exception (not a returned error), it becomes an UnexpectedError (a TaggedError). The original thrown value is preserved in error.cause:

import { isUnexpectedError } from 'awaitly';
const badOperation = async (): AsyncResult<string, 'KNOWN_ERROR'> => {
throw new Error('Something broke'); // Throws instead of returning err()
};
const workflow = createWorkflow('workflow', { badOperation });
const result = await workflow.run(async ({ step, deps }) => {
return await step('badOperation', () => deps.badOperation());
});
if (!result.ok && isUnexpectedError(result.error)) {
console.log(result.error.cause); // The original Error object
}

Choose your error type based on complexity:

Do you need data attached to the error?
├── No → Use string literals: 'NOT_FOUND' | 'UNAUTHORIZED'
└── Yes → Do you have 3+ error variants to handle?
├── No → Object literal: { type: 'NOT_FOUND', id: string }
└── Yes → TaggedError with match()
Use CaseRecommendation
Simple distinct statesString literals: 'NOT_FOUND' | 'UNAUTHORIZED'
Errors with contextTaggedError: NotFoundError { id, resource }
Multiple variants to handleTaggedError with match()

Simple and sufficient for most cases:

const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND' | 'FORBIDDEN'> => {
if (!session.valid) return err('FORBIDDEN');
const user = await db.find(id);
return user ? ok(user) : err('NOT_FOUND');
};

When you need data attached to errors:

import { TaggedError } from 'awaitly';
class UserNotFoundError extends TaggedError('UserNotFoundError')<{
userId: string;
searchedAt: string;
}> {}
class UserForbiddenError extends TaggedError('UserForbiddenError')<{
userId: string;
reason: 'session_expired' | 'insufficient_permissions';
}> {}
const fetchUser = async (id: string): AsyncResult<User, UserNotFoundError | UserForbiddenError> => {
if (!session.valid) {
return err(new UserForbiddenError({ userId: id, reason: 'session_expired' }));
}
const user = await db.find(id);
if (!user) {
return err(new UserNotFoundError({ userId: id, searchedAt: 'users_table' }));
}
return ok(user);
};

Use TaggedError.match for exhaustive handling:

if (!result.ok) {
const response = TaggedError.match(result.error, {
UserNotFoundError: (e) => ({
status: 404,
body: { error: 'not_found', userId: e.userId },
}),
UserForbiddenError: (e) => ({
status: 403,
body: { error: 'forbidden', reason: e.reason },
}),
});
return res.status(response.status).json(response.body);
}

Limit how long a step can run:

const data = await step.withTimeout(
'slowOp',
() => slowOperation(),
{ ms: 5000 }
);
// Default - return timeout error
const data = await step.withTimeout(
'slowOp',
() => slowOperation(),
{ ms: 5000, onTimeout: 'error' }
);
// Returns StepTimeoutError on timeout
import { isStepTimeoutError, getStepTimeoutMeta } from 'awaitly/workflow';
if (!result.ok && isStepTimeoutError(result.error)) {
const meta = getStepTimeoutMeta(result.error);
console.log(`${meta.name} timed out after ${meta.ms}ms`);
}

Retry failed steps with configurable backoff:

const data = await step.retry(
'fetchData',
() => fetchData(),
{
attempts: 3,
backoff: 'exponential',
delayMs: 100,
}
);
Fixed Backoff (delayMs: 100)
────────────────────────────
Attempt │ Delay
────────┼────────
1 │ 100ms
2 │ 100ms
3 │ 100ms
Linear Backoff (delayMs: 100)
─────────────────────────────
Attempt │ Delay
────────┼────────
1 │ 100ms
2 │ 200ms
3 │ 300ms
Exponential Backoff (delayMs: 100)
──────────────────────────────────
Attempt │ Delay
────────┼────────
1 │ 100ms
2 │ 200ms
3 │ 400ms
4 │ 800ms

Prevent delays from growing too large:

{
attempts: 10,
backoff: 'exponential',
delayMs: 100,
maxDelayMs: 5000, // Never wait more than 5 seconds
}

Randomize delays to avoid thundering herd:

{
attempts: 3,
backoff: 'exponential',
delayMs: 100,
jitter: true, // Adds ±50% random variation
}

Only retry certain errors:

const user = await step.retry(
'fetchUser',
() => fetchUser('1'),
{
attempts: 3,
backoff: 'exponential',
retryOn: (error) => {
// Don't retry permanent failures
if (error === 'NOT_FOUND') return false;
if (error === 'INVALID_ID') return false;
// Retry transient failures
return true;
},
}
);

Each attempt has its own timeout:

const data = await step.retry(
'fetchData',
() => step.withTimeout('fetchData', () => fetchData(), { ms: 2000 }),
{ attempts: 3, backoff: 'exponential', delayMs: 100 }
);
Timeline:
├── Attempt 1 ──────────────► timeout at 2s
│ (wait 100ms)
├── Attempt 2 ──────────────► timeout at 2s
│ (wait 200ms)
├── Attempt 3 ──────────────► success or final failure

Configure retry and timeout directly in step options:

const user = await step('Fetch user', () => fetchUser('1'), {
retry: {
attempts: 3,
backoff: 'exponential',
delayMs: 100,
jitter: true,
},
timeout: {
ms: 5000,
},
});

By default, thrown exceptions become an UnexpectedError. Both run() and createWorkflow include it in the error union automatically:

// run() — use ErrorOf/Errors to derive E, or specify manually
import { type ErrorOf, type Errors } from 'awaitly';
// Best DX: derive errors from deps
type RunErrors = Errors<[typeof fetchUser, typeof sendEmail]>;
const result = await run<User, RunErrors>(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser('1'));
await step('sendEmail', () => sendEmail(user.email));
return user;
});
// result.error is: 'NOT_FOUND' | 'EMAIL_FAILED' | UnexpectedError

To replace UnexpectedError with a custom type, pass catchUnexpected:

// createWorkflow
const workflow = createWorkflow('workflow', { fetchUser, fetchPosts },
{
catchUnexpected: (thrown) => ({
type: 'UNEXPECTED' as const,
message: String(thrown),
}),
}
);
// result.error is now: 'NOT_FOUND' | 'EMAIL_FAILED' | { type: 'UNEXPECTED', message: string }
// run() — same option available
const result = await run<User, 'NOT_FOUND'>(
async ({ step }) => { /* ... */ },
{ catchUnexpected: (thrown) => ({ type: 'UNEXPECTED' as const, message: String(thrown) }) }
);
// result.error is: 'NOT_FOUND' | { type: 'UNEXPECTED', message: string }
OptionTypeDefaultDescription
attemptsnumberrequiredMax retry attempts
backoff'fixed' | 'linear' | 'exponential''fixed'Delay growth strategy
delayMsnumber0Base delay in milliseconds
maxDelayMsnumberundefinedMaximum delay cap
jitterbooleanfalseAdd random variation
retryOn(error) => boolean() => trueCondition for retry
import { createWorkflow } from 'awaitly/workflow';
import { TaggedError } from 'awaitly';
class NetworkError extends TaggedError('NetworkError')<{
endpoint: string;
statusCode?: number;
}> {}
class NotFoundError extends TaggedError('NotFoundError')<{
resource: string;
id: string;
}> {}
const fetchUserFromApi = async (id: string): AsyncResult<User, NetworkError | NotFoundError> => {
// Implementation
};
const workflow = createWorkflow('workflow', { fetchUserFromApi });
const result = await workflow.run(async ({ step, deps }) => {
const user = await step.retry(
'fetchUser',
() => step.withTimeout(
'fetchUser',
() => deps.fetchUserFromApi('123'),
{ ms: 3000 }
),
{
attempts: 3,
backoff: 'exponential',
delayMs: 200,
jitter: true,
retryOn: (error) => {
// Don't retry NOT_FOUND - user doesn't exist
if (error instanceof NotFoundError) return false;
// Retry network errors
return true;
},
}
);
return user;
});
if (!result.ok) {
TaggedError.match(result.error, {
NetworkError: (e) => console.log(`Network error: ${e.endpoint}`),
NotFoundError: (e) => console.log(`${e.resource} ${e.id} not found`),
});
}

Learn about State and Resumption →