Skip to content

Commit 8a8309d

Browse files
committed
Complex retry policies implemented
1 parent a20e539 commit 8a8309d

7 files changed

Lines changed: 121 additions & 40 deletions

File tree

README.md

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @gkoos/ffetch
22

3-
**A TypeScript-first fetch wrapper that adds production-grade resilience in <4 kB.**
3+
**A production-ready TypeScript-first drop-in replacement for native fetch.**
44

55
- **Timeouts** – per-request or global
66
- **Retries** – exponential back-off + jitter
@@ -24,13 +24,15 @@ import createClient from '@gkoos/ffetch'
2424
const f = createClient({
2525
timeout: 5000,
2626
retries: 3,
27-
retryDelay: (n) => 2 ** n * 100 + Math.random() * 100,
27+
retryDelay: ({ attempt }) => 2 ** attempt * 100 + Math.random() * 100,
2828
})
2929

3030
const res = await f('https://api.example.com/v1/users')
3131
const data = await res.json()
3232
```
3333

34+
---
35+
3436
## API
3537

3638
createClient(options?)
@@ -39,7 +41,7 @@ createClient(options?)
3941
| ------------ | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
4042
| `timeout` | `number` (ms) | whole-request timeout |
4143
| `retries` | `number` (0) | max retry attempts |
42-
| `retryDelay` | `number \| fn` (exponential backoff + jitter) | delay between retries |
44+
| `retryDelay` | `number \| (ctx: { attempt, request, response, error }) => number` (exponential backoff + jitter) | delay between retries |
4345
| `circuit` | `{ threshold, reset }` | circuit-breaker rules |
4446
| `hooks` | `{ before, after, onError, onRetry, onTimeout, onAbort, onCircuitOpen, onComplete, transformRequest, transformResponse }` | lifecycle hooks/interceptors, transformers |
4547

@@ -52,6 +54,22 @@ type FFetch = (
5254
) => Promise<Response>
5355
```
5456
57+
### Defaults
58+
59+
| Option | Default Value / Logic |
60+
| ------------- | ---------------------------------------------------------------------------------------------------- |
61+
| `timeout` | `5000` ms (5 seconds) |
62+
| `retries` | `0` (no retries) |
63+
| `retryDelay` | Exponential backoff + jitter: <br>`({ attempt }) => 2 ** attempt * 200 + Math.random() * 100` |
64+
| `shouldRetry` | Retries on network errors, HTTP 5xx, or 429. <br>Does not retry on 4xx (except 429) or abort/timeout |
65+
| `circuit` | `undefined` (circuit breaker disabled by default) |
66+
| `hooks` | `{}` (no hooks by default) |
67+
68+
**Note:**
69+
70+
- The first retry attempt uses `attempt = 2` (i.e., the first call is attempt 1, first retry is 2).
71+
- `shouldRetry` default logic: retries on network errors, HTTP 5xx, or 429; does not retry on 4xx (except 429), abort, or timeout errors.
72+
5573
## Advanced
5674
5775
### Per-request overrides
@@ -69,7 +87,7 @@ await f('https://api.example.com/v1/users', {
6987

7088
// Use a custom retry delay function for a single request
7189
await f('https://api.example.com/v1/data', {
72-
retryDelay: (attempt) => 100 * attempt, // linear backoff for this request
90+
retryDelay: ({ attempt }) => 100 * attempt, // linear backoff for this request
7391
})
7492

7593
// Override hooks for a single request
@@ -80,6 +98,33 @@ await f('https://api.example.com/v1/metrics', {
8098
})
8199
```
82100

101+
### Retry/Backoff and Retry Policy
102+
103+
#### retryDelay
104+
105+
You can provide a function for `retryDelay` that receives a context object:
106+
107+
```typescript
108+
retryDelay: ({ attempt, request, response, error }) => {
109+
// attempt: number (starts at 2 for first retry)
110+
// request: Request
111+
// response: Response | undefined
112+
// error: unknown
113+
return 100 * attempt
114+
}
115+
```
116+
117+
#### shouldRetry
118+
119+
You can provide a function for `shouldRetry` that receives the same context object:
120+
121+
```typescript
122+
shouldRetry: ({ attempt, request, response, error }) => {
123+
// Retry only on 503
124+
return response?.status === 503
125+
}
126+
```
127+
83128
### Custom Error Types
84129

85130
`ffetch` throws custom error classes for robust error handling. You can catch and handle these errors as needed:
@@ -209,7 +254,7 @@ These hooks allow you to inject authentication, modify request/response bodies,
209254

210255
---
211256

212-
### Note on Timeout vs Abort Errors
257+
## Note on Timeout vs Abort Errors
213258

214259
In most environments, `ffetch` will throw a `TimeoutError` if a request times out, and an `AbortError` if the user aborts the request. However, due to differences in how abort signals are handled in Node.js, browsers, and CI environments, a timeout may sometimes surface as an `AbortError` instead of a `TimeoutError` (especially in automated test environments).
215260

@@ -229,6 +274,11 @@ try {
229274

230275
This is a pragmatic workaround for cross-environment compatibility.
231276

232-
### License
277+
## Planned Features
278+
279+
- Middleware support
280+
- Built-in caching
281+
282+
## License
233283

234284
MIT © 2025 gkoos

src/client.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,16 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
7676

7777
// Wrap shouldRetry to call onRetry hook
7878
let attempt = 0
79-
const shouldRetryWithHook = (err: unknown, res?: Response) => {
80-
attempt++
81-
const retrying = effectiveShouldRetry(err, res)
79+
const shouldRetryWithHook = (ctx: import('./types').RetryContext) => {
80+
attempt = ctx.attempt
81+
const retrying = effectiveShouldRetry(ctx)
8282
if (retrying && attempt <= effectiveRetries) {
83-
effectiveHooks.onRetry?.(request, attempt - 1, err, res)
83+
effectiveHooks.onRetry?.(
84+
request,
85+
attempt - 1,
86+
ctx.error,
87+
ctx.response
88+
)
8489
}
8590
return retrying
8691
}
@@ -132,7 +137,8 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
132137
},
133138
effectiveRetries,
134139
effectiveRetryDelay,
135-
shouldRetryWithHook
140+
shouldRetryWithHook,
141+
request
136142
)
137143
clearTimeout(timeoutTimer)
138144
if (effectiveHooks.transformResponse) {

src/retry.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,42 @@
1-
export type RetryDelay = number | ((attempt: number) => number)
1+
import type { RetryContext } from './types.js'
22

3-
export const defaultDelay: RetryDelay = (n) =>
4-
2 ** n * 200 + Math.random() * 100
3+
export type RetryDelay = number | ((ctx: RetryContext) => number)
54

6-
export async function retry<T>(
7-
fn: () => Promise<T>,
5+
export const defaultDelay: RetryDelay = (ctx) =>
6+
2 ** ctx.attempt * 200 + Math.random() * 100
7+
8+
export async function retry(
9+
fn: () => Promise<Response>,
810
retries: number,
911
delay: RetryDelay,
10-
shouldRetry: (err: unknown, res?: T) => boolean = () => true
11-
): Promise<T> {
12+
shouldRetry: (ctx: RetryContext) => boolean = () => true,
13+
request: Request
14+
): Promise<Response> {
1215
let lastErr: unknown
13-
let lastRes: T | undefined
16+
let lastRes: Response | undefined
1417

1518
for (let i = 0; i <= retries; i++) {
19+
const ctx: RetryContext = {
20+
attempt: i + 1,
21+
request,
22+
response: lastRes,
23+
error: lastErr,
24+
}
1625
try {
1726
lastRes = await fn()
18-
// Check if we should retry based on the resolved value (e.g., HTTP status)
19-
if (i < retries && shouldRetry(undefined, lastRes)) {
20-
const wait = typeof delay === 'function' ? delay(i + 1) : delay
27+
ctx.response = lastRes
28+
ctx.error = undefined
29+
if (i < retries && shouldRetry(ctx)) {
30+
const wait = typeof delay === 'function' ? delay(ctx) : delay
2131
await new Promise((r) => setTimeout(r, wait))
2232
continue
2333
}
2434
return lastRes
2535
} catch (err) {
2636
lastErr = err
27-
if (i === retries || !shouldRetry(err, lastRes)) throw err
28-
const wait = typeof delay === 'function' ? delay(i + 1) : delay
37+
ctx.error = err
38+
if (i === retries || !shouldRetry(ctx)) throw err
39+
const wait = typeof delay === 'function' ? delay(ctx) : delay
2940
await new Promise((r) => setTimeout(r, wait))
3041
}
3142
}

src/should-retry.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { AbortError, CircuitOpenError, TimeoutError } from './error.js'
2+
import type { RetryContext } from './types.js'
23

3-
export function shouldRetry(err: unknown, res?: Response): boolean {
4+
export function shouldRetry(ctx: RetryContext): boolean {
5+
const { error, response } = ctx
46
if (
5-
err instanceof AbortError ||
6-
err instanceof CircuitOpenError ||
7-
err instanceof TimeoutError
7+
error instanceof AbortError ||
8+
error instanceof CircuitOpenError ||
9+
error instanceof TimeoutError
810
)
911
return false
10-
if (!res) return true // network error
11-
return res.status >= 500 || res.status === 429
12+
if (!response) return true // network error
13+
return response.status >= 500 || response.status === 429
1214
}

src/types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import type { Hooks } from './hooks'
22

3+
export interface RetryContext {
4+
attempt: number
5+
request: Request
6+
response?: Response
7+
error?: unknown
8+
}
9+
310
export interface FFetchOptions {
411
timeout?: number
512
retries?: number
6-
retryDelay?: number | ((attempt: number) => number)
7-
shouldRetry?: (err: unknown, res?: Response) => boolean
13+
retryDelay?: number | ((ctx: RetryContext) => number)
14+
shouldRetry?: (ctx: RetryContext) => boolean
815
circuit?: { threshold: number; reset: number }
916
hooks?: Hooks
1017
}

test/client.override.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,26 +47,29 @@ describe('FFetch per-request override', () => {
4747

4848
it('overrides retryDelay per request', async () => {
4949
const delays: number[] = []
50-
const client = createClient({ retryDelay: () => 1 })
50+
const client = createClient({ retryDelay: ({ attempt: _attempt }) => 1 })
5151
mockFetchImpl(new Response('ok'), { failTimes: 2 })
5252
const origSetTimeout = globalThis.setTimeout
5353
vi.stubGlobal('setTimeout', (fn, ms) => {
5454
delays.push(ms)
5555
return origSetTimeout(fn, 0)
5656
})
57-
await client('http://x', { retries: 2, retryDelay: (a) => 42 + a })
57+
await client('http://x', {
58+
retries: 2,
59+
retryDelay: ({ attempt: _attempt }) => 43 + _attempt,
60+
})
5861
// Only check retry delays (ignore timeout delay, which is >= 1000ms)
5962
const retryDelays = delays.filter((d) => d < 1000)
60-
expect(retryDelays[0]).toBe(43)
61-
expect(retryDelays[1]).toBe(44)
63+
expect(retryDelays[0]).toBe(44)
64+
expect(retryDelays[1]).toBe(45)
6265
})
6366

6467
it('overrides shouldRetry per request', async () => {
6568
const client = createClient({ shouldRetry: () => false, retries: 2 })
6669
let called = false
6770
mockFetchImpl(new Response('ok'), { failTimes: 1 })
6871
await client('http://x', {
69-
shouldRetry: (_err) => {
72+
shouldRetry: (_ctx) => {
7073
called = true
7174
return true
7275
},

test/client.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ describe('retry with shouldRetry', () => {
7272
describe('custom shouldRetry', () => {
7373
it('uses custom retry policy and retries only once', async () => {
7474
// custom policy that only retries on 503
75-
const customShouldRetry = (err: unknown, res?: Response): boolean => {
76-
if (res) {
77-
return res.status === 503
75+
const customShouldRetry = (
76+
ctx: import('../src/types').RetryContext
77+
): boolean => {
78+
if (ctx.response) {
79+
return ctx.response.status === 503
7880
}
7981
return false
8082
}

0 commit comments

Comments
 (0)