better-isomorphic-fetch 0.0.1
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 +189 -0
- package/dist/browser.js +101 -0
- package/dist/node.js +24026 -0
- package/package.json +44 -0
- package/src/browser.ts +15 -0
- package/src/node.ts +225 -0
- package/src/retry.ts +124 -0
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# better-isomorphic-fetch
|
|
2
|
+
|
|
3
|
+
A drop-in `fetch` replacement with **retries**, **exponential backoff**, and **OpenTelemetry tracing** — zero config required.
|
|
4
|
+
|
|
5
|
+
- Same `fetch(url, init)` signature you already know
|
|
6
|
+
- Exponential backoff with jitter, `Retry-After` support, abort signal awareness
|
|
7
|
+
- Safe defaults: only idempotent methods are retried, only transient status codes trigger retries
|
|
8
|
+
- Automatic [OpenTelemetry](https://opentelemetry.io/) spans, trace propagation, and `Server-Timing` parsing on Node.js (opt-in)
|
|
9
|
+
- Uses [undici](https://github.com/nodejs/undici) `request` on Node.js, native `fetch` in the browser
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add better-isomorphic-fetch
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`@opentelemetry/api` is an optional peer dependency — install it to enable automatic tracing on Node.js.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add @opentelemetry/api
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { fetch } from "better-isomorphic-fetch";
|
|
27
|
+
|
|
28
|
+
const response = await fetch("https://api.example.com/data", {
|
|
29
|
+
retries: 3,
|
|
30
|
+
retryDelay: 500,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's it. If the request fails with a `503`, it retries up to 3 times with exponential backoff starting at 500ms. Everything else works exactly like `fetch`.
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
`better-isomorphic-fetch` extends the standard `RequestInit` with retry configuration:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
interface BetterFetchInit extends RequestInit {
|
|
42
|
+
retries?: number;
|
|
43
|
+
retryDelay?: number;
|
|
44
|
+
retryOn?: number[];
|
|
45
|
+
retryMethods?: string[];
|
|
46
|
+
onRetry?: (info: {
|
|
47
|
+
attempt: number;
|
|
48
|
+
error: Error | null;
|
|
49
|
+
response: Response | null;
|
|
50
|
+
delay: number;
|
|
51
|
+
}) => boolean | void | Promise<boolean | void>;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Option | Default | Description |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `retries` | `0` | Number of retry attempts. `0` means no retries (standard fetch behavior). |
|
|
58
|
+
| `retryDelay` | `1000` | Base delay in milliseconds. Actual delay uses exponential backoff with jitter: `delay * 2^attempt + random jitter`. |
|
|
59
|
+
| `retryOn` | `[408, 429, 500, 502, 503, 504]` | HTTP status codes that trigger a retry. |
|
|
60
|
+
| `retryMethods` | `["GET", "HEAD", "OPTIONS", "PUT"]` | HTTP methods eligible for retry. POST is excluded by default to prevent duplicate side effects. |
|
|
61
|
+
| `onRetry` | — | Hook called before each retry. Return `false` to abort. |
|
|
62
|
+
|
|
63
|
+
## Examples
|
|
64
|
+
|
|
65
|
+
### Basic retry
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { fetch } from "better-isomorphic-fetch";
|
|
69
|
+
|
|
70
|
+
const res = await fetch("https://api.example.com/users", {
|
|
71
|
+
retries: 3,
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Custom retry behavior
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const res = await fetch("https://api.example.com/webhook", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
body: JSON.stringify({ event: "deploy" }),
|
|
81
|
+
retries: 5,
|
|
82
|
+
retryDelay: 200,
|
|
83
|
+
retryMethods: ["POST"], // opt POST into retries
|
|
84
|
+
retryOn: [429, 502, 503], // only retry on these codes
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Logging retries
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const res = await fetch("https://api.example.com/data", {
|
|
92
|
+
retries: 3,
|
|
93
|
+
retryDelay: 1000,
|
|
94
|
+
onRetry: ({ attempt, error, response, delay }) => {
|
|
95
|
+
console.log(
|
|
96
|
+
`Retry ${attempt} in ${delay}ms`,
|
|
97
|
+
response ? `status=${response.status}` : error?.message,
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Aborting retries early
|
|
104
|
+
|
|
105
|
+
Return `false` from `onRetry` to stop retrying immediately:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const res = await fetch("https://api.example.com/data", {
|
|
109
|
+
retries: 10,
|
|
110
|
+
onRetry: ({ attempt, response }) => {
|
|
111
|
+
if (response?.status === 401) return false; // don't retry auth errors
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### With AbortSignal
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
setTimeout(() => controller.abort(), 5000);
|
|
121
|
+
|
|
122
|
+
const res = await fetch("https://api.example.com/slow", {
|
|
123
|
+
retries: 3,
|
|
124
|
+
signal: controller.signal,
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The signal is checked between retries — if aborted during the backoff sleep, the fetch throws immediately.
|
|
129
|
+
|
|
130
|
+
## Retry behavior
|
|
131
|
+
|
|
132
|
+
**Exponential backoff with jitter.** Delay doubles each attempt with 20% random jitter to prevent thundering herd:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
Attempt 1: retryDelay * 2^0 + jitter → ~1000ms
|
|
136
|
+
Attempt 2: retryDelay * 2^1 + jitter → ~2000ms
|
|
137
|
+
Attempt 3: retryDelay * 2^2 + jitter → ~4000ms
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**`Retry-After` header.** On 429 responses, the `Retry-After` header is respected (both seconds and HTTP-date formats). The delay is clamped to the max backoff to prevent unbounded waits.
|
|
141
|
+
|
|
142
|
+
**Connection cleanup.** Response bodies are cancelled between retries to free connections.
|
|
143
|
+
|
|
144
|
+
**Method safety.** Only idempotent methods are retried by default. Override with `retryMethods` when you know it's safe.
|
|
145
|
+
|
|
146
|
+
## OpenTelemetry (Node.js)
|
|
147
|
+
|
|
148
|
+
When `@opentelemetry/api` is installed and a tracer provider is configured, every fetch automatically produces a client span:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
HTTP GET
|
|
152
|
+
├─ http.request.method: GET
|
|
153
|
+
├─ url.full: https://api.example.com/data
|
|
154
|
+
├─ server.address: api.example.com
|
|
155
|
+
├─ server.port: 443
|
|
156
|
+
├─ http.response.status_code: 200
|
|
157
|
+
├─ http.resend_count: 2 ← only if retries occurred
|
|
158
|
+
├─ http.server_timing.cache.duration: 2.5
|
|
159
|
+
├─ http.server_timing.db.duration: 53.2
|
|
160
|
+
└─ events:
|
|
161
|
+
├─ http.retry { attempt: 1, status_code: 503, delay_ms: 1020 }
|
|
162
|
+
└─ http.retry { attempt: 2, status_code: 503, delay_ms: 2180 }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Features:
|
|
166
|
+
|
|
167
|
+
- **Client spans** with [semantic HTTP attributes](https://opentelemetry.io/docs/specs/semconv/http/http-spans/)
|
|
168
|
+
- **W3C Trace Context** propagation on outgoing headers
|
|
169
|
+
- **`http.retry` events** with attempt number, delay, status code, and error
|
|
170
|
+
- **`http.resend_count`** attribute when retries occurred
|
|
171
|
+
- **`Server-Timing` header** parsed into `http.server_timing.<name>.duration` and `http.server_timing.<name>.description` attributes
|
|
172
|
+
- **Error recording** — 5xx responses set span status to ERROR; exceptions are recorded
|
|
173
|
+
|
|
174
|
+
No OpenTelemetry? No problem — the library works identically without it. The import is lazy and failure is silent.
|
|
175
|
+
|
|
176
|
+
## Browser vs Node.js
|
|
177
|
+
|
|
178
|
+
The package uses [conditional exports](https://nodejs.org/api/packages.html#conditional-exports):
|
|
179
|
+
|
|
180
|
+
| Environment | Implementation | OTel |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| **Node.js** | [undici](https://github.com/nodejs/undici) `request()` | Yes (when `@opentelemetry/api` is installed) |
|
|
183
|
+
| **Browser** | `globalThis.fetch` | No |
|
|
184
|
+
|
|
185
|
+
Both environments get the same retry behavior. The split is handled automatically by your bundler or runtime.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/retry.ts
|
|
2
|
+
var DEFAULT_RETRY_ON = [408, 429, 500, 502, 503, 504];
|
|
3
|
+
var DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT"];
|
|
4
|
+
function stripRetryFields(init) {
|
|
5
|
+
if (!init)
|
|
6
|
+
return init;
|
|
7
|
+
const { retries, retryDelay, retryOn, retryMethods, onRetry, ...rest } = init;
|
|
8
|
+
return rest;
|
|
9
|
+
}
|
|
10
|
+
function parseRetryAfter(header) {
|
|
11
|
+
if (header == null)
|
|
12
|
+
return null;
|
|
13
|
+
const seconds = Number(header);
|
|
14
|
+
if (!Number.isNaN(seconds))
|
|
15
|
+
return seconds * 1000;
|
|
16
|
+
const date = Date.parse(header);
|
|
17
|
+
if (!Number.isNaN(date))
|
|
18
|
+
return Math.max(0, date - Date.now());
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
function resolveMethod(input, init) {
|
|
25
|
+
if (init?.method)
|
|
26
|
+
return init.method.toUpperCase();
|
|
27
|
+
if (input instanceof Request)
|
|
28
|
+
return input.method.toUpperCase();
|
|
29
|
+
return "GET";
|
|
30
|
+
}
|
|
31
|
+
async function withRetry(doFetch, input, init) {
|
|
32
|
+
const retries = init?.retries ?? 0;
|
|
33
|
+
if (retries <= 0)
|
|
34
|
+
return doFetch(input, init);
|
|
35
|
+
const cleanInit = stripRetryFields(init);
|
|
36
|
+
const retryDelay = init?.retryDelay ?? 1000;
|
|
37
|
+
const retryOn = init?.retryOn ?? DEFAULT_RETRY_ON;
|
|
38
|
+
const retryMethods = (init?.retryMethods ?? DEFAULT_RETRY_METHODS).map((m) => m.toUpperCase());
|
|
39
|
+
const onRetry = init?.onRetry;
|
|
40
|
+
const signal = init?.signal;
|
|
41
|
+
const method = resolveMethod(input, cleanInit);
|
|
42
|
+
if (!retryMethods.includes(method))
|
|
43
|
+
return doFetch(input, cleanInit);
|
|
44
|
+
let lastError = null;
|
|
45
|
+
let lastResponse = null;
|
|
46
|
+
for (let attempt = 0;attempt <= retries; attempt++) {
|
|
47
|
+
if (attempt > 0 && signal?.aborted) {
|
|
48
|
+
throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
lastResponse = await doFetch(input, cleanInit);
|
|
52
|
+
lastError = null;
|
|
53
|
+
if (!retryOn.includes(lastResponse.status) || attempt === retries) {
|
|
54
|
+
return lastResponse;
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
58
|
+
lastResponse = null;
|
|
59
|
+
if (attempt === retries)
|
|
60
|
+
throw lastError;
|
|
61
|
+
}
|
|
62
|
+
lastResponse?.body?.cancel();
|
|
63
|
+
let delay = retryDelay * Math.pow(2, attempt) + Math.random() * retryDelay * 0.2;
|
|
64
|
+
if (lastResponse?.status === 429) {
|
|
65
|
+
const retryAfter = parseRetryAfter(lastResponse.headers.get("Retry-After"));
|
|
66
|
+
if (retryAfter != null) {
|
|
67
|
+
const maxBackoff = retryDelay * Math.pow(2, retries);
|
|
68
|
+
delay = Math.min(Math.max(delay, retryAfter), maxBackoff);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (onRetry) {
|
|
72
|
+
const result = await onRetry({
|
|
73
|
+
attempt: attempt + 1,
|
|
74
|
+
error: lastError,
|
|
75
|
+
response: lastResponse,
|
|
76
|
+
delay
|
|
77
|
+
});
|
|
78
|
+
if (result === false) {
|
|
79
|
+
if (lastError)
|
|
80
|
+
throw lastError;
|
|
81
|
+
return lastResponse;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (signal?.aborted) {
|
|
85
|
+
throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
86
|
+
}
|
|
87
|
+
await sleep(delay);
|
|
88
|
+
}
|
|
89
|
+
throw lastError ?? new Error("Retry loop exited unexpectedly");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/browser.ts
|
|
93
|
+
async function doFetch(input, init) {
|
|
94
|
+
return globalThis.fetch(input, init);
|
|
95
|
+
}
|
|
96
|
+
async function fetch(input, init) {
|
|
97
|
+
return withRetry(doFetch, input, init);
|
|
98
|
+
}
|
|
99
|
+
export {
|
|
100
|
+
fetch
|
|
101
|
+
};
|