@codingaryan/smoothapi 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -123
- package/dist/dedup.d.ts +44 -0
- package/dist/dedup.d.ts.map +1 -0
- package/dist/dedup.js +86 -0
- package/dist/dedup.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -48
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,123 +1,174 @@
|
|
|
1
|
-
# @codingaryan/smoothapi
|
|
2
|
-
|
|
3
|
-
API resilience library for TypeScript/JavaScript. It wraps the native `fetch` API with **exponential backoff, full jitter, and a finite-state machine circuit breaker** to protect against cascading failures.
|
|
4
|
-
|
|
5
|
-
Zero dependencies. Small bundle size. Built for modern ESM.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install @codingaryan/smoothapi
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Features
|
|
14
|
-
|
|
15
|
-
- **Exponential Backoff with Full Jitter:** Prevents the "thundering herd" problem by randomizing retry delays.
|
|
16
|
-
- **Circuit Breaker (FSM):** Isolated per-domain state machine (`CLOSED` → `OPEN` → `HALF_OPEN`).
|
|
17
|
-
- **Smart Retries:** Automatically retries on specific HTTP status codes (e.g., 429, 500, 502, 503, 504) while throwing immediately on client errors (400, 401, 404).
|
|
18
|
-
- **Graceful Fallbacks:** Optionally serve cached or default data instantly when the circuit is `OPEN`.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **Circuit
|
|
49
|
-
- **
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
* **
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
# @codingaryan/smoothapi
|
|
2
|
+
|
|
3
|
+
API resilience library for TypeScript/JavaScript. It wraps the native `fetch` API with **exponential backoff, full jitter, and a finite-state machine circuit breaker** to protect against cascading failures.
|
|
4
|
+
|
|
5
|
+
Zero dependencies. Small bundle size. Built for modern ESM.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @codingaryan/smoothapi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Exponential Backoff with Full Jitter:** Prevents the "thundering herd" problem by randomizing retry delays.
|
|
16
|
+
- **Circuit Breaker (FSM):** Isolated per-domain state machine (`CLOSED` → `OPEN` → `HALF_OPEN`).
|
|
17
|
+
- **Smart Retries:** Automatically retries on specific HTTP status codes (e.g., 429, 500, 502, 503, 504) while throwing immediately on client errors (400, 401, 404).
|
|
18
|
+
- **Graceful Fallbacks:** Optionally serve cached or default data instantly when the circuit is `OPEN`.
|
|
19
|
+
- **Request Deduplication:** Automatically coalesce concurrent identical requests into a single network call.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Basic Usage (Defaults)
|
|
24
|
+
|
|
25
|
+
If you don't need custom configurations, you can instantiate the resilient fetch with its defaults by simply passing an empty object.
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { createResilientFetch } from '@codingaryan/smoothapi';
|
|
29
|
+
|
|
30
|
+
// Create it with default settings
|
|
31
|
+
const fetchWithRetry = createResilientFetch({});
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
try {
|
|
35
|
+
// Drop-in replacement for native fetch
|
|
36
|
+
const response = await fetchWithRetry('https://api.example.com/data');
|
|
37
|
+
const data = await response.json();
|
|
38
|
+
console.log(data);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error("Request failed completely:", err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Default Settings provided automatically:**
|
|
46
|
+
- **Retries**: 3 attempts
|
|
47
|
+
- **Backoff Base Delay**: 100 milliseconds
|
|
48
|
+
- **Circuit Failure Threshold**: Trips after 3 consecutive failures
|
|
49
|
+
- **Circuit Cooldown**: Stays open for 10 seconds before probing
|
|
50
|
+
- **Status Codes to Retry**: `429`, `500`, `502`, `503`, and `504`
|
|
51
|
+
|
|
52
|
+
### Advanced Usage (Custom Settings)
|
|
53
|
+
|
|
54
|
+
You can override any of the defaults to suit your application's needs, such as adding a fallback object.
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { createResilientFetch } from '@codingaryan/smoothapi';
|
|
58
|
+
|
|
59
|
+
const fetchWithRetry = createResilientFetch({
|
|
60
|
+
backoff: {
|
|
61
|
+
baseDelay: 100, // ms to wait before first retry
|
|
62
|
+
maxDelay: 30000, // cap on exponential growth
|
|
63
|
+
maxRetries: 3 // max number of retry attempts
|
|
64
|
+
},
|
|
65
|
+
circuitBreaker: {
|
|
66
|
+
failureThreshold: 3, // trip OPEN after 3 consecutive failures
|
|
67
|
+
cooldownMs: 10000 // stay OPEN for 10 seconds before probing
|
|
68
|
+
},
|
|
69
|
+
// Optional: Return this instead of throwing when the circuit is OPEN
|
|
70
|
+
fallback: { error: "Service degraded, returning stale data." },
|
|
71
|
+
// Optional: Custom status codes to retry on
|
|
72
|
+
retryOn: [429, 500, 502, 503, 504]
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
async function main() {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetchWithRetry('https://api.example.com/data');
|
|
78
|
+
|
|
79
|
+
// If fallback triggered, it returns your fallback object directly
|
|
80
|
+
if ('error' in response) {
|
|
81
|
+
console.log("Fallback triggered:", response.error);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Otherwise it's a standard Response object
|
|
86
|
+
const data = await response.json();
|
|
87
|
+
console.log(data);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error("Request failed completely:", err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Client Error Handling & Alerts
|
|
95
|
+
|
|
96
|
+
By default, client errors (e.g. `400`, `401`, `403`, `404`, `405`) resolve immediately and bypass the retry loop. If you want to handle these errors gracefully and alert users:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { createResilientFetch } from '@codingaryan/smoothapi';
|
|
100
|
+
|
|
101
|
+
const fetchWithRetry = createResilientFetch({
|
|
102
|
+
fallbackOnNonRetryable: true,
|
|
103
|
+
// Optional: Trigger custom UI logic when a client error happens
|
|
104
|
+
onNonRetryableError: (status, message) => {
|
|
105
|
+
console.log(`Custom callback: Received status ${status}`);
|
|
106
|
+
},
|
|
107
|
+
// Optional: Fallback returned on non-retryable errors
|
|
108
|
+
fallback: { error: "Page not found." }
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
* **Default Alerting**: If `fallbackOnNonRetryable` is `true` and no custom `onNonRetryableError` is provided, running in a browser environment will trigger a standard `window.alert("Non-retryable HTTP error: [status]")`. In backend/Node environments, it logs the warning to `console.error`.
|
|
113
|
+
* **Graceful Return**: If no custom `fallback` is configured, it returns a mock `Response` wrapper with the status code and a JSON error body: `{ error: true, status: 404, message: "..." }`. Callers can safely call `.json()`, `.status`, or `.ok` on it without crashing.
|
|
114
|
+
|
|
115
|
+
### Request Deduplication
|
|
116
|
+
|
|
117
|
+
When multiple identical requests are made concurrently, SmoothAPI can execute only one network call and share the result with all callers. This reduces unnecessary load on downstream services.
|
|
118
|
+
|
|
119
|
+
**Enable with default key function** (deduplicates by URL):
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { createResilientFetch } from '@codingaryan/smoothapi';
|
|
123
|
+
|
|
124
|
+
const fetchWithRetry = createResilientFetch({
|
|
125
|
+
deduplication: {} // Empty object activates deduplication
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// All three calls share a single network request
|
|
129
|
+
const [a, b, c] = await Promise.all([
|
|
130
|
+
fetchWithRetry('http://api.example.com/users/1'),
|
|
131
|
+
fetchWithRetry('http://api.example.com/users/1'),
|
|
132
|
+
fetchWithRetry('http://api.example.com/users/1'),
|
|
133
|
+
]);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Custom key function** for advanced coalescing:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
const fetchWithRetry = createResilientFetch({
|
|
140
|
+
deduplication: {
|
|
141
|
+
// Deduplicate by method + URL (ignores headers/body)
|
|
142
|
+
keyFn: (url, options) => `${options?.method ?? 'GET'}:${url.toString()}`
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Opt out of deduplication** for specific requests:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const fetchWithRetry = createResilientFetch({
|
|
151
|
+
deduplication: {
|
|
152
|
+
keyFn: (url, options) => {
|
|
153
|
+
// Skip dedup for POST requests
|
|
154
|
+
if (options?.method === 'POST') return null;
|
|
155
|
+
return url.toString();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
* **Default Behavior**: Deduplicates by URL only (method-agnostic). Concurrent GETs to the same URL are coalesced.
|
|
162
|
+
* **Error Propagation**: If the network call fails, all waiting callers receive the same error.
|
|
163
|
+
* **Settlement**: Once a request completes, the next call to the same URL triggers a fresh network request.
|
|
164
|
+
|
|
165
|
+
## How It Works
|
|
166
|
+
|
|
167
|
+
1. **Host Extraction:** The domain is automatically extracted from the URL. The circuit breaker state is isolated per host (e.g., `api.github.com` failing won't trip the circuit for `api.stripe.com`).
|
|
168
|
+
2. **Circuit Check:** Before making a network request, the breaker checks the state. If it's `OPEN`, the request is blocked instantly (returning your fallback, or throwing a `CircuitOpenError`).
|
|
169
|
+
3. **Execution & Retries:** If the response status is in your `retryOn` list, it's counted as a failure and retried with backoff.
|
|
170
|
+
4. **Recovery:** After `cooldownMs`, the breaker enters `HALF_OPEN` state. The next request acts as a probe. If it succeeds, the circuit closes. If it fails, it snaps back to `OPEN` immediately.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
package/dist/dedup.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DeduplicationKeyFn } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tracks in-flight requests and coalesces identical concurrent calls
|
|
4
|
+
* into a single shared Promise.
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle
|
|
7
|
+
* ---------
|
|
8
|
+
* 1. First caller for key K → starts the network call, stores the raw
|
|
9
|
+
* Promise in the map, and returns a `.then(clone)` chain so the
|
|
10
|
+
* original `Response` body stream is never consumed by anyone.
|
|
11
|
+
* 2. Subsequent callers for K (while the first is still pending) →
|
|
12
|
+
* attach their own `.then(clone)` to the same shared Promise, each
|
|
13
|
+
* receiving an independent cloned `Response`.
|
|
14
|
+
* 3. Once the shared Promise settles the entry is deleted, so the *next*
|
|
15
|
+
* caller after settlement triggers a fresh network request.
|
|
16
|
+
*
|
|
17
|
+
* Response cloning
|
|
18
|
+
* ----------------
|
|
19
|
+
* `fetch()` returns a `Response` whose body is a one-time-readable stream.
|
|
20
|
+
* If two callers received the *same* `Response` object, whichever reads
|
|
21
|
+
* `.json()` / `.text()` first would disturb the body for the other.
|
|
22
|
+
* To avoid this, the raw result is kept in the inflight map and every
|
|
23
|
+
* caller — including the first one — receives `response.clone()`, leaving
|
|
24
|
+
* the stored original unconsumed and safe to clone again.
|
|
25
|
+
*/
|
|
26
|
+
export declare class RequestDeduplicator {
|
|
27
|
+
/** Stores the raw (un-cloned) shared Promise for each in-flight key. */
|
|
28
|
+
private readonly inflight;
|
|
29
|
+
private readonly keyFn;
|
|
30
|
+
constructor(keyFn?: DeduplicationKeyFn);
|
|
31
|
+
/**
|
|
32
|
+
* Execute `fetcher` if no identical request is already in-flight,
|
|
33
|
+
* otherwise attach to the existing Promise. In either case the caller
|
|
34
|
+
* receives `Response.clone()` so body streams are independent.
|
|
35
|
+
*
|
|
36
|
+
* @param url - Same value passed to the outer resilientFetch.
|
|
37
|
+
* @param options - Same value passed to the outer resilientFetch.
|
|
38
|
+
* @param fetcher - A thunk that performs the actual network call.
|
|
39
|
+
*/
|
|
40
|
+
execute<R>(url: string | URL, options: RequestInit | undefined, fetcher: () => Promise<R>): Promise<R>;
|
|
41
|
+
/** Number of in-flight deduplicated requests. Useful for tests. */
|
|
42
|
+
get size(): number;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=dedup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dedup.d.ts","sourceRoot":"","sources":["../src/dedup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAyBrD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,mBAAmB;IAC9B,wEAAwE;IACxE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA4C;IACrE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;gBAE/B,KAAK,CAAC,EAAE,kBAAkB;IAItC;;;;;;;;OAQG;IACH,OAAO,CAAC,CAAC,EACP,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,OAAO,EAAE,WAAW,GAAG,SAAS,EAChC,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACxB,OAAO,CAAC,CAAC,CAAC;IAyBb,mEAAmE;IACnE,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
|
package/dist/dedup.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default key derivation: stringified URL only.
|
|
3
|
+
* This intentionally ignores `options` (e.g. headers, body) so that
|
|
4
|
+
* concurrent GET /users/1 calls are always collapsed, even when the
|
|
5
|
+
* caller did not customise the key function.
|
|
6
|
+
*
|
|
7
|
+
* For mutation-safe deduplication (POST, PUT …) supply a custom
|
|
8
|
+
* `keyFn` via `DeduplicationConfig.keyFn`.
|
|
9
|
+
*/
|
|
10
|
+
const defaultKeyFn = (url) => url.toString();
|
|
11
|
+
/**
|
|
12
|
+
* Clone `result` if it is a `Response` — necessary because `Response` bodies
|
|
13
|
+
* are single-consumption streams. Non-Response values (fallback objects, etc.)
|
|
14
|
+
* are returned as-is.
|
|
15
|
+
*/
|
|
16
|
+
function cloneIfResponse(result) {
|
|
17
|
+
if (result instanceof Response) {
|
|
18
|
+
return result.clone();
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Tracks in-flight requests and coalesces identical concurrent calls
|
|
24
|
+
* into a single shared Promise.
|
|
25
|
+
*
|
|
26
|
+
* Lifecycle
|
|
27
|
+
* ---------
|
|
28
|
+
* 1. First caller for key K → starts the network call, stores the raw
|
|
29
|
+
* Promise in the map, and returns a `.then(clone)` chain so the
|
|
30
|
+
* original `Response` body stream is never consumed by anyone.
|
|
31
|
+
* 2. Subsequent callers for K (while the first is still pending) →
|
|
32
|
+
* attach their own `.then(clone)` to the same shared Promise, each
|
|
33
|
+
* receiving an independent cloned `Response`.
|
|
34
|
+
* 3. Once the shared Promise settles the entry is deleted, so the *next*
|
|
35
|
+
* caller after settlement triggers a fresh network request.
|
|
36
|
+
*
|
|
37
|
+
* Response cloning
|
|
38
|
+
* ----------------
|
|
39
|
+
* `fetch()` returns a `Response` whose body is a one-time-readable stream.
|
|
40
|
+
* If two callers received the *same* `Response` object, whichever reads
|
|
41
|
+
* `.json()` / `.text()` first would disturb the body for the other.
|
|
42
|
+
* To avoid this, the raw result is kept in the inflight map and every
|
|
43
|
+
* caller — including the first one — receives `response.clone()`, leaving
|
|
44
|
+
* the stored original unconsumed and safe to clone again.
|
|
45
|
+
*/
|
|
46
|
+
export class RequestDeduplicator {
|
|
47
|
+
/** Stores the raw (un-cloned) shared Promise for each in-flight key. */
|
|
48
|
+
inflight = new Map();
|
|
49
|
+
keyFn;
|
|
50
|
+
constructor(keyFn) {
|
|
51
|
+
this.keyFn = keyFn ?? defaultKeyFn;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Execute `fetcher` if no identical request is already in-flight,
|
|
55
|
+
* otherwise attach to the existing Promise. In either case the caller
|
|
56
|
+
* receives `Response.clone()` so body streams are independent.
|
|
57
|
+
*
|
|
58
|
+
* @param url - Same value passed to the outer resilientFetch.
|
|
59
|
+
* @param options - Same value passed to the outer resilientFetch.
|
|
60
|
+
* @param fetcher - A thunk that performs the actual network call.
|
|
61
|
+
*/
|
|
62
|
+
execute(url, options, fetcher) {
|
|
63
|
+
const key = this.keyFn(url, options);
|
|
64
|
+
// null means: "skip deduplication for this request"
|
|
65
|
+
if (key === null) {
|
|
66
|
+
return fetcher();
|
|
67
|
+
}
|
|
68
|
+
if (this.inflight.has(key)) {
|
|
69
|
+
// Attach to the existing in-flight promise and return a fresh clone
|
|
70
|
+
// so this caller's Response stream is fully independent.
|
|
71
|
+
return this.inflight.get(key).then(cloneIfResponse);
|
|
72
|
+
}
|
|
73
|
+
const raw = fetcher().finally(() => {
|
|
74
|
+
this.inflight.delete(key);
|
|
75
|
+
});
|
|
76
|
+
this.inflight.set(key, raw);
|
|
77
|
+
// The first caller also gets a clone so the raw result stored in the
|
|
78
|
+
// map is never body-consumed, keeping it safe to clone for latecomers.
|
|
79
|
+
return raw.then(cloneIfResponse);
|
|
80
|
+
}
|
|
81
|
+
/** Number of in-flight deduplicated requests. Useful for tests. */
|
|
82
|
+
get size() {
|
|
83
|
+
return this.inflight.size;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=dedup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dedup.js","sourceRoot":"","sources":["../src/dedup.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,YAAY,GAAuB,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;AAEjE;;;;GAIG;AACH,SAAS,eAAe,CAAI,MAAS;IACnC,IAAI,MAAM,YAAY,QAAQ,EAAE,CAAC;QAC/B,OAAO,MAAM,CAAC,KAAK,EAAkB,CAAC;IACxC,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,mBAAmB;IAC9B,wEAAwE;IACvD,QAAQ,GAAkC,IAAI,GAAG,EAAE,CAAC;IACpD,KAAK,CAAqB;IAE3C,YAAY,KAA0B;QACpC,IAAI,CAAC,KAAK,GAAG,KAAK,IAAI,YAAY,CAAC;IACrC,CAAC;IAED;;;;;;;;OAQG;IACH,OAAO,CACL,GAAiB,EACjB,OAAgC,EAChC,OAAyB;QAEzB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QAErC,oDAAoD;QACpD,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YACjB,OAAO,OAAO,EAAE,CAAC;QACnB,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3B,oEAAoE;YACpE,yDAAyD;YACzD,OAAQ,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAgB,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;QACtE,CAAC;QAED,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE;YACjC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAC5B,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAE5B,qEAAqE;QACrE,uEAAuE;QACvE,OAAO,GAAG,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IACnC,CAAC;IAED,mEAAmE;IACnE,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;IAC5B,CAAC;CACF"}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAoB,oBAAoB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAoB,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAWpE,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,YAAY,EAAE,oBAAoB,CAAC,CAAC,CAAC,IASzE,KAAK,MAAM,GAAG,GAAG,EACjB,UAAU,WAAW,KACpB,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC,CAuFzB"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CircuitBreakerState } from "./state.js";
|
|
2
2
|
import { calculateBackoff, sleep } from "./utils/backoff.js";
|
|
3
3
|
import { CircuitOpenError } from "./types.js";
|
|
4
|
+
import { RequestDeduplicator } from "./dedup.js";
|
|
4
5
|
const BACKOFF_DEFAULTS = {
|
|
5
6
|
baseDelay: 100,
|
|
6
7
|
maxDelay: 30_000,
|
|
@@ -11,6 +12,9 @@ export function createResilientFetch(globalConfig) {
|
|
|
11
12
|
const backoffConfig = { ...BACKOFF_DEFAULTS, ...globalConfig.backoff };
|
|
12
13
|
const retryOn = globalConfig.retryOn ?? DEFAULT_RETRY_ON;
|
|
13
14
|
const breaker = new CircuitBreakerState(globalConfig.circuitBreaker);
|
|
15
|
+
const deduplicator = globalConfig.deduplication
|
|
16
|
+
? new RequestDeduplicator(globalConfig.deduplication.keyFn)
|
|
17
|
+
: null;
|
|
14
18
|
return async function resilientFetch(url, options) {
|
|
15
19
|
const domain = new URL(url).hostname;
|
|
16
20
|
// Block before any network IO if the circuit is OPEN.
|
|
@@ -20,58 +24,69 @@ export function createResilientFetch(globalConfig) {
|
|
|
20
24
|
}
|
|
21
25
|
throw new CircuitOpenError(domain);
|
|
22
26
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
// The core fetch-with-retry logic extracted into a thunk so the
|
|
28
|
+
// deduplicator can decide whether to run it or share an existing Promise.
|
|
29
|
+
const executeRequest = () => {
|
|
30
|
+
let lastError;
|
|
31
|
+
const run = async () => {
|
|
32
|
+
for (let attempt = 0; attempt <= backoffConfig.maxRetries; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(url, options);
|
|
35
|
+
// fetch() resolves for any HTTP status. Retryable codes need to be
|
|
36
|
+
// treated as failures manually.
|
|
37
|
+
if (retryOn.includes(response.status)) {
|
|
38
|
+
breaker.recordFailure(domain);
|
|
39
|
+
if (attempt < backoffConfig.maxRetries) {
|
|
40
|
+
await sleep(calculateBackoff(attempt, backoffConfig));
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
if (response.status >= 400 && globalConfig.fallbackOnNonRetryable) {
|
|
46
|
+
const message = `Non-retryable HTTP error: ${response.status}${response.statusText ? ' ' + response.statusText : ''}`;
|
|
47
|
+
if (globalConfig.onNonRetryableError) {
|
|
48
|
+
globalConfig.onNonRetryableError(response.status, message);
|
|
49
|
+
}
|
|
50
|
+
else if (typeof window !== 'undefined') {
|
|
51
|
+
window.alert(message);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
console.error(message);
|
|
55
|
+
}
|
|
56
|
+
breaker.recordSuccess(domain);
|
|
57
|
+
if (globalConfig.fallback !== undefined) {
|
|
58
|
+
return globalConfig.fallback;
|
|
59
|
+
}
|
|
60
|
+
return new Response(JSON.stringify({
|
|
61
|
+
error: true,
|
|
62
|
+
status: response.status,
|
|
63
|
+
message,
|
|
64
|
+
}), {
|
|
65
|
+
status: response.status,
|
|
66
|
+
statusText: response.statusText,
|
|
67
|
+
headers: { "Content-Type": "application/json" }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
breaker.recordSuccess(domain);
|
|
71
|
+
return response;
|
|
34
72
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
else if (typeof window !== 'undefined') {
|
|
43
|
-
window.alert(message);
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
console.error(message);
|
|
47
|
-
}
|
|
48
|
-
breaker.recordSuccess(domain);
|
|
49
|
-
if (globalConfig.fallback !== undefined) {
|
|
50
|
-
return globalConfig.fallback;
|
|
73
|
+
catch (err) {
|
|
74
|
+
lastError = err;
|
|
75
|
+
breaker.recordFailure(domain);
|
|
76
|
+
// Don't sleep after the final attempt
|
|
77
|
+
if (attempt < backoffConfig.maxRetries) {
|
|
78
|
+
await sleep(calculateBackoff(attempt, backoffConfig));
|
|
79
|
+
}
|
|
51
80
|
}
|
|
52
|
-
return new Response(JSON.stringify({
|
|
53
|
-
error: true,
|
|
54
|
-
status: response.status,
|
|
55
|
-
message,
|
|
56
|
-
}), {
|
|
57
|
-
status: response.status,
|
|
58
|
-
statusText: response.statusText,
|
|
59
|
-
headers: { "Content-Type": "application/json" }
|
|
60
|
-
});
|
|
61
81
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Don't sleep after the final attempt
|
|
69
|
-
if (attempt < backoffConfig.maxRetries) {
|
|
70
|
-
await sleep(calculateBackoff(attempt, backoffConfig));
|
|
71
|
-
}
|
|
72
|
-
}
|
|
82
|
+
throw lastError;
|
|
83
|
+
};
|
|
84
|
+
return run();
|
|
85
|
+
};
|
|
86
|
+
if (deduplicator) {
|
|
87
|
+
return deduplicator.execute(url, options, executeRequest);
|
|
73
88
|
}
|
|
74
|
-
|
|
89
|
+
return executeRequest();
|
|
75
90
|
};
|
|
76
91
|
}
|
|
77
92
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAwB,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
2
|
+
/**
|
|
3
|
+
* Optional function that derives a cache key from a request.
|
|
4
|
+
* Defaults to `url.toString()` when not provided.
|
|
5
|
+
* Return `null` to opt this specific request out of deduplication.
|
|
6
|
+
*/
|
|
7
|
+
export type DeduplicationKeyFn = (url: string | URL, options?: RequestInit) => string | null;
|
|
8
|
+
export interface DeduplicationConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Custom function to compute the deduplication key.
|
|
11
|
+
* Receives the same (url, options) passed to resilientFetch.
|
|
12
|
+
* Defaults to the stringified URL (method-agnostic).
|
|
13
|
+
*/
|
|
14
|
+
keyFn?: DeduplicationKeyFn;
|
|
15
|
+
}
|
|
2
16
|
export interface CircuitEntry {
|
|
3
17
|
state: CircuitState;
|
|
4
18
|
failureCount: number;
|
|
@@ -20,6 +34,11 @@ export interface ResilientFetchConfig<T = unknown> {
|
|
|
20
34
|
retryOn?: number[];
|
|
21
35
|
fallbackOnNonRetryable?: boolean;
|
|
22
36
|
onNonRetryableError?: (status: number, message: string) => void;
|
|
37
|
+
/**
|
|
38
|
+
* When set, enables request deduplication.
|
|
39
|
+
* Pass an empty object `{}` to activate with the default key function.
|
|
40
|
+
*/
|
|
41
|
+
deduplication?: DeduplicationConfig;
|
|
23
42
|
}
|
|
24
43
|
export declare class CircuitOpenError extends Error {
|
|
25
44
|
readonly domain: string;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;AAE3D;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAC/B,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,OAAO,CAAC,EAAE,WAAW,KAClB,MAAM,GAAG,IAAI,CAAC;AAEnB,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,KAAK,CAAC,EAAE,kBAAkB,CAAC;CAC5B;AAGD,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,YAAY,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;CACpB;AAGD,MAAM,WAAW,oBAAoB,CAAC,CAAC,GAAG,OAAO;IAC/C,OAAO,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC/C,QAAQ,CAAC,EAAE,CAAC,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAChE;;;OAGG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;CACrC;AAGD,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;gBAEZ,MAAM,EAAE,MAAM;CAO3B"}
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAsDA,iEAAiE;AACjE,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAChC,MAAM,CAAS;IAExB,YAAY,MAAc;QACxB,KAAK,CAAC,uCAAuC,MAAM,EAAE,CAAC,CAAC;QACvD,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,kEAAkE;QAClE,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACpD,CAAC;CACF"}
|