retry
v0.1.2@backendkit-labs/retry
Enterprise retry without the boilerplate. Returns Result<T, RetryError>, never throws.
Overview#
retry() wraps any async task and returns Result<T, RetryError> — it never throws. Errors become explicit values: the type system forces you to handle both the success and failure paths at every call site.
The library is built in two layers. The standalone retry() function covers 90% of use cases in two lines. RetryEngine is the stateful core — use it when you need shared config, per-engine metrics, or duck-typed integrations with circuit breaker, bulkhead, and observability.
Backoff Strategies#
Three built-in strategies, all composable with JitterDecorator. Pass a shorthand config object or a strategy instance directly.
| Prop | Type | Description |
|---|---|---|
| fixed | { type: 'fixed', baseDelay } | Same delay every retry. Predictable, good for queues. |
| linear | { type: 'linear', baseDelay, multiplier?, maxDelay? } | Delay grows linearly. Gentler ramp than exponential. |
| exponential | { type: 'exponential', baseDelay, multiplier?, maxDelay?, jitter? } | Delay doubles each attempt. Add jitter: 'full' | 'equal' | 'decorrelated' to prevent thundering herd. |
Configuration#
| Prop | Type | Description |
|---|---|---|
| maxAttempts | number | Total attempts including the first. maxAttempts: 3 = 1 call + 2 retries. |
| backoff | BackoffConfig | BackoffStrategy | Delay strategy between retries. |
| retryIf | (error) => boolean | Custom predicate — return false to stop retrying. Default: retry on 5xx, network, timeout. |
| abortIf | (error) => boolean | Return true to abort immediately without consuming remaining attempts. Default: abort on 4xx (except 429). |
| timeout.attemptTimeoutMs | number | Cap per-call duration. Exceeded attempts fail with type: "timeout" and are retried. |
| timeout.globalTimeoutMs | number | Cap the entire retry operation including delays. Abort when exceeded. |
| budget.windowMs | number | Sliding window for retry ratio tracking (ms). |
| budget.maxRetryRatio | number | Max fraction of calls that may be retries. Prevents retry storms. |
| fallback | (error) => T | Return a default value on exhaustion instead of err(...). |
| dynamicDelay | (error, attempt) => number | Override backoff for this attempt — useful for Retry-After headers. |
| hooks | RetryHooks | Lifecycle hooks: beforeRetry, afterRetry, onRetrySuccess, onExhausted, onBudgetExhausted. |
| idempotency | Partial<IdempotencyConfig> | Cache successful results and return them on duplicate calls. See Idempotency below. |
NestJS Integration#
Import RetryModule once. It registers RetryService and an optional global RetryInterceptor. Use @Retry() on any service method — the interceptor handles the retry loop without changing the method signature.
import { RetryModule } from '@backendkit-labs/retry/nestjs'; @Module({ imports: [ RetryModule.forRoot({ engineConfig: { name: 'default', defaultConfig: { maxAttempts: 3, backoff: { type: 'exponential', baseDelay: 200, jitter: 'full' }, }, }, }), ], }) export class AppModule {} // payment.service.ts import { Retry, RetryService } from '@backendkit-labs/retry/nestjs'; @Injectable() export class PaymentService { constructor(private readonly retry: RetryService) {} // Option 1 — inject RetryService and call .execute() async charge(order: Order) { const result = await this.retry.execute( () => this.gateway.charge(order), { maxAttempts: 4, backoff: { type: 'exponential', baseDelay: 300 } }, ); if (!result.ok) throw new ServiceUnavailableException(result.error.message); return result.value; } // Option 2 — @Retry() decorator + RetryInterceptor @Retry({ maxAttempts: 3, backoff: { type: 'exponential', baseDelay: 150 } }) async reserveStock(sku: string, qty: number) { return this.inventoryApi.post('/reserve', { sku, qty }); } }
Idempotency#
Non-idempotent operations (payment charges, email sends, inventory deductions) can succeed server-side even when the response never reaches the client. A naive retry would execute the operation twice. The idempotency layer prevents this: the first successful result is serialized and cached; subsequent calls with the same key return the cached value immediately — the task is never invoked again.
| Prop | Type | Description |
|---|---|---|
| enabled | boolean | Must be true to activate idempotency. |
| key | string | Unique key for this logical operation, e.g. "charge:order-123". |
| store | IdempotencyStore | Storage backend. Implement IdempotencyStore for Redis, SQL, etc. |
| ttlMs | number | How long to keep the cached result (ms). Default: 24 h. |
InMemoryIdempotencyStore is process-local and loses state on restart. For production, implement IdempotencyStore backed by Redis or a database so all instances share the same cache.
Examples#
From basic to production-grade — copy and adapt.
import { retry } from '@backendkit-labs/retry'; const result = await retry( () => paymentGateway.charge(orderId, amount), { maxAttempts: 4, backoff: { type: 'exponential', baseDelay: 300, maxDelay: 5_000, jitter: 'full' }, hooks: { beforeRetry: ({ attempt, delayMs, error }) => logger.warn(`Payment retry ${attempt}: ${error.message} — waiting ${Math.round(delayMs)}ms`), onExhausted: ({ totalAttempts }) => metrics.increment('payment.exhausted', { attempts: totalAttempts }), }, }, ); if (result.ok) { return { transactionId: result.value.id }; } else { const { type, message, metadata } = result.error; throw new PaymentException(message, { type, attempts: metadata.attempts }); }