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 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
@@ -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
+ };