Errors and Retries
awaitly provides unified error handling: automatic type inference, structured error types, and resilience patterns like retries and timeouts.
Error Propagation
Section titled “Error Propagation”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 propagationconst 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 resultconst result = await workflow.run(async ({ step, deps }) => { const payment = await step('makePayment', () => deps.makePayment()); return payment;});
// Handle errors at the boundaryif (!result.ok) { switch (result.error.type ?? result.error) { case 'PAYMENT_FAILED': await handleFailedPayment(result.error); break; }}Automatic Error Inference
Section titled “Automatic Error Inference”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' | UnexpectedErrorAdd a new dependency? The error union updates automatically.
UnexpectedError
Section titled “UnexpectedError”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}String Literals vs TaggedError
Section titled “String Literals vs TaggedError”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 Case | Recommendation |
|---|---|
| Simple distinct states | String literals: 'NOT_FOUND' | 'UNAUTHORIZED' |
| Errors with context | TaggedError: NotFoundError { id, resource } |
| Multiple variants to handle | TaggedError with match() |
String Literals
Section titled “String Literals”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');};TaggedError for Rich Context
Section titled “TaggedError for Rich Context”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);};Pattern Matching with TaggedError
Section titled “Pattern Matching with TaggedError”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);}Timeouts
Section titled “Timeouts”Limit how long a step can run:
const data = await step.withTimeout( 'slowOp', () => slowOperation(), { ms: 5000 });Timeout Behavior Variants
Section titled “Timeout Behavior Variants”// Default - return timeout errorconst data = await step.withTimeout( 'slowOp', () => slowOperation(), { ms: 5000, onTimeout: 'error' });// Returns StepTimeoutError on timeout// Treat timeout as optional - return undefinedconst data = await step.withTimeout( 'optionalEnrichment', () => optionalEnrichment(), { ms: 1000, onTimeout: 'option' });// data is undefined if timeout, no error// Custom error typeconst data = await step.withTimeout( 'apiCall', () => apiCall(), { ms: 5000, onTimeout: ({ name, ms }) => ({ _tag: 'API_TIMEOUT' as const, operation: name, waited: ms, }), });Checking Timeout Errors
Section titled “Checking Timeout Errors”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`);}Retries
Section titled “Retries”Retry failed steps with configurable backoff:
const data = await step.retry( 'fetchData', () => fetchData(), { attempts: 3, backoff: 'exponential', delayMs: 100, });Backoff Strategies
Section titled “Backoff Strategies”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 │ 800msCapping Delays
Section titled “Capping Delays”Prevent delays from growing too large:
{ attempts: 10, backoff: 'exponential', delayMs: 100, maxDelayMs: 5000, // Never wait more than 5 seconds}Adding Jitter
Section titled “Adding Jitter”Randomize delays to avoid thundering herd:
{ attempts: 3, backoff: 'exponential', delayMs: 100, jitter: true, // Adds ±50% random variation}Conditional Retry
Section titled “Conditional Retry”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; }, });Combining Retry and Timeout
Section titled “Combining Retry and Timeout”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 failureVia Step Options
Section titled “Via Step Options”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, },});Custom unexpected errors
Section titled “Custom unexpected errors”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 manuallyimport { type ErrorOf, type Errors } from 'awaitly';
// Best DX: derive errors from depstype 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' | UnexpectedErrorTo replace UnexpectedError with a custom type, pass catchUnexpected:
// createWorkflowconst 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 availableconst 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 }Summary Table
Section titled “Summary Table”| Option | Type | Default | Description |
|---|---|---|---|
attempts | number | required | Max retry attempts |
backoff | 'fixed' | 'linear' | 'exponential' | 'fixed' | Delay growth strategy |
delayMs | number | 0 | Base delay in milliseconds |
maxDelayMs | number | undefined | Maximum delay cap |
jitter | boolean | false | Add random variation |
retryOn | (error) => boolean | () => true | Condition for retry |
Complete Example
Section titled “Complete Example”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`), });}