@cedvict/http-guardian-plugin-retry 0.2.1 → 0.5.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/CHANGELOG.md +3 -0
- package/dist/index.cjs +5 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Fix: a `Retry-After` is a floor, no longer capped by `maxDelayMs` — the plugin
|
|
6
|
+
never retries sooner than the server asks. New `maxRetryAfterMs` (default
|
|
7
|
+
60 000 ms): a longer `Retry-After` returns the response instead of retrying early.
|
|
5
8
|
- Add `isRetryableResponse`, whose explicit boolean decision takes precedence
|
|
6
9
|
over `retryOnStatuses` while preserving request-level idempotency checks.
|
|
7
10
|
- Add `retryableEnvelopeClassifier` for MIC-compatible JSON error envelopes.
|
package/dist/index.cjs
CHANGED
|
@@ -39,6 +39,7 @@ function retryPlugin(options = {}) {
|
|
|
39
39
|
const retries = options.retries ?? 3;
|
|
40
40
|
const baseDelayMs = options.baseDelayMs ?? 200;
|
|
41
41
|
const maxDelayMs = options.maxDelayMs ?? 5e3;
|
|
42
|
+
const maxRetryAfterMs = options.maxRetryAfterMs ?? 6e4;
|
|
42
43
|
const jitter = options.jitter ?? "full";
|
|
43
44
|
const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];
|
|
44
45
|
const retryOnNetworkError = options.retryOnNetworkError ?? true;
|
|
@@ -72,7 +73,10 @@ function retryPlugin(options = {}) {
|
|
|
72
73
|
let delay = backoffDelayMs(meta.attempt, baseDelayMs, maxDelayMs, jitter);
|
|
73
74
|
if (res) {
|
|
74
75
|
const ra = parseRetryAfterMs(res.headers, now);
|
|
75
|
-
if (typeof ra === "number")
|
|
76
|
+
if (typeof ra === "number") {
|
|
77
|
+
if (ra > maxRetryAfterMs) return res;
|
|
78
|
+
delay = Math.max(delay, ra);
|
|
79
|
+
}
|
|
76
80
|
}
|
|
77
81
|
await wait(delay, ctx);
|
|
78
82
|
ctx = Object.assign({}, ctx, { attempt: ctx.attempt + 1 });
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { HttpPlugin } from \"@cedvict/http-guardian\";\nimport type { Guard } from \"@cedvict/http-guardian\";\nimport type { RequestContext } from \"@cedvict/http-guardian\";\n\nexport type RetryJitter = \"none\" | \"full\";\n\nexport type RetryResponseClassifier = (\n response: Response,\n ctx: RequestContext,\n) => boolean | undefined | Promise<boolean | undefined>;\n\nexport type RetryPluginOptions = {\n retries?: number;\n baseDelayMs?: number;\n maxDelayMs?: number;\n jitter?: RetryJitter;\n retryOnStatuses?: number[];\n retryOnNetworkError?: boolean;\n /**\n * By default, retries only idempotent methods.\n */\n isRetryableRequest?: (ctx: RequestContext) => boolean;\n /**\n * Classifies a response before applying `retryOnStatuses`. Return `true` to\n * retry, `false` to stop, or `undefined` to keep the status-based fallback.\n * The callback receives a clone, so reading its body is safe.\n */\n isRetryableResponse?: RetryResponseClassifier;\n /**\n * Override wait logic (useful for tests).\n */\n wait?: (ms: number, ctx: RequestContext) => Promise<void>;\n};\n\nconst META_KEY = Symbol.for(\"http-guardian.retry\");\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n}\n\nfunction parseRetryAfterMs(h: Headers, nowMs: number): number | undefined {\n const v = h.get(\"retry-after\");\n if (!v) return undefined;\n const asInt = Number(v);\n if (Number.isFinite(asInt)) return Math.max(0, asInt * 1000);\n const asDate = Date.parse(v);\n if (!Number.isNaN(asDate)) return Math.max(0, asDate - nowMs);\n return undefined;\n}\n\nfunction isIdempotent(method: string): boolean {\n return method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\";\n}\n\nfunction backoffDelayMs(attempt: number, base: number, max: number, jitter: RetryJitter): number {\n // attempt starts at 1 for first retry\n const exp = Math.min(max, base * Math.pow(2, attempt - 1));\n if (jitter === \"full\") return Math.floor(Math.random() * exp);\n return exp;\n}\n\n/**\n * Reads a top-level boolean `retryable` field from a JSON error envelope.\n * Missing, malformed, and non-error bodies defer to the status-based policy.\n */\nexport async function retryableEnvelopeClassifier(response: Response): Promise<boolean | undefined> {\n if (response.status < 400) return undefined;\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (!contentType.toLowerCase().includes(\"json\")) return undefined;\n\n try {\n const raw: unknown = await response.json();\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const retryable = (raw as Record<string, unknown>)[\"retryable\"];\n return typeof retryable === \"boolean\" ? retryable : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Production-grade retry plugin:\n * - exponential backoff (+ jitter)\n * - honors Retry-After for 429/503 etc\n * - idempotent only by default\n * - uses ctx.attempt to keep compatibility with other guards\n */\nexport function retryPlugin(options: RetryPluginOptions = {}): HttpPlugin {\n const retries = options.retries ?? 3;\n const baseDelayMs = options.baseDelayMs ?? 200;\n const maxDelayMs = options.maxDelayMs ?? 5_000;\n const jitter = options.jitter ?? \"full\";\n const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];\n const retryOnNetworkError = options.retryOnNetworkError ?? true;\n const isRetryableRequest = options.isRetryableRequest ?? ((ctx) => isIdempotent(ctx.method));\n const isRetryableResponse = options.isRetryableResponse;\n const wait = options.wait ?? ((ms) => sleep(ms));\n\n const guard: Guard = async (ctx, next) => {\n if (!isRetryableRequest(ctx)) return next(ctx);\n\n const meta = ((ctx as any)[META_KEY] ??= { attempt: 0 }) as { attempt: number };\n // If another guard already replayed, do not loop forever:\n // retries count are independent from refresh, but we cap them.\n while (true) {\n let res: Response | undefined;\n let networkError: unknown | undefined;\n\n try {\n res = await next(ctx);\n } catch (e) {\n networkError = e;\n }\n\n const shouldRetryNetwork = !!networkError && retryOnNetworkError;\n const retryableResponse = res && isRetryableResponse\n ? await isRetryableResponse(res.clone(), ctx)\n : undefined;\n const shouldRetryStatus = !!res && (retryableResponse ?? retryOnStatuses.includes(res.status));\n\n if (!shouldRetryNetwork && !shouldRetryStatus) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n if (meta.attempt >= retries) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n meta.attempt += 1;\n\n const now = Date.now();\n let delay = backoffDelayMs(meta.attempt, baseDelayMs, maxDelayMs, jitter);\n\n if (res) {\n const ra = parseRetryAfterMs(res.headers, now);\n if (typeof ra === \"number\") delay = Math.min(maxDelayMs, Math.max(delay, ra));\n }\n\n await wait(delay, ctx);\n\n // clone ctx with incremented attempt to keep anti-loop invariants aligned with core\n ctx = Object.assign({}, ctx, { attempt: ctx.attempt + 1 }) as RequestContext;\n (ctx as any)[META_KEY] = meta;\n }\n };\n\n return {\n name: \"retry\",\n apply: (draft) => {\n draft.guards = [...(draft.guards ?? []), guard];\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { HttpPlugin } from \"@cedvict/http-guardian\";\nimport type { Guard } from \"@cedvict/http-guardian\";\nimport type { RequestContext } from \"@cedvict/http-guardian\";\n\nexport type RetryJitter = \"none\" | \"full\";\n\nexport type RetryResponseClassifier = (\n response: Response,\n ctx: RequestContext,\n) => boolean | undefined | Promise<boolean | undefined>;\n\nexport type RetryPluginOptions = {\n retries?: number;\n baseDelayMs?: number;\n /** Ceiling of the backoff delay — never of a `Retry-After`. */\n maxDelayMs?: number;\n /**\n * Longest `Retry-After` the plugin accepts to wait. Beyond it, the response is\n * returned as is rather than retried early. Default: 60 000 ms.\n */\n maxRetryAfterMs?: number;\n jitter?: RetryJitter;\n retryOnStatuses?: number[];\n retryOnNetworkError?: boolean;\n /**\n * By default, retries only idempotent methods.\n */\n isRetryableRequest?: (ctx: RequestContext) => boolean;\n /**\n * Classifies a response before applying `retryOnStatuses`. Return `true` to\n * retry, `false` to stop, or `undefined` to keep the status-based fallback.\n * The callback receives a clone, so reading its body is safe.\n */\n isRetryableResponse?: RetryResponseClassifier;\n /**\n * Override wait logic (useful for tests).\n */\n wait?: (ms: number, ctx: RequestContext) => Promise<void>;\n};\n\nconst META_KEY = Symbol.for(\"http-guardian.retry\");\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n}\n\nfunction parseRetryAfterMs(h: Headers, nowMs: number): number | undefined {\n const v = h.get(\"retry-after\");\n if (!v) return undefined;\n const asInt = Number(v);\n if (Number.isFinite(asInt)) return Math.max(0, asInt * 1000);\n const asDate = Date.parse(v);\n if (!Number.isNaN(asDate)) return Math.max(0, asDate - nowMs);\n return undefined;\n}\n\nfunction isIdempotent(method: string): boolean {\n return method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\";\n}\n\nfunction backoffDelayMs(attempt: number, base: number, max: number, jitter: RetryJitter): number {\n // attempt starts at 1 for first retry\n const exp = Math.min(max, base * Math.pow(2, attempt - 1));\n if (jitter === \"full\") return Math.floor(Math.random() * exp);\n return exp;\n}\n\n/**\n * Reads a top-level boolean `retryable` field from a JSON error envelope.\n * Missing, malformed, and non-error bodies defer to the status-based policy.\n */\nexport async function retryableEnvelopeClassifier(response: Response): Promise<boolean | undefined> {\n if (response.status < 400) return undefined;\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (!contentType.toLowerCase().includes(\"json\")) return undefined;\n\n try {\n const raw: unknown = await response.json();\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const retryable = (raw as Record<string, unknown>)[\"retryable\"];\n return typeof retryable === \"boolean\" ? retryable : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Production-grade retry plugin:\n * - exponential backoff (+ jitter)\n * - honors Retry-After for 429/503 etc: never retries sooner than the server asks\n * - idempotent only by default\n * - uses ctx.attempt to keep compatibility with other guards\n */\nexport function retryPlugin(options: RetryPluginOptions = {}): HttpPlugin {\n const retries = options.retries ?? 3;\n const baseDelayMs = options.baseDelayMs ?? 200;\n const maxDelayMs = options.maxDelayMs ?? 5_000;\n const maxRetryAfterMs = options.maxRetryAfterMs ?? 60_000;\n const jitter = options.jitter ?? \"full\";\n const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];\n const retryOnNetworkError = options.retryOnNetworkError ?? true;\n const isRetryableRequest = options.isRetryableRequest ?? ((ctx) => isIdempotent(ctx.method));\n const isRetryableResponse = options.isRetryableResponse;\n const wait = options.wait ?? ((ms) => sleep(ms));\n\n const guard: Guard = async (ctx, next) => {\n if (!isRetryableRequest(ctx)) return next(ctx);\n\n const meta = ((ctx as any)[META_KEY] ??= { attempt: 0 }) as { attempt: number };\n // If another guard already replayed, do not loop forever:\n // retries count are independent from refresh, but we cap them.\n while (true) {\n let res: Response | undefined;\n let networkError: unknown | undefined;\n\n try {\n res = await next(ctx);\n } catch (e) {\n networkError = e;\n }\n\n const shouldRetryNetwork = !!networkError && retryOnNetworkError;\n const retryableResponse = res && isRetryableResponse\n ? await isRetryableResponse(res.clone(), ctx)\n : undefined;\n const shouldRetryStatus = !!res && (retryableResponse ?? retryOnStatuses.includes(res.status));\n\n if (!shouldRetryNetwork && !shouldRetryStatus) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n if (meta.attempt >= retries) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n meta.attempt += 1;\n\n const now = Date.now();\n let delay = backoffDelayMs(meta.attempt, baseDelayMs, maxDelayMs, jitter);\n\n if (res) {\n // Never retry sooner than the server asks: a Retry-After is a floor, not\n // a hint the backoff ceiling may cut. Too long a wait ends the retries.\n const ra = parseRetryAfterMs(res.headers, now);\n if (typeof ra === \"number\") {\n if (ra > maxRetryAfterMs) return res;\n delay = Math.max(delay, ra);\n }\n }\n\n await wait(delay, ctx);\n\n // clone ctx with incremented attempt to keep anti-loop invariants aligned with core\n ctx = Object.assign({}, ctx, { attempt: ctx.attempt + 1 }) as RequestContext;\n (ctx as any)[META_KEY] = meta;\n }\n };\n\n return {\n name: \"retry\",\n apply: (draft) => {\n draft.guards = [...(draft.guards ?? []), guard];\n },\n };\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -5,7 +5,13 @@ type RetryResponseClassifier = (response: Response, ctx: RequestContext) => bool
|
|
|
5
5
|
type RetryPluginOptions = {
|
|
6
6
|
retries?: number;
|
|
7
7
|
baseDelayMs?: number;
|
|
8
|
+
/** Ceiling of the backoff delay — never of a `Retry-After`. */
|
|
8
9
|
maxDelayMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Longest `Retry-After` the plugin accepts to wait. Beyond it, the response is
|
|
12
|
+
* returned as is rather than retried early. Default: 60 000 ms.
|
|
13
|
+
*/
|
|
14
|
+
maxRetryAfterMs?: number;
|
|
9
15
|
jitter?: RetryJitter;
|
|
10
16
|
retryOnStatuses?: number[];
|
|
11
17
|
retryOnNetworkError?: boolean;
|
|
@@ -32,10 +38,11 @@ declare function retryableEnvelopeClassifier(response: Response): Promise<boolea
|
|
|
32
38
|
/**
|
|
33
39
|
* Production-grade retry plugin:
|
|
34
40
|
* - exponential backoff (+ jitter)
|
|
35
|
-
* - honors Retry-After for 429/503 etc
|
|
41
|
+
* - honors Retry-After for 429/503 etc: never retries sooner than the server asks
|
|
36
42
|
* - idempotent only by default
|
|
37
43
|
* - uses ctx.attempt to keep compatibility with other guards
|
|
38
44
|
*/
|
|
39
45
|
declare function retryPlugin(options?: RetryPluginOptions): HttpPlugin;
|
|
40
46
|
|
|
41
|
-
export {
|
|
47
|
+
export { retryPlugin, retryableEnvelopeClassifier };
|
|
48
|
+
export type { RetryJitter, RetryPluginOptions, RetryResponseClassifier };
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,13 @@ type RetryResponseClassifier = (response: Response, ctx: RequestContext) => bool
|
|
|
5
5
|
type RetryPluginOptions = {
|
|
6
6
|
retries?: number;
|
|
7
7
|
baseDelayMs?: number;
|
|
8
|
+
/** Ceiling of the backoff delay — never of a `Retry-After`. */
|
|
8
9
|
maxDelayMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Longest `Retry-After` the plugin accepts to wait. Beyond it, the response is
|
|
12
|
+
* returned as is rather than retried early. Default: 60 000 ms.
|
|
13
|
+
*/
|
|
14
|
+
maxRetryAfterMs?: number;
|
|
9
15
|
jitter?: RetryJitter;
|
|
10
16
|
retryOnStatuses?: number[];
|
|
11
17
|
retryOnNetworkError?: boolean;
|
|
@@ -32,10 +38,11 @@ declare function retryableEnvelopeClassifier(response: Response): Promise<boolea
|
|
|
32
38
|
/**
|
|
33
39
|
* Production-grade retry plugin:
|
|
34
40
|
* - exponential backoff (+ jitter)
|
|
35
|
-
* - honors Retry-After for 429/503 etc
|
|
41
|
+
* - honors Retry-After for 429/503 etc: never retries sooner than the server asks
|
|
36
42
|
* - idempotent only by default
|
|
37
43
|
* - uses ctx.attempt to keep compatibility with other guards
|
|
38
44
|
*/
|
|
39
45
|
declare function retryPlugin(options?: RetryPluginOptions): HttpPlugin;
|
|
40
46
|
|
|
41
|
-
export {
|
|
47
|
+
export { retryPlugin, retryableEnvelopeClassifier };
|
|
48
|
+
export type { RetryJitter, RetryPluginOptions, RetryResponseClassifier };
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ function retryPlugin(options = {}) {
|
|
|
37
37
|
const retries = options.retries ?? 3;
|
|
38
38
|
const baseDelayMs = options.baseDelayMs ?? 200;
|
|
39
39
|
const maxDelayMs = options.maxDelayMs ?? 5e3;
|
|
40
|
+
const maxRetryAfterMs = options.maxRetryAfterMs ?? 6e4;
|
|
40
41
|
const jitter = options.jitter ?? "full";
|
|
41
42
|
const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];
|
|
42
43
|
const retryOnNetworkError = options.retryOnNetworkError ?? true;
|
|
@@ -70,7 +71,10 @@ function retryPlugin(options = {}) {
|
|
|
70
71
|
let delay = backoffDelayMs(meta.attempt, baseDelayMs, maxDelayMs, jitter);
|
|
71
72
|
if (res) {
|
|
72
73
|
const ra = parseRetryAfterMs(res.headers, now);
|
|
73
|
-
if (typeof ra === "number")
|
|
74
|
+
if (typeof ra === "number") {
|
|
75
|
+
if (ra > maxRetryAfterMs) return res;
|
|
76
|
+
delay = Math.max(delay, ra);
|
|
77
|
+
}
|
|
74
78
|
}
|
|
75
79
|
await wait(delay, ctx);
|
|
76
80
|
ctx = Object.assign({}, ctx, { attempt: ctx.attempt + 1 });
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { HttpPlugin } from \"@cedvict/http-guardian\";\nimport type { Guard } from \"@cedvict/http-guardian\";\nimport type { RequestContext } from \"@cedvict/http-guardian\";\n\nexport type RetryJitter = \"none\" | \"full\";\n\nexport type RetryResponseClassifier = (\n response: Response,\n ctx: RequestContext,\n) => boolean | undefined | Promise<boolean | undefined>;\n\nexport type RetryPluginOptions = {\n retries?: number;\n baseDelayMs?: number;\n maxDelayMs?: number;\n jitter?: RetryJitter;\n retryOnStatuses?: number[];\n retryOnNetworkError?: boolean;\n /**\n * By default, retries only idempotent methods.\n */\n isRetryableRequest?: (ctx: RequestContext) => boolean;\n /**\n * Classifies a response before applying `retryOnStatuses`. Return `true` to\n * retry, `false` to stop, or `undefined` to keep the status-based fallback.\n * The callback receives a clone, so reading its body is safe.\n */\n isRetryableResponse?: RetryResponseClassifier;\n /**\n * Override wait logic (useful for tests).\n */\n wait?: (ms: number, ctx: RequestContext) => Promise<void>;\n};\n\nconst META_KEY = Symbol.for(\"http-guardian.retry\");\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n}\n\nfunction parseRetryAfterMs(h: Headers, nowMs: number): number | undefined {\n const v = h.get(\"retry-after\");\n if (!v) return undefined;\n const asInt = Number(v);\n if (Number.isFinite(asInt)) return Math.max(0, asInt * 1000);\n const asDate = Date.parse(v);\n if (!Number.isNaN(asDate)) return Math.max(0, asDate - nowMs);\n return undefined;\n}\n\nfunction isIdempotent(method: string): boolean {\n return method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\";\n}\n\nfunction backoffDelayMs(attempt: number, base: number, max: number, jitter: RetryJitter): number {\n // attempt starts at 1 for first retry\n const exp = Math.min(max, base * Math.pow(2, attempt - 1));\n if (jitter === \"full\") return Math.floor(Math.random() * exp);\n return exp;\n}\n\n/**\n * Reads a top-level boolean `retryable` field from a JSON error envelope.\n * Missing, malformed, and non-error bodies defer to the status-based policy.\n */\nexport async function retryableEnvelopeClassifier(response: Response): Promise<boolean | undefined> {\n if (response.status < 400) return undefined;\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (!contentType.toLowerCase().includes(\"json\")) return undefined;\n\n try {\n const raw: unknown = await response.json();\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const retryable = (raw as Record<string, unknown>)[\"retryable\"];\n return typeof retryable === \"boolean\" ? retryable : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Production-grade retry plugin:\n * - exponential backoff (+ jitter)\n * - honors Retry-After for 429/503 etc\n * - idempotent only by default\n * - uses ctx.attempt to keep compatibility with other guards\n */\nexport function retryPlugin(options: RetryPluginOptions = {}): HttpPlugin {\n const retries = options.retries ?? 3;\n const baseDelayMs = options.baseDelayMs ?? 200;\n const maxDelayMs = options.maxDelayMs ?? 5_000;\n const jitter = options.jitter ?? \"full\";\n const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];\n const retryOnNetworkError = options.retryOnNetworkError ?? true;\n const isRetryableRequest = options.isRetryableRequest ?? ((ctx) => isIdempotent(ctx.method));\n const isRetryableResponse = options.isRetryableResponse;\n const wait = options.wait ?? ((ms) => sleep(ms));\n\n const guard: Guard = async (ctx, next) => {\n if (!isRetryableRequest(ctx)) return next(ctx);\n\n const meta = ((ctx as any)[META_KEY] ??= { attempt: 0 }) as { attempt: number };\n // If another guard already replayed, do not loop forever:\n // retries count are independent from refresh, but we cap them.\n while (true) {\n let res: Response | undefined;\n let networkError: unknown | undefined;\n\n try {\n res = await next(ctx);\n } catch (e) {\n networkError = e;\n }\n\n const shouldRetryNetwork = !!networkError && retryOnNetworkError;\n const retryableResponse = res && isRetryableResponse\n ? await isRetryableResponse(res.clone(), ctx)\n : undefined;\n const shouldRetryStatus = !!res && (retryableResponse ?? retryOnStatuses.includes(res.status));\n\n if (!shouldRetryNetwork && !shouldRetryStatus) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n if (meta.attempt >= retries) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n meta.attempt += 1;\n\n const now = Date.now();\n let delay = backoffDelayMs(meta.attempt, baseDelayMs, maxDelayMs, jitter);\n\n if (res) {\n const ra = parseRetryAfterMs(res.headers, now);\n if (typeof ra === \"number\") delay = Math.min(maxDelayMs, Math.max(delay, ra));\n }\n\n await wait(delay, ctx);\n\n // clone ctx with incremented attempt to keep anti-loop invariants aligned with core\n ctx = Object.assign({}, ctx, { attempt: ctx.attempt + 1 }) as RequestContext;\n (ctx as any)[META_KEY] = meta;\n }\n };\n\n return {\n name: \"retry\",\n apply: (draft) => {\n draft.guards = [...(draft.guards ?? []), guard];\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { HttpPlugin } from \"@cedvict/http-guardian\";\nimport type { Guard } from \"@cedvict/http-guardian\";\nimport type { RequestContext } from \"@cedvict/http-guardian\";\n\nexport type RetryJitter = \"none\" | \"full\";\n\nexport type RetryResponseClassifier = (\n response: Response,\n ctx: RequestContext,\n) => boolean | undefined | Promise<boolean | undefined>;\n\nexport type RetryPluginOptions = {\n retries?: number;\n baseDelayMs?: number;\n /** Ceiling of the backoff delay — never of a `Retry-After`. */\n maxDelayMs?: number;\n /**\n * Longest `Retry-After` the plugin accepts to wait. Beyond it, the response is\n * returned as is rather than retried early. Default: 60 000 ms.\n */\n maxRetryAfterMs?: number;\n jitter?: RetryJitter;\n retryOnStatuses?: number[];\n retryOnNetworkError?: boolean;\n /**\n * By default, retries only idempotent methods.\n */\n isRetryableRequest?: (ctx: RequestContext) => boolean;\n /**\n * Classifies a response before applying `retryOnStatuses`. Return `true` to\n * retry, `false` to stop, or `undefined` to keep the status-based fallback.\n * The callback receives a clone, so reading its body is safe.\n */\n isRetryableResponse?: RetryResponseClassifier;\n /**\n * Override wait logic (useful for tests).\n */\n wait?: (ms: number, ctx: RequestContext) => Promise<void>;\n};\n\nconst META_KEY = Symbol.for(\"http-guardian.retry\");\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n}\n\nfunction parseRetryAfterMs(h: Headers, nowMs: number): number | undefined {\n const v = h.get(\"retry-after\");\n if (!v) return undefined;\n const asInt = Number(v);\n if (Number.isFinite(asInt)) return Math.max(0, asInt * 1000);\n const asDate = Date.parse(v);\n if (!Number.isNaN(asDate)) return Math.max(0, asDate - nowMs);\n return undefined;\n}\n\nfunction isIdempotent(method: string): boolean {\n return method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\";\n}\n\nfunction backoffDelayMs(attempt: number, base: number, max: number, jitter: RetryJitter): number {\n // attempt starts at 1 for first retry\n const exp = Math.min(max, base * Math.pow(2, attempt - 1));\n if (jitter === \"full\") return Math.floor(Math.random() * exp);\n return exp;\n}\n\n/**\n * Reads a top-level boolean `retryable` field from a JSON error envelope.\n * Missing, malformed, and non-error bodies defer to the status-based policy.\n */\nexport async function retryableEnvelopeClassifier(response: Response): Promise<boolean | undefined> {\n if (response.status < 400) return undefined;\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (!contentType.toLowerCase().includes(\"json\")) return undefined;\n\n try {\n const raw: unknown = await response.json();\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const retryable = (raw as Record<string, unknown>)[\"retryable\"];\n return typeof retryable === \"boolean\" ? retryable : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Production-grade retry plugin:\n * - exponential backoff (+ jitter)\n * - honors Retry-After for 429/503 etc: never retries sooner than the server asks\n * - idempotent only by default\n * - uses ctx.attempt to keep compatibility with other guards\n */\nexport function retryPlugin(options: RetryPluginOptions = {}): HttpPlugin {\n const retries = options.retries ?? 3;\n const baseDelayMs = options.baseDelayMs ?? 200;\n const maxDelayMs = options.maxDelayMs ?? 5_000;\n const maxRetryAfterMs = options.maxRetryAfterMs ?? 60_000;\n const jitter = options.jitter ?? \"full\";\n const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];\n const retryOnNetworkError = options.retryOnNetworkError ?? true;\n const isRetryableRequest = options.isRetryableRequest ?? ((ctx) => isIdempotent(ctx.method));\n const isRetryableResponse = options.isRetryableResponse;\n const wait = options.wait ?? ((ms) => sleep(ms));\n\n const guard: Guard = async (ctx, next) => {\n if (!isRetryableRequest(ctx)) return next(ctx);\n\n const meta = ((ctx as any)[META_KEY] ??= { attempt: 0 }) as { attempt: number };\n // If another guard already replayed, do not loop forever:\n // retries count are independent from refresh, but we cap them.\n while (true) {\n let res: Response | undefined;\n let networkError: unknown | undefined;\n\n try {\n res = await next(ctx);\n } catch (e) {\n networkError = e;\n }\n\n const shouldRetryNetwork = !!networkError && retryOnNetworkError;\n const retryableResponse = res && isRetryableResponse\n ? await isRetryableResponse(res.clone(), ctx)\n : undefined;\n const shouldRetryStatus = !!res && (retryableResponse ?? retryOnStatuses.includes(res.status));\n\n if (!shouldRetryNetwork && !shouldRetryStatus) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n if (meta.attempt >= retries) {\n if (networkError) throw networkError;\n return res as Response;\n }\n\n meta.attempt += 1;\n\n const now = Date.now();\n let delay = backoffDelayMs(meta.attempt, baseDelayMs, maxDelayMs, jitter);\n\n if (res) {\n // Never retry sooner than the server asks: a Retry-After is a floor, not\n // a hint the backoff ceiling may cut. Too long a wait ends the retries.\n const ra = parseRetryAfterMs(res.headers, now);\n if (typeof ra === \"number\") {\n if (ra > maxRetryAfterMs) return res;\n delay = Math.max(delay, ra);\n }\n }\n\n await wait(delay, ctx);\n\n // clone ctx with incremented attempt to keep anti-loop invariants aligned with core\n ctx = Object.assign({}, ctx, { attempt: ctx.attempt + 1 }) as RequestContext;\n (ctx as any)[META_KEY] = meta;\n }\n };\n\n return {\n name: \"retry\",\n apply: (draft) => {\n draft.guards = [...(draft.guards ?? []), guard];\n },\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cedvict/http-guardian-plugin-retry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Retry plugin (exponential backoff) for http-guardian.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"url": "git+https://gitlab.com/code-idea/code-libs/npm/http-guardian.git"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@cedvict/http-guardian": "^0.
|
|
34
|
+
"@cedvict/http-guardian": "^0.5.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsup",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@types/node": "^24.0.0",
|
|
48
48
|
"eslint": "^10.0.0",
|
|
49
49
|
"typescript": "^6.0.0",
|
|
50
|
-
"tsup": "^
|
|
50
|
+
"tsup": "^9.0.0",
|
|
51
51
|
"vitest": "^5.0.0",
|
|
52
52
|
"@typescript-eslint/parser": "^8.0.0",
|
|
53
53
|
"@typescript-eslint/eslint-plugin": "^8.0.0"
|