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 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
+ [![npm version](https://img.shields.io/npm/v/fetch-shield)](https://www.npmjs.com/package/fetch-shield)
4
+ [![npm downloads](https://img.shields.io/npm/dm/fetch-shield)](https://www.npmjs.com/package/fetch-shield)
5
+ [![license](https://img.shields.io/npm/l/fetch-shield)](./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
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["cjs", "esm"],
6
+ dts: false,
7
+ clean: true,
8
+ sourcemap: true,
9
+ });