Skip to content

Commit a021fa9

Browse files
committed
Add SmartRetry library: source, tests, CI, and documentation
- Exponential backoff with full jitter - Global timeout across attempts - AbortSignal cancellation support - Intelligent default retry policy (429, 5xx, network errors) - Fully typed TypeScript (ESM + CJS dual build) - 48 deterministic Vitest tests - GitHub Actions CI (Node 18 & 20) - Professional README with badges and API docs
1 parent 1306aeb commit a021fa9

File tree

13 files changed

+3202
-1
lines changed

13 files changed

+3202
-1
lines changed

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
node-version: [18, 20]
16+
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Setup Node.js ${{ matrix.node-version }}
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: ${{ matrix.node-version }}
25+
cache: npm
26+
27+
- name: Install dependencies
28+
run: npm ci
29+
30+
- name: Type check
31+
run: npm run typecheck
32+
33+
- name: Build
34+
run: npm run build
35+
36+
- name: Test
37+
run: npm test

README.md

Lines changed: 261 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,262 @@
11
# SmartRetry
2-
Intelligent, dependency-light TypeScript retry utility with exponential backoff, jitter, timeout, and AbortSignal support.
2+
3+
[![npm version](https://img.shields.io/npm/v/@rootvector/smart-retry.svg)](https://www.npmjs.com/package/@rootvector/smart-retry)
4+
[![npm downloads](https://img.shields.io/npm/dm/@rootvector/smart-retry.svg)](https://www.npmjs.com/package/@rootvector/smart-retry)
5+
[![CI](https://github.com/rootvector2/SmartRetry/actions/workflows/ci.yml/badge.svg)](https://github.com/rootvector2/SmartRetry/actions)
6+
[![License](https://img.shields.io/npm/l/@rootvector/smart-retry.svg)](LICENSE)
7+
8+
Async functions fail. Networks drop, servers return 500s, rate limits kick in. SmartRetry wraps any async function with configurable retry logic — exponential backoff, jitter, global timeouts, and cancellation via `AbortSignal` — so you don't have to write that boilerplate again.
9+
10+
## Features
11+
12+
- **Exponential backoff** with configurable base and max delay
13+
- **Full jitter** to prevent thundering herd problems
14+
- **Global timeout** across all attempts (not per-attempt)
15+
- **AbortSignal** support for cancellation
16+
- **Intelligent default policy** — retries network errors, 429s, and 5xx; stops on 4xx
17+
- **Custom retry predicates** — full control over what gets retried
18+
- **Zero runtime dependencies**
19+
- **ESM + CJS** dual build with proper type declarations
20+
- **Tree-shakeable** and side-effect free
21+
- **Node.js >= 18** and modern browsers
22+
23+
## Why SmartRetry?
24+
25+
Most retry implementations:
26+
- Retry too aggressively
27+
- Do not support global timeout
28+
- Lack AbortSignal support
29+
- Wrap non-retryable errors incorrectly
30+
31+
SmartRetry focuses on correctness, predictable behavior, and production-safe defaults.
32+
33+
## Installation
34+
35+
```bash
36+
npm install @rootvector/smart-retry
37+
```
38+
39+
## Basic Usage
40+
41+
```ts
42+
import { smartRetry } from "@rootvector/smart-retry";
43+
44+
const data = await smartRetry(
45+
async (attempt) => {
46+
const res = await fetch("https://api.example.com/data");
47+
if (!res.ok) {
48+
const err: any = new Error(`HTTP ${res.status}`);
49+
err.status = res.status;
50+
throw err;
51+
}
52+
return res.json();
53+
},
54+
{
55+
maxRetries: 5,
56+
baseDelayMs: 200,
57+
timeoutMs: 10000,
58+
}
59+
);
60+
```
61+
62+
The `attempt` parameter starts at `0` for the initial call. `maxRetries` controls **additional** attempts after the first, so total attempts = `1 + maxRetries`.
63+
64+
## Advanced Usage
65+
66+
### Custom Retry Predicate
67+
68+
```ts
69+
import { smartRetry } from "@rootvector/smart-retry";
70+
71+
await smartRetry(callExternalService, {
72+
maxRetries: 4,
73+
retryOn: (error, attempt) => {
74+
// Only retry on specific conditions
75+
if (error instanceof TypeError) return true;
76+
if (error instanceof Error && error.message.includes("rate limit")) return true;
77+
return false;
78+
},
79+
onRetry: (error, attempt, delay) => {
80+
console.log(`Attempt ${attempt} in ${Math.round(delay)}ms...`);
81+
},
82+
});
83+
```
84+
85+
### Cancellation with AbortSignal
86+
87+
```ts
88+
import { smartRetry, AbortError } from "@rootvector/smart-retry";
89+
90+
const controller = new AbortController();
91+
setTimeout(() => controller.abort(), 3000);
92+
93+
try {
94+
await smartRetry(fn, {
95+
maxRetries: 10,
96+
signal: controller.signal,
97+
});
98+
} catch (err) {
99+
if (err instanceof AbortError) {
100+
// Operation was cancelled
101+
}
102+
}
103+
```
104+
105+
### Global Timeout
106+
107+
```ts
108+
import { smartRetry, TimeoutError } from "@rootvector/smart-retry";
109+
110+
try {
111+
await smartRetry(fn, {
112+
maxRetries: 10,
113+
timeoutMs: 15000, // 15 seconds total, not per-attempt
114+
});
115+
} catch (err) {
116+
if (err instanceof TimeoutError) {
117+
console.error(`Timed out after ${err.totalElapsedMs}ms`);
118+
}
119+
}
120+
```
121+
122+
## API
123+
124+
### `smartRetry<T>(fn, options?): Promise<T>`
125+
126+
Executes `fn` and retries on failure according to the provided options.
127+
128+
| Parameter | Type | Description |
129+
|-----------|------|-------------|
130+
| `fn` | `(attempt: number) => Promise<T>` | Async function to execute. `attempt` is 0-indexed. |
131+
| `options` | `RetryOptions` | Optional configuration. |
132+
133+
### `RetryOptions`
134+
135+
| Option | Type | Default | Description |
136+
|--------|------|---------|-------------|
137+
| `maxRetries` | `number` | `3` | Retry attempts after the initial call. Total = `1 + maxRetries`. |
138+
| `baseDelayMs` | `number` | `300` | Base delay (ms) for exponential backoff. |
139+
| `maxDelayMs` | `number` | `5000` | Upper bound for computed delay. |
140+
| `jitter` | `boolean` | `true` | Apply full jitter: `random(0, computedDelay)`. |
141+
| `retryOn` | `(error, attempt) => boolean` | *default policy* | Return `true` to retry, `false` to stop. |
142+
| `onRetry` | `(error, attempt, delay) => void` || Called before each retry. |
143+
| `timeoutMs` | `number` || Global timeout across all attempts. |
144+
| `signal` | `AbortSignal` || Cancellation signal. |
145+
146+
### `isRetryableHttpError(error: unknown): boolean`
147+
148+
Standalone utility that returns `true` if the error carries an HTTP status of 429 or 500–599. Inspects `error.status` and `error.response?.status`.
149+
150+
## Backoff Algorithm
151+
152+
```
153+
delay = min(maxDelayMs, baseDelayMs * 2^retryIndex)
154+
```
155+
156+
Where `retryIndex` is 0 for the first retry. With jitter enabled, the final delay is `random(0, delay)`.
157+
158+
## Default Retry Policy
159+
160+
When no `retryOn` predicate is provided, SmartRetry uses a built-in policy:
161+
162+
| Condition | Retried? |
163+
|-----------|----------|
164+
| Network errors (`ECONNRESET`, `ETIMEDOUT`, `ENOTFOUND`, `EAI_AGAIN`) | Yes |
165+
| HTTP 429 (Too Many Requests) | Yes |
166+
| HTTP 500–599 (Server errors) | Yes |
167+
| HTTP 400–499 (Client errors, except 429) | No |
168+
| Errors without a recognized status or code | Yes |
169+
170+
## Error Handling
171+
172+
All errors thrown by SmartRetry extend `SmartRetryError` and include:
173+
174+
- `totalAttempts` — number of attempts made
175+
- `totalElapsedMs` — total wall-clock time in milliseconds
176+
- `cause` — the original error (via standard `ErrorOptions`)
177+
178+
| Error Class | When Thrown |
179+
|-------------|------------|
180+
| `RetryExhaustedError` | All retry attempts failed |
181+
| `TimeoutError` | Global `timeoutMs` exceeded |
182+
| `AbortError` | `AbortSignal` was aborted |
183+
184+
When `retryOn` returns `false`, the original error is rethrown directly — it is **not** wrapped in `RetryExhaustedError`.
185+
186+
```ts
187+
import { smartRetry, RetryExhaustedError, TimeoutError } from "@rootvector/smart-retry";
188+
189+
try {
190+
await smartRetry(fn, { maxRetries: 3, timeoutMs: 5000 });
191+
} catch (err) {
192+
if (err instanceof TimeoutError) {
193+
console.error(`Timed out after ${err.totalElapsedMs}ms`);
194+
} else if (err instanceof RetryExhaustedError) {
195+
console.error(`Failed after ${err.totalAttempts} attempts:`, err.cause);
196+
}
197+
}
198+
```
199+
200+
## Real-World Example: API Client
201+
202+
Wrapping an API call with structured retry logic:
203+
204+
```ts
205+
import { smartRetry, TimeoutError, RetryExhaustedError } from "@rootvector/smart-retry";
206+
207+
async function fetchUser(userId: string) {
208+
return smartRetry(
209+
async () => {
210+
const res = await fetch(`https://api.example.com/users/${userId}`);
211+
if (!res.ok) {
212+
const err: any = new Error(`HTTP ${res.status}`);
213+
err.status = res.status;
214+
throw err;
215+
}
216+
return res.json();
217+
},
218+
{
219+
maxRetries: 3,
220+
baseDelayMs: 500,
221+
maxDelayMs: 5000,
222+
timeoutMs: 15000,
223+
onRetry: (error, attempt, delay) => {
224+
console.warn(`Retry ${attempt} for user ${userId} in ${Math.round(delay)}ms`);
225+
},
226+
}
227+
);
228+
}
229+
```
230+
231+
## Configuration Validation
232+
233+
Invalid options throw synchronously with descriptive messages:
234+
235+
- `maxRetries < 0`
236+
- `baseDelayMs <= 0`
237+
- `maxDelayMs < baseDelayMs`
238+
- `timeoutMs <= 0`
239+
240+
## Versioning
241+
242+
SmartRetry follows [Semantic Versioning (SemVer)](https://semver.org/).
243+
244+
- **Patch** — Bug fixes
245+
- **Minor** — Backward-compatible improvements
246+
- **Major** — Breaking API changes
247+
248+
## Contributing
249+
250+
Contributions are welcome. Please open an issue to discuss proposed changes before submitting a pull request.
251+
252+
```bash
253+
git clone https://github.com/rootvector2/SmartRetry.git
254+
cd SmartRetry
255+
npm install
256+
npm test
257+
npm run build
258+
```
259+
260+
## License
261+
262+
MIT

0 commit comments

Comments
 (0)