Retry
@modularityjs/retry is the retry-policy primitive: a RetryPolicy shape plus the computeDelay backoff math (fixed or exponential, capped, optionally jittered). Zero deps, no DI, no Module — like exception and factory, it sits on the infrastructure allowlist and is importable from anywhere without module wiring. The @Retryable method decorator ships separately in @modularityjs/retry-plugins.
The policy
import { computeDelay, type RetryPolicy } from '@modularityjs/retry';
const policy: RetryPolicy = {
maxAttempts: 5,
strategy: 'exponential', // or 'fixed'
delayMs: 100,
maxDelayMs: 5_000, // optional clamp
jitterRatio: 0.2, // optional, 0..1 — ±20% spread
};| Field | Meaning |
|---|---|
maxAttempts | Total attempts, including the first. |
strategy | 'fixed' — every delay is delayMs. 'exponential' — delayMs * 2^(attempt - 1). |
delayMs | Base delay in milliseconds. |
maxDelayMs | Optional upper clamp applied before jitter. |
jitterRatio | Random spread around the computed delay (0.2 = ±20%), clamped to ≥ 0. Use it to avoid thundering-herd retries. |
computeDelay
Pure function from (policy, attempt) to a delay in milliseconds — attempt is 1-based (the delay after the first failure is computeDelay(policy, 1)):
import { computeDelay } from '@modularityjs/retry';
const policy = {
maxAttempts: 4,
strategy: 'exponential' as const,
delayMs: 100,
};
computeDelay(policy, 1); // 100
computeDelay(policy, 2); // 200
computeDelay(policy, 3); // 400The primitive deliberately contains no loop and no sleep — callers own the control flow (a for loop, a queue redelivery, a scheduler re-arm) and just ask for the next delay. That is what makes it reusable across the framework: webhook delivery retries (webhook-direct), queue redelivery (queue-memory, queue-redis), and the @Retryable decorator all share this one piece of backoff math instead of four hand-rolled Math.pow(2, n) copies.
A hand-rolled loop looks like this:
import { computeDelay, type RetryPolicy } from '@modularityjs/retry';
async function withRetry<T>(
policy: RetryPolicy,
fn: () => Promise<T>,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt >= policy.maxAttempts) {
break;
}
await new Promise((r) => setTimeout(r, computeDelay(policy, attempt)));
}
}
throw lastError;
}@Retryable (@modularityjs/retry-plugins)
For DI-managed services, @modularityjs/retry-plugins packages that loop as a method decorator riding the shared plugins interceptor seam:
import { Injectable } from '@modularityjs/di';
import { Retryable } from '@modularityjs/retry-plugins';
@Injectable()
export class PaymentGateway {
@Retryable({ policy: { maxAttempts: 5, delayMs: 250 } })
async charge(orderId: string): Promise<Receipt> {
// transient network failures retry with exponential backoff
}
}Wire the module (it imports PluginsModule itself):
import { RetryPluginsModule } from '@modularityjs/retry-plugins';
const modules = [/* ... */ RetryPluginsModule];A decorated method whose module isn't wired fails boot loudly — the decorator never silently degrades to "no retries".
Options
interface RetryableOptions {
/** Merged over `{ maxAttempts: 3, strategy: 'exponential', delayMs: 100, maxDelayMs: 5000 }`. */
policy?: Partial<RetryPolicy>;
/** Which errors retry. Default: everything except CircuitOpenException. */
retryOn?: (error: unknown) => boolean;
/** Interceptor chain position; lower = outermost. Default 200. */
order?: number;
}retryOnfilters which errors are worth another attempt — validation failures and 4xx-mapped exceptions usually aren't:typescript@Retryable({ retryOn: (error) => error instanceof TimeoutException })The default
retryOnalready excludesCircuitOpenException— never back off past an open circuit (see below).policy.maxAttemptsmust be an integer ≥ 1; anything else is rejected with aValidationExceptionat call time rather than looping zero times.
Composing with a circuit breaker
@Retryable (order 200) wraps outside @WithCircuitBreaker (order 300), so the breaker sits inside the retry loop — every attempt is individually admitted and recorded, and once the circuit opens the default retryOn stops retrying immediately:
import { Retryable } from '@modularityjs/retry-plugins';
import { WithCircuitBreaker } from '@modularityjs/circuit-breaker-plugins';
@Injectable()
export class SearchClient {
@Retryable({ policy: { maxAttempts: 3 } })
@WithCircuitBreaker({ policy: { failureThreshold: 5 } })
async query(term: string): Promise<Results> {
// ...
}
}See Circuit Breaker for the breaker semantics and Plugins for the shared decorator seam (@Cacheable 100 → @CacheEvict 150 → @Retryable 200 → @WithCircuitBreaker 300 → @Transactional 400 — lower is outermost).
When to reach for it
Before writing Math.pow(2, attempt - 1) anywhere — a poller, a webhook sender, a queue consumer — depend on this primitive instead. If the operation you need doesn't exist, extend the primitive rather than reimplementing it in the consumer.
Next Steps
- Circuit Breaker — stop calling a dependency that keeps failing
- Plugins — the method-interceptor seam
@Retryablerides - Queue — redelivery with its own retry policy