Skip to content

Commit efbfdfd

Browse files
committed
Dedupe map sweeper timer implemented
Test coverage improved
1 parent ad3bb34 commit efbfdfd

9 files changed

Lines changed: 494 additions & 49 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st
130130
| --------------------------------------------- | ------------------------------------------------------------------------- |
131131
| **[Complete Documentation](./docs/index.md)** | **Start here** - Documentation index and overview |
132132
| **[API Reference](./docs/api.md)** | Complete API documentation and configuration options |
133-
| **[Deduplication](./docs/deduplication.md)** | How deduplication works, config, custom hash, limitations |
133+
| **[Deduplication](./docs/deduplication.md)** | How deduplication works, hash config, optional TTL cleanup, limitations |
134134
| **[Error Handling](./docs/errorhandling.md)** | Strategies for managing errors, including `throwOnHttpError` |
135135
| **[Advanced Features](./docs/advanced.md)** | Per-request overrides, pending requests, circuit breakers, custom errors |
136136
| **[Hooks & Transformation](./docs/hooks.md)** | Lifecycle hooks, authentication, logging, request/response transformation |
@@ -172,6 +172,7 @@ npm install abort-controller-x
172172

173173
- Deduplication is **off** by default. Enable it via the `dedupe` option.
174174
- The default hash function is `dedupeRequestHash`, which handles common body types and skips deduplication for streams and FormData.
175+
- Optional stale-entry cleanup: `dedupeTTL` enables map-entry eviction, and `dedupeSweepInterval` controls how often eviction runs. TTL eviction only removes dedupe keys; it does not reject already in-flight promises.
175176
- **Stream bodies** (`ReadableStream`, `FormData`): Deduplication is skipped for requests with these body types, as they cannot be reliably hashed or replayed.
176177
- **Non-idempotent requests**: Use deduplication with caution for non-idempotent methods (e.g., POST), as it may suppress multiple intended requests.
177178
- **Custom hash function**: Ensure your hash function uniquely identifies requests to avoid accidental deduplication.

docs/api.md

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,20 @@ await clientStrict('https://example.com/404') // throws
2222

2323
### Configuration Options
2424

25-
| Option | Type | Default | Description |
26-
| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
27-
| `timeout` | `number` (ms) | `5000` | Whole-request timeout in milliseconds. Use `0` to disable timeout |
28-
| `retries` | `number` | `0` | Maximum retry attempts |
29-
| `retryDelay` | `number \| (ctx: { attempt, request, response, error }) => number` | Exponential backoff + jitter | Delay between retries |
30-
| `shouldRetry` | `(ctx: { attempt, request, response, error }) => boolean` | Retries on network errors, 5xx, 429 | Custom retry logic |
31-
| `dedupe` | `boolean` | `false` | If true, enables automatic deduplication of in-flight identical requests. |
32-
| `dedupeHashFn` | `(params: DedupeHashParams) => string | undefined` | `dedupeRequestHash` | Custom function to generate deduplication keys. Use exported `DedupeHashParams` type for params. |
33-
| `throwOnHttpError` | `boolean` | `false` | If true, throws an `HttpError` for all HTTP error responses (all 4xx and 5xx) after all retries are exhausted. Otherwise, returns the final `Response`. |
34-
| `circuit` | `{ threshold: number, reset: number }` | `undefined` | Circuit-breaker configuration |
35-
| `hooks` | `{ before, after, onError, onRetry, onTimeout, onAbort, onCircuitOpen, onComplete, transformRequest, transformResponse }` | `{}` | Lifecycle hooks and transformers |
36-
| `fetchHandler` | `(input: RequestInfo \| URL, init?: RequestInit) => Promise<Response>` | `global fetch` | Custom fetch-compatible implementation to wrap (e.g., SvelteKit, Next.js, Nuxt, node-fetch, undici, or any polyfill). Defaults to global fetch. |
25+
| Option | Type | Default | Description |
26+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
27+
| `timeout` | `number` (ms) | `5000` | Whole-request timeout in milliseconds. Use `0` to disable timeout |
28+
| `retries` | `number` | `0` | Maximum retry attempts |
29+
| `retryDelay` | `number \| (ctx: { attempt, request, response, error }) => number` | Exponential backoff + jitter | Delay between retries |
30+
| `shouldRetry` | `(ctx: { attempt, request, response, error }) => boolean` | Retries on network errors, 5xx, 429 | Custom retry logic |
31+
| `dedupe` | `boolean` | `false` | If true, enables automatic deduplication of in-flight identical requests. |
32+
| `dedupeHashFn` | `(params: DedupeHashParams) => string \| undefined` | `dedupeRequestHash` | Custom function to generate deduplication keys. Return `undefined` to skip deduplication for a request. |
33+
| `dedupeTTL` | `number` (ms) | `undefined` | Optional TTL for dedupe-map entries. Expired entries are evicted by the sweeper. |
34+
| `dedupeSweepInterval` | `number` (ms) | `5000` | Interval used by the dedupe sweeper when `dedupeTTL` is set. |
35+
| `throwOnHttpError` | `boolean` | `false` | If true, throws an `HttpError` for all HTTP error responses (all 4xx and 5xx) after all retries are exhausted. Otherwise, returns the final `Response`. |
36+
| `circuit` | `{ threshold: number, reset: number }` | `undefined` | Circuit-breaker configuration |
37+
| `hooks` | `{ before, after, onError, onRetry, onTimeout, onAbort, onCircuitOpen, onComplete, transformRequest, transformResponse }` | `{}` | Lifecycle hooks and transformers |
38+
| `fetchHandler` | `(input: RequestInfo \| URL, init?: RequestInit) => Promise<Response>` | `global fetch` | Custom fetch-compatible implementation to wrap (e.g., SvelteKit, Next.js, Nuxt, node-fetch, undici, or any polyfill). Defaults to global fetch. |
3739

3840
### Deduplication
3941

@@ -59,6 +61,23 @@ const client = createClient({
5961
**Default:**
6062
Uses the built-in `dedupeRequestHash` function, which considers method, URL, and body, and skips deduplication for FormData and ReadableStream bodies.
6163

64+
#### `dedupeTTL` (number, milliseconds)
65+
66+
Optional TTL for dedupe-map entries. If set, expired entries are removed by a sweeper timer. This helps prevent stale in-flight dedupe keys from lingering indefinitely in failure or hanging-request scenarios.
67+
68+
Important behavior:
69+
70+
- Deduplication still works when `dedupeTTL` is `undefined`.
71+
- TTL eviction only removes dedupe keys from the map.
72+
- TTL eviction does **not** reject already in-flight promises.
73+
74+
#### `dedupeSweepInterval` (number, milliseconds)
75+
76+
Controls how often the dedupe sweeper checks for expired entries when `dedupeTTL` is enabled.
77+
78+
- Default: `5000` ms.
79+
- Only relevant when `dedupeTTL` is set to a positive number.
80+
6281
**Limitations:**
6382

6483
- Deduplication is off by default.
@@ -85,6 +104,10 @@ type FFetch = (
85104
retries?: number
86105
retryDelay?: number | ((ctx: RetryContext) => number)
87106
shouldRetry?: (ctx: RetryContext) => boolean
107+
dedupe?: boolean
108+
dedupeHashFn?: (params: DedupeHashParams) => string | undefined
109+
dedupeTTL?: number
110+
dedupeSweepInterval?: number
88111
throwOnHttpError?: boolean
89112
circuit?: { threshold: number; reset: number }
90113
hooks?: HooksConfig
@@ -136,14 +159,18 @@ if (client.circuitOpen) {
136159

137160
### Default Values
138161

139-
| Option | Default Value / Logic |
140-
| ------------- | ------------------------------------------------------------------------------------------------ |
141-
| `timeout` | `5000` ms (5 seconds) |
142-
| `retries` | `0` (no retries) |
143-
| `retryDelay` | Exponential backoff + jitter: `({ attempt }) => 2 ** attempt * 200 + Math.random() * 100` |
144-
| `shouldRetry` | Retries on network errors, HTTP 5xx, or 429. Does not retry on 4xx (except 429) or abort/timeout |
145-
| `circuit` | `undefined` (circuit breaker disabled by default) |
146-
| `hooks` | `{}` (no hooks by default) |
162+
| Option | Default Value / Logic |
163+
| --------------------- | ------------------------------------------------------------------------------------------------ |
164+
| `timeout` | `5000` ms (5 seconds) |
165+
| `retries` | `0` (no retries) |
166+
| `retryDelay` | Exponential backoff + jitter: `({ attempt }) => 2 ** attempt * 200 + Math.random() * 100` |
167+
| `shouldRetry` | Retries on network errors, HTTP 5xx, or 429. Does not retry on 4xx (except 429) or abort/timeout |
168+
| `dedupe` | `false` |
169+
| `dedupeHashFn` | `dedupeRequestHash` |
170+
| `dedupeTTL` | `undefined` (disabled) |
171+
| `dedupeSweepInterval` | `5000` ms |
172+
| `circuit` | `undefined` (circuit breaker disabled by default) |
173+
| `hooks` | `{}` (no hooks by default) |
147174

148175
### Notes
149176

@@ -212,9 +239,10 @@ const clientNode = createClient({ fetchHandler: nodeFetch })
212239

213240
// Per-request fetchHandler override (useful for testing)
214241
const client = createClient({ retries: 0 })
215-
const mockFetch = () => Promise.resolve(
216-
new Response(JSON.stringify({ test: 'data' }), { status: 200 })
217-
)
242+
const mockFetch = () =>
243+
Promise.resolve(
244+
new Response(JSON.stringify({ test: 'data' }), { status: 200 })
245+
)
218246
await client('https://example.com', { fetchHandler: mockFetch }) // Uses mockFetch
219247
await client('https://example.com') // Uses global fetch
220248
```

docs/deduplication.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ const client = createClient({ dedupe: true })
1717
client('https://api.example.com/data', { dedupe: true })
1818
```
1919

20+
### Optional Dedupe Map TTL
21+
22+
You can optionally enable stale-entry eviction for the internal dedupe map:
23+
24+
```js
25+
const client = createClient({
26+
dedupe: true,
27+
dedupeTTL: 30_000, // Evict dedupe entries older than 30s
28+
dedupeSweepInterval: 5_000, // Check every 5s
29+
})
30+
```
31+
32+
Behavior notes:
33+
34+
- Deduplication works with or without TTL.
35+
- `dedupeTTL` only controls eviction of dedupe-map keys.
36+
- TTL eviction does **not** reject an already in-flight request promise.
37+
- `dedupeSweepInterval` only matters when `dedupeTTL` is a positive number.
38+
2039
### Custom Hash Function
2140

2241
You can provide a custom hash function to control how deduplication keys are generated:
@@ -34,12 +53,15 @@ The default hash function considers method, URL, and body. For advanced use case
3453

3554
- Deduplication is **off** by default. Enable it via the `dedupe` option.
3655
- The default hash function is `dedupeRequestHash`, which handles common body types and skips deduplication for streams and FormData.
56+
- `dedupeTTL` is `undefined` by default (TTL eviction disabled).
57+
- `dedupeSweepInterval` defaults to `5000` ms.
3758

3859
## Limitations
3960

4061
- **Stream bodies** (`ReadableStream`, `FormData`): Deduplication is skipped for requests with these body types, as they cannot be reliably hashed or replayed.
4162
- **Non-idempotent requests**: Use deduplication with caution for non-idempotent methods (e.g., POST), as it may suppress multiple intended requests.
4263
- **Custom hash function**: Ensure your hash function uniquely identifies requests to avoid accidental deduplication.
64+
- **TTL scope**: `dedupeTTL` does not cache final responses. It only evicts in-flight dedupe keys from the internal map.
4365

4466
## Example
4567

docs/examples.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,16 +148,18 @@ const data = await response.json()
148148
const client2 = createClient({ retries: 0 })
149149

150150
// First request with specific mock
151-
const mockUser = () => Promise.resolve(
152-
new Response(JSON.stringify({ id: 1, name: 'Alice' }), { status: 200 })
153-
)
151+
const mockUser = () =>
152+
Promise.resolve(
153+
new Response(JSON.stringify({ id: 1, name: 'Alice' }), { status: 200 })
154+
)
154155
const userResponse = await client2('/api/user', { fetchHandler: mockUser })
155156
// Returns: { id: 1, name: 'Alice' }
156157

157158
// Second request with different mock
158-
const mockPosts = () => Promise.resolve(
159-
new Response(JSON.stringify([{ id: 1, title: 'Hello' }]), { status: 200 })
160-
)
159+
const mockPosts = () =>
160+
Promise.resolve(
161+
new Response(JSON.stringify([{ id: 1, title: 'Hello' }]), { status: 200 })
162+
)
161163
const postsResponse = await client2('/api/posts', { fetchHandler: mockPosts })
162164
// Returns: [{ id: 1, title: 'Hello' }]
163165
```
@@ -573,6 +575,28 @@ window.addEventListener('beforeunload', () => poller.stopPolling())
573575

574576
### Caching with TTL
575577

578+
### In-flight Deduplication with Dedupe Map TTL
579+
580+
Use this when you want to dedupe concurrent identical requests and optionally evict stale in-flight dedupe keys:
581+
582+
```typescript
583+
import createClient from '@fetchkit/ffetch'
584+
585+
const client = createClient({
586+
dedupe: true,
587+
dedupeTTL: 30_000,
588+
dedupeSweepInterval: 5_000,
589+
})
590+
591+
const p1 = client('https://api.example.com/profile')
592+
const p2 = client('https://api.example.com/profile')
593+
594+
// Same in-flight request is shared
595+
const [r1, r2] = await Promise.all([p1, p2])
596+
```
597+
598+
> Note: `dedupeTTL` is not response caching TTL. It only evicts entries in the internal dedupe map.
599+
576600
```typescript
577601
import createClient, { type FFetch } from '@fetchkit/ffetch'
578602

0 commit comments

Comments
 (0)