@onelifepolymath/retryflow
v0.1.1
Published
Elegant async retry with advanced backoff strategies for resilient operations
Maintainers
Readme
retryflow
Elegant async retry with advanced backoff strategies for resilient operations.
Network requests, database connections, and external API calls fail. retryflow makes them resilient with intelligent retry logic, multiple backoff strategies, and jitter — all in a zero-dependency TypeScript package.
Features
| Feature | Description |
|---------|-------------|
| Multiple Backoff Strategies | Exponential, linear, and fixed delay strategies |
| Jitter Modes | Full jitter and equal jitter to prevent thundering herd |
| Conditional Retry | shouldRetry callback to filter which errors trigger retries |
| Success Hooks | onSuccess for recovery observability after failures |
| Retry Hooks | onRetry for logging, metrics, and side effects |
| Retry Policies | Pre-built configurations (exponential, linear, fixedDelay, aggressive) |
| Respect Retry-After | respectRetryAfter uses error's retryAfter property as the delay |
| Timeout Support | Per-attempt timeout and total elapsed time budget |
| Cancellation | AbortSignal support for external cancellation; signal forwarded to your function |
| Fallback on Exhaustion | Static value or fallback function when all retries fail |
| Fluent Builder API | RetryPipeline — chainable builder for complex configurations |
| Retry Events | Discriminated union events for every lifecycle point (onEvent) |
| Context Propagation | Pass typed context through all hooks and callbacks |
| Pre-configured Retry | withRetryDefaults factory for reusable retry instances |
| Function Wrapping | wrapRetry decorator preserves type signatures |
| Type Safe | Full TypeScript generics — inferred return types |
| Zero Dependencies | No runtime dependencies, minimal attack surface |
| Dual Module | CJS (require) and ESM (import) |
Installation
npm install @onelifepolymath/retryflowQuick Start
import { retryflow } from 'retryflow';
const data = await retryflow(async (attempt) => {
const res = await fetch('https://api.example.com/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}, {
maxAttempts: 3,
baseDelay: 1000,
backoffType: 'exponential',
jitter: 'full',
});Usage
CommonJS
const { retryflow } = require('retryflow');
async function main() {
const result = await retryflow(async (attempt) => {
const res = await fetch('https://api.example.com/data');
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}, {
maxAttempts: 3,
baseDelay: 500,
});
console.log(result);
}Conditional Retry with shouldRetry
import { retryflow } from 'retryflow';
const data = await retryflow(async (attempt) => {
const res = await fetch('https://api.example.com/data');
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`);
(error as any).status = res.status;
throw error;
}
return res.json();
}, {
shouldRetry: (error, attempt) => {
// Don't retry 4xx errors, retry 5xx and network errors
return (error as any).status >= 500 || (error as any).status === 429;
},
});Logging with onRetry
import { retryflow } from 'retryflow';
const data = await retryflow(async (attempt) => {
// ... flaky operation
}, {
onRetry: (error, attempt) => {
console.warn(`Attempt ${attempt} failed: ${error.message}. Retrying...`);
},
});Observing Recovery with onSuccess
import { retryflow } from 'retryflow';
const data = await retryflow(async (attempt) => {
// ... flaky operation
}, {
onSuccess: (result, attempt) => {
console.log(`Recovered on attempt ${attempt}`);
},
});The onSuccess callback fires only when the function succeeds after at least one failure. It does not fire on first-attempt success.
Rate-Limit Aware Retry with respectRetryAfter
import { retryflow } from 'retryflow';
const data = await retryflow(async (attempt) => {
const res = await fetch('https://api.example.com/data');
if (res.status === 429) {
const error = new Error('Rate limited');
// Set retryAfter (in ms) to override the calculated delay
(error as any).retryAfter = 5000;
throw error;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}, {
respectRetryAfter: true,
});When respectRetryAfter is true and the thrown error has a numeric retryAfter property, that value is used as the delay instead of the calculated backoff.
Custom Delay with delayFn
import { retryflow } from 'retryflow';
const data = await retryflow(async (attempt) => {
// ... flaky operation
}, {
delayFn: (attempt, error) => {
// Exponential: 2^attempt * 1000
return Math.pow(2, attempt) * 1000;
},
});When delayFn is provided, it completely replaces the built-in backoff calculation. Use it for custom delay algorithms, circuit-breaker-aware delays, or dynamic delays based on error properties.
Using Retry Policies
import { retryflow, policies } from 'retryflow';
// Use pre-built policies with optional overrides
const data = await retryflow(fn, policies.exponential({ maxAttempts: 5 }));
// or: policies.linear(), policies.fixedDelay(), policies.aggressive()Fallback on Exhaustion
When all retry attempts fail, you can provide a fallback value or function instead of throwing:
import { retryflow } from 'retryflow';
// Static fallback value
const data = await retryflow(flakyFn, {
fallback: 'cached_default',
});
// Fallback function — receives attempt count and all errors
const data = await retryflow(flakyFn, {
fallback: async (attempt, errors) => {
console.warn(`Failed ${errors.length} times, using cache`);
return await cache.get('fallback');
},
});Retry Events (onEvent)
A unified event callback for deep observability into the retry lifecycle:
import { retryflow } from 'retryflow';
const data = await retryflow(fn, {
onEvent: (event) => {
switch (event.type) {
case 'attempt':
console.log(`Attempt ${event.attempt} starting...`);
break;
case 'success':
console.log(`Attempt ${event.attempt} succeeded (${event.duration}ms)`);
break;
case 'error':
console.log(`Attempt ${event.attempt} failed: ${event.error.message}`);
break;
case 'retry':
console.log(`Retrying attempt ${event.attempt} after ${event.delay}ms`);
break;
case 'abort':
console.log('Operation aborted');
break;
case 'failure':
console.log(`Failed (${event.reason}): ${event.error.message}`);
break;
case 'fallback':
console.log(`Using fallback after ${event.errors.length} errors`);
break;
case 'timeout':
console.log(`Attempt timed out after ${event.timeoutMs}ms`);
break;
}
},
});Event types:
| Event | Shape | When it fires |
|-------|-------|---------------|
| attempt | { type, attempt, signal? } | Before each attempt |
| success | { type, attempt, result, duration } | On successful attempt |
| error | { type, attempt, error, duration } | On failed attempt |
| retry | { type, attempt, error, delay } | Before retry delay |
| abort | { type, attempt, errors } | On abort signal |
| failure | { type, attempt, error, errors, reason } | On final failure (reason: exhausted, non-retryable, aborted, timeout) |
| fallback | { type, attempt, errors } | When fallback is used |
| timeout | { type, attempt, timeoutMs } | When per-attempt timeout fires |
Fluent Builder with RetryPipeline
For complex configurations, use the fluent builder API:
import { RetryPipeline } from 'retryflow';
const result = await RetryPipeline
.configure({ maxAttempts: 3 })
.baseDelay(500)
.backoff('exponential')
.jitter('full')
.timeout(5000)
.maxElapsed(15000)
.withFallback('degraded')
.withContext({ requestId: 'abc' })
.onRetry((error, attempt, ctx) => {
logger.warn(`[${ctx.requestId}] Attempt ${attempt}: ${error.message}`);
})
.run(async (attempt, signal) => {
const res = await fetch(url, { signal });
return res.json();
});Using AbortSignal
The signal is forwarded to your function as a second argument, so you can cancel the underlying operation itself:
import { retryflow } from 'retryflow';
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const data = await retryflow(async (attempt, signal) => {
const res = await fetch(url, { signal });
return res.json();
}, {
signal: controller.signal,
});Configuration
RetryOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| maxAttempts | number | 3 | Total attempts including the initial call |
| retries | number | — | Number of retries (overrides maxAttempts if set, using maxAttempts = retries + 1) |
| baseDelay | number | 1000 | Base delay in milliseconds |
| backoffType | 'exponential' \| 'linear' \| 'fixed' | 'exponential' | Backoff strategy |
| maxDelay | number | Infinity | Maximum delay cap in milliseconds |
| jitter | 'full' \| 'equal' \| 'none' | 'none' | Jitter mode |
| shouldRetry | (error, attempt) => boolean \| Promise<boolean> | () => true | Called after each failure. Return false to stop retrying |
| onRetry | (error, attempt) => void \| Promise<void> | — | Called before each retry attempt |
| onSuccess | (result, attempt) => void \| Promise<void> | — | Called when a retry succeeds after at least one failure |
| delayFn | (attempt, error) => number \| Promise<number> | — | Replaces the built-in delay calculation with a custom function |
| maxElapsedMs | number | Infinity | Maximum total elapsed time in milliseconds |
| attemptTimeoutMs | number | Infinity | Timeout per attempt in milliseconds |
| signal | AbortSignal | — | AbortSignal for external cancellation |
| respectRetryAfter | boolean | false | When true, uses error's numeric retryAfter property (ms) as the delay |
| fallback | T \| (attempt, errors) => T \| Promise<T> | — | Static value or function returning a fallback result when all retries are exhausted |
| onEvent | (event: RetryEvent) => void \| Promise<void> | — | Unified callback for all retry lifecycle events (attempt, success, error, retry, abort, failure, fallback, timeout) |
Backoff Strategies
Exponential (default)
delay = min(maxDelay, baseDelay * 2^attempt)Doubles the delay after each attempt. Use when you want to quickly back off from a failing service.
Linear
delay = min(maxDelay, baseDelay * (attempt + 1))Increases delay linearly. Use for rate-limited APIs or predictable backoff.
Fixed
delay = baseDelayConstant delay between all attempts. Use for polling or steady-interval retries.
Jitter Modes
Full Jitter
delay = Math.random() * calculatedDelayRandom delay between 0 and the calculated backoff. Best for preventing thundering herd problems. Recommended by AWS.
Equal Jitter
delay = calculatedDelay * (0.5 + Math.random() * 0.5)Keeps delay between 50-100% of the calculated value. Good balance of randomness and minimum wait time.
None
delay = calculatedDelayNo randomness. Use for deterministic retry intervals where jitter is not needed.
Error Handling
When all retry attempts are exhausted (or retry is stopped) and no fallback is provided, retryflow throws a RetryError:
import { retryflow, RetryError, isRetryError } from 'retryflow';
try {
const result = await retryflow(flakyFn, { maxAttempts: 3 });
} catch (error) {
if (isRetryError(error)) {
console.error(`Failed after ${error.attempts} attempts`);
console.error('All errors:', error.retryErrors);
console.error('Last error cause:', error.cause);
}
}RetryError properties:
| Property | Type | Description |
|----------|------|-------------|
| .message | string | Reason for stopping ('All retry attempts failed', 'Non-retryable error: ...', etc.) |
| .attempts | number | Total attempts made |
| .retryErrors | Error[] | Every error encountered during retries |
| .cause | Error \| undefined | The last error that occurred (using native Error.cause) |
API Reference
retryflow<T>(fn, options?)
Type parameters:
T— Return type of the function (inferred automatically)
Parameters:
fn—(attempt: number, signal?: AbortSignal) => Promise<T>— The async function to retry. Receives the 1-indexed attempt number and optionally theAbortSignalfrom options.options—RetryOptions(optional)
Returns: Promise<T> — Resolves with the function's return value.
Throws: RetryError — When all attempts fail or a non-retryable error occurs.
wrapRetry<T>(fn, options?)
Wraps an async function with retry logic, returning a new function with the same signature.
Type parameters:
T— Tuple of argument types (inferred)
Parameters:
fn—(...args: T) => Promise<U>— The async function to wrapoptions—RetryOptions(optional)
Returns: (...args: T) => Promise<U> — A new function with retry built in.
withRetryDefaults(options)
Creates a pre-configured retry function with baked-in default options.
Parameters:
options—RetryOptions— Default options for all retries
Returns: <T>(fn, overrides?) => Promise<T> — A retry function that merges defaults with per-call overrides.
const query = withRetryDefaults({ maxAttempts: 3, baseDelay: 500 });
const users = await query(() => db.findUsers());
const posts = await query(() => db.findPosts(), { maxAttempts: 5 }); // overrideRetryPipeline
A fluent builder API for configuring and running retry operations with context propagation.
Static methods:
RetryPipeline.configure(options?)— Creates a new pipeline instance
Builder methods (chainable):
| Method | Description |
|--------|-------------|
| .configure(options) | Merge additional options |
| .retries(n) | Set max attempts |
| .baseDelay(ms) | Set base delay |
| .backoff(type) | Set backoff strategy |
| .jitter(type) | Set jitter mode |
| .maxDelay(ms) | Cap the delay |
| .timeout(ms) | Per-attempt timeout |
| .maxElapsed(ms) | Total elapsed time budget |
| .signal(AbortSignal) | Attach abort signal |
| .respectRetryAfter(bool) | Enable/disable retry-after |
| .delayFn(fn) | Custom delay function |
| .withFallback(value \| fn) | Fallback value or function |
| .withContext(ctx) | Attach typed context |
| .shouldRetry(fn) | Conditional retry with context |
| .onRetry(fn) | Retry hook with context |
| .onSuccess(fn) | Success hook with context |
| .onFallback(fn) | Fallback hook with context |
| .onEvent(fn) | Unified event hook with context |
| .clone() | Create independent copy |
Terminal method:
pipeline.run<T>(fn): Promise<T>— Executes the operation with the configured options. Context-aware hooks receive(error, attempt, ctx).
Type parameters:
C— Context type (inferred from.withContext())
const pipeline = RetryPipeline
.configure({ baseDelay: 200 })
.jitter('full')
.withContext({ region: 'us-east' })
.onRetry((err, attempt, ctx) => {
console.error(`[${ctx.region}] attempt ${attempt} failed`);
});
// Run with per-call overrides
const result = await pipeline
.clone()
.retries(5)
.withFallback('default')
.run(myAsyncFn);Examples
Database query with retry
import { retryflow } from 'retryflow';
import { query } from './db';
const users = await retryflow(
() => query('SELECT * FROM users WHERE active = ?', [true]),
{ maxAttempts: 3, baseDelay: 200, backoffType: 'exponential' },
);API call with per-attempt timeout
import { retryflow } from 'retryflow';
const data = await retryflow(async (attempt) => {
const res = await fetch('https://api.example.com/data');
return res.json();
}, {
maxAttempts: 3,
attemptTimeoutMs: 5000,
maxElapsedMs: 15000,
});Using retries option (alternative to maxAttempts)
// These are equivalent:
retryflow(fn, { retries: 2 }); // 1 initial + 2 retries = 3 total
retryflow(fn, { maxAttempts: 3 }); // 3 total attemptswrapRetry
Wraps any async function with retry logic, preserving its type signature.
import { wrapRetry } from 'retryflow';
const queryWithRetry = wrapRetry(
async (id: number) => db.query('SELECT * FROM users WHERE id = ?', [id]),
{ maxAttempts: 3, baseDelay: 200 },
);
// Type-safe: id is inferred as number, return type is inferred
const user = await queryWithRetry(42);Default Import
import retryflow from 'retryflow';
// Same as named import
await retryflow(async () => { /* ... */ });Advanced: calculateDelay
For custom retry logic, you can use the delay calculator directly:
import { calculateDelay } from 'retryflow';
const delay = calculateDelay(0, {
maxAttempts: 3,
baseDelay: 1000,
backoffType: 'exponential',
maxDelay: 30000,
jitter: 'full',
maxElapsedMs: Infinity,
attemptTimeoutMs: Infinity,
});Exported API
| Export | Type | Description |
|--------|------|-------------|
| retryflow (named + default) | function | Main retry function |
| wrapRetry | function | Decorator to wrap functions with retry |
| withRetryDefaults | function | Factory for pre-configured retry instances |
| RetryPipeline | class | Fluent builder API for retry with context |
| policies | object | Pre-built retry policies (.exponential, .linear, .fixedDelay, .aggressive) |
| RetryError | class | Error thrown on retry exhaustion |
| isRetryError | function | Type guard for RetryError (error is RetryError) |
| calculateDelay | function | Pure delay calculation utility |
| RetryOptions | interface | Configuration options type |
| BackoffType | type | 'exponential' \| 'linear' \| 'fixed' |
| JitterType | type | 'full' \| 'equal' \| 'none' |
| AsyncFn | type | (...args: T) => Promise<U> |
| RetryFn | type | (attempt: number, signal?: AbortSignal) => Promise<T> |
| FallbackFn | type | (attempt: number, errors: Error[]) => T \| Promise<T> |
| PipelineOptions | interface | Options type for RetryPipeline |
| RetryEvent | type | Discriminated union of lifecycle event objects |
| RetryEventType | type | 'attempt' \| 'success' \| 'error' \| 'retry' \| 'abort' \| 'failure' \| 'fallback' \| 'timeout' |
License
MIT
