retry-circuit
v1.0.0
Published
Universal retry utility with exponential backoff, jitter, timeout and circuit-breaker for any async function — works in Node and browsers.
Maintainers
Readme
retry-circuit
⚡ Why retry-circuit?
Every backend, frontend, or microservice eventually needs to handle transient network hiccups, rate-limit spikes (HTTP 429), or flaky downstream services. But naive retry loops cause problems:
- Thundering Herd Problem: Retrying on fixed intervals makes hundreds of clients slam the struggling server simultaneously.
- Infinite Loops on Fatal Errors: Retrying 400 Bad Request or 401 Unauthorized errors wastes compute and locks user threads.
- Cascading Failures: When a downstream dependency is completely offline, hammering it with retries exhausts connection pools.
retry-circuit solves all three:
- 📈 Flexible Backoff Curves: Exponential, linear, or fixed intervals.
- 🎲 Full Jitter: Randomizes backoff delays to smooth out spike traffic and eliminate the thundering herd effect.
- ⏱️ Per-Attempt Timeouts: Automatically abort slow hangs using
timeoutMs. - 🔌 Built-in Circuit Breaker: Fails fast when downstream services go down, allowing them time to recover.
- 🛑 AbortSignal Support: Seamlessly cancel active retry cycles when a user navigates away or an HTTP request is aborted.
- 🪶 Zero Dependencies & Universal: Runs in Node.js, Bun, Deno, and all modern browsers.
📦 Installation
# npm
npm install retry-circuit
# pnpm
pnpm add retry-circuit
# yarn
yarn add retry-circuit
# bun
bun add retry-circuit🚀 Quick Start
import { retrySmart } from "retry-circuit";
// Automatically retries on network failures with exponential backoff & jitter
const response = await retrySmart(async (attempt) => {
console.log(`Fetch attempt #${attempt}...`);
const res = await fetch("https://api.example.com/orders");
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
return res.json();
}, {
retries: 4,
baseDelayMs: 400,
maxDelayMs: 5000,
});🛠️ Configuration & Options
retrySmart(fn, options)
const data = await retrySmart(
async (attempt) => {
return await queryDatabase();
},
{
// Maximum attempts including the initial call (default: 3)
retries: 5,
// Base delay in milliseconds (default: 300)
baseDelayMs: 500,
// Maximum delay cap in milliseconds (default: 10000)
maxDelayMs: 8000,
// Curve strategy: "exponential" | "linear" | "fixed" (default: "exponential")
strategy: "exponential",
// Add randomized jitter to distribute retries (default: true)
jitter: true,
// Maximum duration allowed per individual attempt in ms (optional)
timeoutMs: 3000,
// Custom predicate: determine if an error is retryable (default: always retry)
shouldRetry: (error, attempt) => {
// Don't retry client 4xx errors
const status = (error as any)?.status;
if (status >= 400 && status < 500 && status !== 429) {
return false;
}
return true;
},
// Callback hook executed prior to waiting for the next attempt
onRetry: (error, attempt, delayMs) => {
console.warn(`[Attempt ${attempt}] Failed. Retrying in ${delayMs}ms... Error:`, error);
},
// Pass an AbortSignal to cancel pending retries immediately
signal: abortController.signal,
}
);🔌 Circuit Breaker Pattern
Prevent catastrophic cascading failures by wrapping calls with CircuitBreaker. When a service exceeds the failure threshold, the circuit trips OPEN and immediately rejects calls without waiting for network timeouts. After a cooldown period, it enters HALF-OPEN to test if the service has recovered.
import { CircuitBreaker, retrySmart } from "retry-circuit";
const paymentBreaker = new CircuitBreaker({
failureThreshold: 4, // Trip open after 4 consecutive failures
cooldownMs: 20000, // Wait 20 seconds before testing recovery
onStateChange: (state) => {
console.log(`[Circuit State] Payment Gateway is now: ${state.toUpperCase()}`);
},
});
async function processPayment(payload: any) {
return paymentBreaker.exec(async () => {
// Combine CircuitBreaker with retrySmart for ultimate resilience
return retrySmart(
() => fetch("https://gateway.example.com/charge", {
method: "POST",
body: JSON.stringify(payload),
}).then(r => r.json()),
{ retries: 3, timeoutMs: 2500 }
);
});
}Circuit States
stateDiagram-v2
[*] --> Closed
Closed --> Open: Consecutive failures >= threshold
Open --> HalfOpen: cooldownMs elapsed
HalfOpen --> Closed: Probe call succeeds
HalfOpen --> Open: Probe call failsclosed: Normal operation. Requests flow through.open: Failing fast. ThrowsCircuitOpenErrorinstantly.half-open: Testing health. If the trial call succeeds, circuit resets toclosed.
🚨 Error Handling
When retries are exhausted or aborted, retrySmart throws typed errors:
import { retrySmart, RetryError, TimeoutError } from "retry-circuit";
try {
await retrySmart(() => callFlakyService(), { retries: 3, timeoutMs: 2000 });
} catch (err) {
if (err instanceof RetryError) {
console.error(`Gave up after ${err.attempts} attempts.`);
console.error("Root cause:", err.lastError);
} else if (err instanceof TimeoutError) {
console.error("Single attempt timed out:", err.message);
}
}🛑 Cancellation with AbortSignal
Easily abort in-flight retry delays (e.g. when an Express client disconnects or React unmounts):
const controller = new AbortController();
// Abort if user clicks "Cancel"
cancelBtn.addEventListener("click", () => controller.abort());
try {
await retrySmart(() => downloadLargeFile(), {
retries: 5,
signal: controller.signal,
});
} catch (err) {
if (err instanceof RetryError && err.message.includes("aborted")) {
console.log("Operation cancelled by user.");
}
}📚 API Reference
Exported Functions & Classes
| Export | Type | Description |
|---|---|---|
| retrySmart(fn, options?) | Function | Executes an async function with automated backoff, jitter, and retry policies. |
| CircuitBreaker | Class | Three-state circuit breaker (closed, open, half-open). |
| RetryError | Class (Error) | Thrown when all attempts fail or execution is aborted. Has .attempts and .lastError. |
| TimeoutError | Class (Error) | Thrown when timeoutMs expires on an attempt. |
| CircuitOpenError | Class (Error) | Thrown when calls are attempted while the circuit breaker is open. |
