fetch-shield 1.0.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/LICENSE +21 -0
- package/README.md +96 -0
- package/package.json +35 -0
- package/src/adapters/axios.ts +78 -0
- package/src/adapters/fetch.ts +44 -0
- package/src/core/retry.ts +103 -0
- package/src/core/types.ts +28 -0
- package/src/index.ts +12 -0
- package/src/retry.test.ts +169 -0
- package/src/utils/logger.ts +46 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dilan Weerasinghe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# fetch-shield
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/fetch-shield)
|
|
4
|
+
[](https://www.npmjs.com/package/fetch-shield)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
> Lightweight fetch/axios wrapper with automatic retries, exponential backoff, and rate-limit handling.
|
|
8
|
+
|
|
9
|
+
Every developer keeps rewriting the same retry logic. `fetch-shield` solves that once, cleanly, in TypeScript.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- ð Auto-retry with exponential backoff
|
|
14
|
+
- ðĶ Rate limit (429) handling with `Retry-After` header parsing
|
|
15
|
+
- ð Structured logging of failed requests
|
|
16
|
+
- ðŠķ Zero runtime dependencies (axios is optional)
|
|
17
|
+
- ð· Full TypeScript support with generics
|
|
18
|
+
- ðĶ Dual CJS/ESM output
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install fetch-shield
|
|
24
|
+
# if using axios adapter:
|
|
25
|
+
npm install axios
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### fetch adapter
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { guardedFetch } from 'fetch-shield';
|
|
34
|
+
|
|
35
|
+
const response = await guardedFetch<{ id: number }>(
|
|
36
|
+
'https://api.example.com/users/1',
|
|
37
|
+
{
|
|
38
|
+
maxRetries: 3,
|
|
39
|
+
baseDelay: 300,
|
|
40
|
+
onRetry: (info) => console.log(`Retrying... attempt ${info.attempt + 1}`),
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
console.log(response.data); // typed as { id: number }
|
|
45
|
+
console.log(response.status); // 200
|
|
46
|
+
console.log(response.attempts); // number of attempts made
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### axios adapter
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { guardedAxios } from 'fetch-shield';
|
|
53
|
+
|
|
54
|
+
const response = await guardedAxios<{ id: number }>(
|
|
55
|
+
{
|
|
56
|
+
method: 'GET',
|
|
57
|
+
url: 'https://api.example.com/users/1',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
maxRetries: 3,
|
|
61
|
+
baseDelay: 300,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
console.log(response.data);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### With logger
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { guardedFetch, defaultRetryLogger } from 'fetch-shield';
|
|
72
|
+
|
|
73
|
+
const response = await guardedFetch('https://api.example.com/data', {
|
|
74
|
+
maxRetries: 3,
|
|
75
|
+
onRetry: (info) =>
|
|
76
|
+
defaultRetryLogger(info, {
|
|
77
|
+
url: 'https://api.example.com/data',
|
|
78
|
+
method: 'GET',
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Options
|
|
84
|
+
|
|
85
|
+
| Option | Type | Default | Description |
|
|
86
|
+
| ------------------ | ---------- | -------------------------------- | ------------------------------------------ |
|
|
87
|
+
| `maxRetries` | `number` | `3` | Max retry attempts after initial try |
|
|
88
|
+
| `baseDelay` | `number` | `300` | Base delay in ms for backoff |
|
|
89
|
+
| `maxDelay` | `number` | `10000` | Max delay cap in ms |
|
|
90
|
+
| `jitter` | `boolean` | `true` | Add random jitter to avoid thundering herd |
|
|
91
|
+
| `retryStatusCodes` | `number[]` | `[408, 429, 500, 502, 503, 504]` | Status codes that trigger a retry |
|
|
92
|
+
| `onRetry` | `function` | `undefined` | Called before each retry with attempt info |
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fetch-shield",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsup",
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"dev": "tsup --watch"
|
|
10
|
+
},
|
|
11
|
+
"vitest": {
|
|
12
|
+
"include": [
|
|
13
|
+
"src/**/*.test.ts"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/DIlANMW/fetch-shield-pkg.git"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"type": "commonjs",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/DIlANMW/fetch-shield-pkg/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/DIlANMW/fetch-shield-pkg#readme",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.9.3",
|
|
30
|
+
"axios": "^1.17.0",
|
|
31
|
+
"tsup": "^8.5.1",
|
|
32
|
+
"typescript": "^6.0.3",
|
|
33
|
+
"vitest": "^1.6.1"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from "axios";
|
|
2
|
+
import { withRetry, parseRetryAfter } from "../core/retry";
|
|
3
|
+
import { RetryOptions, GuardianResponse } from "../core/types";
|
|
4
|
+
|
|
5
|
+
export interface AxiosGuardianOptions extends RetryOptions {
|
|
6
|
+
/** An axios instance, or pass nothing to use axios's default export */
|
|
7
|
+
axiosInstance?: AxiosInstance;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wraps an axios request with automatic retries, exponential backoff,
|
|
12
|
+
* and Retry-After header support.
|
|
13
|
+
*
|
|
14
|
+
* Note: axios is a peer dependency,if it's not installed, this function
|
|
15
|
+
* will throw a clear error when called (not at import time).
|
|
16
|
+
*/
|
|
17
|
+
export async function guardedAxios<T = unknown>(
|
|
18
|
+
config: AxiosRequestConfig,
|
|
19
|
+
options: AxiosGuardianOptions = {}
|
|
20
|
+
): Promise<GuardianResponse<T>> {
|
|
21
|
+
const { axiosInstance, ...retryOptions } = options;
|
|
22
|
+
|
|
23
|
+
let client: AxiosInstance;
|
|
24
|
+
if (axiosInstance) {
|
|
25
|
+
client = axiosInstance;
|
|
26
|
+
} else {
|
|
27
|
+
try {
|
|
28
|
+
// Lazy require so axios stays optional at runtime
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
30
|
+
const axiosModule = require("axios");
|
|
31
|
+
client = axiosModule.default ?? axiosModule;
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"request-guardian: axios is not installed. Run `npm install axios` to use guardedAxios, or pass an axiosInstance."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return withRetry(async (attempt) => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await client.request<T>(config);
|
|
42
|
+
|
|
43
|
+
const retryAfter = parseRetryAfter(
|
|
44
|
+
(res.headers["retry-after"] as string) ?? null
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
data: res.data,
|
|
49
|
+
status: res.status,
|
|
50
|
+
headers: res.headers as Record<string, string>,
|
|
51
|
+
attempts: attempt + 1,
|
|
52
|
+
retryAfter,
|
|
53
|
+
};
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const axiosErr = err as AxiosError;
|
|
56
|
+
|
|
57
|
+
// If axios got 429, 500 like, treat it as a result
|
|
58
|
+
// so withRetry can decide whether to retry based on status code.
|
|
59
|
+
if (axiosErr.response) {
|
|
60
|
+
const retryAfter = parseRetryAfter(
|
|
61
|
+
(axiosErr.response.headers["retry-after"] as string) ?? null
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
data: axiosErr.response.data as T,
|
|
66
|
+
status: axiosErr.response.status,
|
|
67
|
+
headers: axiosErr.response.headers as Record<string, string>,
|
|
68
|
+
attempts: attempt + 1,
|
|
69
|
+
retryAfter,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// No response (network error, timeout) rethrow so withRetry
|
|
74
|
+
// retries based on the catch branch instead.
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}, retryOptions);
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { withRetry, parseRetryAfter } from "../core/retry";
|
|
2
|
+
import { RetryOptions, GuardianResponse } from "../core/types";
|
|
3
|
+
|
|
4
|
+
export interface FetchGuardianOptions extends RetryOptions {
|
|
5
|
+
fetchOptions?: RequestInit;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wraps native fetch with automatic retries, exponential backoff,
|
|
10
|
+
* and Retry-After header support for 429 responses.
|
|
11
|
+
*/
|
|
12
|
+
export async function guardedFetch<T = unknown>(
|
|
13
|
+
url: string,
|
|
14
|
+
options: FetchGuardianOptions = {}
|
|
15
|
+
): Promise<GuardianResponse<T>> {
|
|
16
|
+
const { fetchOptions, ...retryOptions } = options;
|
|
17
|
+
|
|
18
|
+
return withRetry(async (attempt) => {
|
|
19
|
+
const res = await fetch(url, fetchOptions);
|
|
20
|
+
|
|
21
|
+
const headers: Record<string, string> = {};
|
|
22
|
+
res.headers.forEach((value, key) => {
|
|
23
|
+
headers[key] = value;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let data: T;
|
|
27
|
+
const contentType = res.headers.get("content-type") || "";
|
|
28
|
+
if (contentType.includes("application/json")) {
|
|
29
|
+
data = (await res.json()) as T;
|
|
30
|
+
} else {
|
|
31
|
+
data = (await res.text()) as unknown as T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
data,
|
|
38
|
+
status: res.status,
|
|
39
|
+
headers,
|
|
40
|
+
attempts: attempt + 1,
|
|
41
|
+
retryAfter,
|
|
42
|
+
};
|
|
43
|
+
}, retryOptions);
|
|
44
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { RetryOptions, RetryInfo } from "./types";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_OPTIONS: Required<Omit<RetryOptions, "onRetry">> = {
|
|
4
|
+
maxRetries: 3,
|
|
5
|
+
baseDelay: 300,
|
|
6
|
+
maxDelay: 10000,
|
|
7
|
+
jitter: true,
|
|
8
|
+
retryStatusCodes: [408, 429, 500, 502, 503, 504],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Calculates delay for a given attempt using exponential backoff.
|
|
13
|
+
* Formula: min(baseDelay * 2^attempt, maxDelay) + optional jitter
|
|
14
|
+
*/
|
|
15
|
+
export function calculateDelay(
|
|
16
|
+
attempt: number,
|
|
17
|
+
options: Required<Omit<RetryOptions, "onRetry">>
|
|
18
|
+
): number {
|
|
19
|
+
const exponential = options.baseDelay * Math.pow(2, attempt);
|
|
20
|
+
const capped = Math.min(exponential, options.maxDelay);
|
|
21
|
+
|
|
22
|
+
if (options.jitter) {
|
|
23
|
+
// Add random jitter between 0 and capped delay (full jitter strategy)
|
|
24
|
+
return Math.floor(Math.random() * capped);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return capped;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads a Retry-After header value (seconds or HTTP date) and converts to ms.
|
|
32
|
+
* Returns null if header is missing or unparsable.
|
|
33
|
+
*/
|
|
34
|
+
export function parseRetryAfter(headerValue: string | null): number | null {
|
|
35
|
+
if (!headerValue) return null;
|
|
36
|
+
|
|
37
|
+
const seconds = Number(headerValue);
|
|
38
|
+
if (!Number.isNaN(seconds)) {
|
|
39
|
+
return seconds * 1000;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const date = new Date(headerValue).getTime();
|
|
43
|
+
if (!Number.isNaN(date)) {
|
|
44
|
+
const diff = date - Date.now();
|
|
45
|
+
return diff > 0 ? diff : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sleep(ms: number): Promise<void> {
|
|
52
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generic retry wrapper. Takes a function that performs one attempt
|
|
57
|
+
* and returns a result with a status field.
|
|
58
|
+
* Retries based on status code or thrown error, with exponential backoff.
|
|
59
|
+
*/
|
|
60
|
+
export async function withRetry<T extends { status?: number; retryAfter?: number | null }>(
|
|
61
|
+
fn: (attempt: number) => Promise<T>,
|
|
62
|
+
userOptions: RetryOptions = {}
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
const options: Required<Omit<RetryOptions, "onRetry">> = {
|
|
65
|
+
...DEFAULT_OPTIONS,
|
|
66
|
+
...userOptions,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
let lastError: unknown;
|
|
70
|
+
|
|
71
|
+
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
|
|
72
|
+
try {
|
|
73
|
+
const result = await fn(attempt);
|
|
74
|
+
|
|
75
|
+
const status = result.status;
|
|
76
|
+
const shouldRetry =
|
|
77
|
+
status !== undefined && options.retryStatusCodes.includes(status);
|
|
78
|
+
|
|
79
|
+
if (!shouldRetry || attempt === options.maxRetries) {
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Prefer Retry-After header if present, else exponential backoff
|
|
84
|
+
const delay =
|
|
85
|
+
result.retryAfter ?? calculateDelay(attempt, options);
|
|
86
|
+
|
|
87
|
+
userOptions.onRetry?.({ attempt, error: null, delay, status });
|
|
88
|
+
await sleep(delay);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
lastError = err;
|
|
91
|
+
|
|
92
|
+
if (attempt === options.maxRetries) {
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const delay = calculateDelay(attempt, options);
|
|
97
|
+
userOptions.onRetry?.({ attempt, error: err, delay });
|
|
98
|
+
await sleep(delay);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw lastError;
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
/** Max number of retry attempts (not counting the initial try) */
|
|
3
|
+
maxRetries?: number;
|
|
4
|
+
/** Base delay in ms for exponential backoff */
|
|
5
|
+
baseDelay?: number;
|
|
6
|
+
/** Max delay cap in ms */
|
|
7
|
+
maxDelay?: number;
|
|
8
|
+
/** Add random jitter to avoid thundering herd */
|
|
9
|
+
jitter?: boolean;
|
|
10
|
+
/** HTTP status codes that should trigger a retry */
|
|
11
|
+
retryStatusCodes?: number[];
|
|
12
|
+
/** Called before each retry attempt, useful for logging */
|
|
13
|
+
onRetry?: (info: RetryInfo) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RetryInfo {
|
|
17
|
+
attempt: number;
|
|
18
|
+
error: unknown;
|
|
19
|
+
delay: number;
|
|
20
|
+
status?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GuardianResponse<T = unknown> {
|
|
24
|
+
data: T;
|
|
25
|
+
status: number;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
attempts: number;
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { guardedFetch } from "./adapters/fetch";
|
|
2
|
+
export type { FetchGuardianOptions } from "./adapters/fetch";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export { guardedAxios } from "./adapters/axios";
|
|
6
|
+
export type { AxiosGuardianOptions } from "./adapters/axios";
|
|
7
|
+
|
|
8
|
+
export { defaultRetryLogger, logFinalFailure } from "./utils/logger";
|
|
9
|
+
export type { LogContext } from "./utils/logger";
|
|
10
|
+
|
|
11
|
+
export { calculateDelay, parseRetryAfter } from "./core/retry";
|
|
12
|
+
export type { RetryOptions, RetryInfo, GuardianResponse } from "./core/types";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { calculateDelay, parseRetryAfter, withRetry, sleep } from "./core/retry";
|
|
3
|
+
|
|
4
|
+
// âââ calculateDelay âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
5
|
+
|
|
6
|
+
describe("calculateDelay", () => {
|
|
7
|
+
const baseOptions = {
|
|
8
|
+
baseDelay: 300,
|
|
9
|
+
maxDelay: 10000,
|
|
10
|
+
jitter: false,
|
|
11
|
+
maxRetries: 3,
|
|
12
|
+
retryStatusCodes: [429, 500],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
it("doubles delay on each attempt", () => {
|
|
16
|
+
expect(calculateDelay(0, baseOptions)).toBe(300);
|
|
17
|
+
expect(calculateDelay(1, baseOptions)).toBe(600);
|
|
18
|
+
expect(calculateDelay(2, baseOptions)).toBe(1200);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("caps at maxDelay", () => {
|
|
22
|
+
expect(calculateDelay(10, baseOptions)).toBe(10000);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns value within range when jitter is enabled", () => {
|
|
26
|
+
const delay = calculateDelay(1, { ...baseOptions, jitter: true });
|
|
27
|
+
expect(delay).toBeGreaterThanOrEqual(0);
|
|
28
|
+
expect(delay).toBeLessThanOrEqual(600);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// âââ parseRetryAfter ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
33
|
+
|
|
34
|
+
describe("parseRetryAfter", () => {
|
|
35
|
+
it("parses seconds value", () => {
|
|
36
|
+
expect(parseRetryAfter("5")).toBe(5000);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parses HTTP date string", () => {
|
|
40
|
+
const future = new Date(Date.now() + 10000).toUTCString();
|
|
41
|
+
const result = parseRetryAfter(future);
|
|
42
|
+
expect(result).toBeGreaterThan(0);
|
|
43
|
+
expect(result).toBeLessThanOrEqual(10000);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null for null input", () => {
|
|
47
|
+
expect(parseRetryAfter(null)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns null for garbage input", () => {
|
|
51
|
+
expect(parseRetryAfter("not-a-date")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// âââ sleep ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
56
|
+
|
|
57
|
+
describe("sleep", () => {
|
|
58
|
+
it("resolves after the given ms", async () => {
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
await sleep(50);
|
|
61
|
+
expect(Date.now() - start).toBeGreaterThanOrEqual(45);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// âââ withRetry ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
66
|
+
|
|
67
|
+
describe("withRetry", () => {
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.useRealTimers();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns immediately on success", async () => {
|
|
73
|
+
vi.useFakeTimers();
|
|
74
|
+
const fn = vi.fn().mockResolvedValue({ status: 200, data: "ok" });
|
|
75
|
+
|
|
76
|
+
const promise = withRetry(fn, { maxRetries: 3, baseDelay: 100, jitter: false });
|
|
77
|
+
await vi.runAllTimersAsync();
|
|
78
|
+
const result = await promise;
|
|
79
|
+
|
|
80
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(result.status).toBe(200);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("retries on retryable status code", async () => {
|
|
85
|
+
vi.useFakeTimers();
|
|
86
|
+
const fn = vi
|
|
87
|
+
.fn()
|
|
88
|
+
.mockResolvedValueOnce({ status: 500 })
|
|
89
|
+
.mockResolvedValueOnce({ status: 500 })
|
|
90
|
+
.mockResolvedValue({ status: 200, data: "ok" });
|
|
91
|
+
|
|
92
|
+
const promise = withRetry(fn, { maxRetries: 3, baseDelay: 100, jitter: false });
|
|
93
|
+
await vi.runAllTimersAsync();
|
|
94
|
+
const result = await promise;
|
|
95
|
+
|
|
96
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
97
|
+
expect(result.status).toBe(200);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("retries on thrown error then succeeds", async () => {
|
|
101
|
+
vi.useFakeTimers();
|
|
102
|
+
const fn = vi
|
|
103
|
+
.fn()
|
|
104
|
+
.mockRejectedValueOnce(new Error("network error"))
|
|
105
|
+
.mockResolvedValue({ status: 200, data: "ok" });
|
|
106
|
+
|
|
107
|
+
const promise = withRetry(fn, { maxRetries: 3, baseDelay: 100, jitter: false });
|
|
108
|
+
await vi.runAllTimersAsync();
|
|
109
|
+
const result = await promise;
|
|
110
|
+
|
|
111
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
112
|
+
expect(result.status).toBe(200);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("throws after maxRetries exhausted", async () => {
|
|
116
|
+
// Use real timers with a tiny delay to avoid unhandled rejection issues
|
|
117
|
+
const fn = vi
|
|
118
|
+
.fn()
|
|
119
|
+
.mockRejectedValueOnce(new Error("always fails"))
|
|
120
|
+
.mockRejectedValueOnce(new Error("always fails"))
|
|
121
|
+
.mockRejectedValue(new Error("always fails"));
|
|
122
|
+
|
|
123
|
+
await expect(
|
|
124
|
+
withRetry(fn, { maxRetries: 2, baseDelay: 1, jitter: false })
|
|
125
|
+
).rejects.toThrow("always fails");
|
|
126
|
+
|
|
127
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("calls onRetry with correct info", async () => {
|
|
131
|
+
vi.useFakeTimers();
|
|
132
|
+
const onRetry = vi.fn();
|
|
133
|
+
const fn = vi
|
|
134
|
+
.fn()
|
|
135
|
+
.mockResolvedValueOnce({ status: 500 })
|
|
136
|
+
.mockResolvedValue({ status: 200 });
|
|
137
|
+
|
|
138
|
+
const promise = withRetry(fn, {
|
|
139
|
+
maxRetries: 3,
|
|
140
|
+
baseDelay: 100,
|
|
141
|
+
jitter: false,
|
|
142
|
+
onRetry,
|
|
143
|
+
});
|
|
144
|
+
await vi.runAllTimersAsync();
|
|
145
|
+
await promise;
|
|
146
|
+
|
|
147
|
+
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(onRetry).toHaveBeenCalledWith(
|
|
149
|
+
expect.objectContaining({ attempt: 0, status: 500 })
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("respects retryAfter delay from response", async () => {
|
|
154
|
+
vi.useFakeTimers();
|
|
155
|
+
const fn = vi
|
|
156
|
+
.fn()
|
|
157
|
+
.mockResolvedValueOnce({ status: 429, retryAfter: 2000 })
|
|
158
|
+
.mockResolvedValue({ status: 200, data: "ok" });
|
|
159
|
+
|
|
160
|
+
const advanceSpy = vi.spyOn(globalThis, "setTimeout");
|
|
161
|
+
|
|
162
|
+
const promise = withRetry(fn, { maxRetries: 3, baseDelay: 100, jitter: false });
|
|
163
|
+
await vi.runAllTimersAsync();
|
|
164
|
+
await promise;
|
|
165
|
+
|
|
166
|
+
const delays = advanceSpy.mock.calls.map((c) => c[1]);
|
|
167
|
+
expect(delays).toContain(2000);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RetryInfo } from "../core/types";
|
|
2
|
+
|
|
3
|
+
export interface LogContext {
|
|
4
|
+
url?: string;
|
|
5
|
+
method?: string;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default logger prints structured info about retries and failures
|
|
11
|
+
* to the console. Pass your own function via onRetry to override.
|
|
12
|
+
*/
|
|
13
|
+
export function defaultRetryLogger(info: RetryInfo, context: LogContext = {}): void {
|
|
14
|
+
const { attempt, status, error, delay } = info;
|
|
15
|
+
|
|
16
|
+
const base = `[request-guardian] attempt ${attempt + 1} failed`;
|
|
17
|
+
const ctx = context.url ? ` for ${context.method ?? "GET"} ${context.url}` : "";
|
|
18
|
+
|
|
19
|
+
if (status !== undefined) {
|
|
20
|
+
console.warn(`${base}${ctx} â status ${status}. Retrying in ${delay}ms...`);
|
|
21
|
+
} else {
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
console.warn(`${base}${ctx} â error: ${message}. Retrying in ${delay}ms...`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Logs a final failure (after all retries exhausted) with full context.
|
|
29
|
+
*/
|
|
30
|
+
export function logFinalFailure(
|
|
31
|
+
info: { attempts: number; status?: number; error?: unknown },
|
|
32
|
+
context: LogContext = {}
|
|
33
|
+
): void {
|
|
34
|
+
const ctx = context.url ? ` ${context.method ?? "GET"} ${context.url}` : "";
|
|
35
|
+
|
|
36
|
+
if (info.status !== undefined) {
|
|
37
|
+
console.error(
|
|
38
|
+
`[request-guardian] gave up${ctx} after ${info.attempts} attempt(s) â final status ${info.status}`
|
|
39
|
+
);
|
|
40
|
+
} else {
|
|
41
|
+
const message = info.error instanceof Error ? info.error.message : String(info.error);
|
|
42
|
+
console.error(
|
|
43
|
+
`[request-guardian] gave up${ctx} after ${info.attempts} attempt(s) â ${message}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"types": ["node"],
|
|
12
|
+
"ignoreDeprecations": "5.0"
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|