@cedvict/http-guardian-plugin-retry 0.0.1-next.15 → 0.0.1-next.16
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 +6 -0
- package/README.md +57 -0
- package/dist/index.cjs +17 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +17 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Add `isRetryableResponse`, whose explicit boolean decision takes precedence
|
|
6
|
+
over `retryOnStatuses` while preserving request-level idempotency checks.
|
|
7
|
+
- Add `retryableEnvelopeClassifier` for MIC-compatible JSON error envelopes.
|
|
8
|
+
|
|
3
9
|
## 0.0.1-next.7
|
|
4
10
|
|
|
5
11
|
- Initial prerelease.
|
package/README.md
CHANGED
|
@@ -2,8 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
Retry plugin (exponential backoff) for http-guardian.
|
|
4
4
|
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @cedvict/http-guardian-plugin-retry
|
|
9
|
+
```
|
|
10
|
+
|
|
5
11
|
## Usage
|
|
6
12
|
|
|
7
13
|
```ts
|
|
8
14
|
import { retryPlugin } from "@cedvict/http-guardian-plugin-retry";
|
|
15
|
+
|
|
16
|
+
const plugin = retryPlugin({
|
|
17
|
+
retries: 3,
|
|
18
|
+
baseDelayMs: 200,
|
|
19
|
+
maxDelayMs: 5_000,
|
|
20
|
+
jitter: "full",
|
|
21
|
+
});
|
|
9
22
|
```
|
|
23
|
+
|
|
24
|
+
By default, only `GET`, `HEAD`, and `OPTIONS` requests are replayed. Network
|
|
25
|
+
errors and statuses `408`, `429`, `500`, `502`, `503`, and `504` are retryable.
|
|
26
|
+
`Retry-After` is honored when present.
|
|
27
|
+
|
|
28
|
+
## Structured retryability (MIC)
|
|
29
|
+
|
|
30
|
+
MIC error envelopes expose an explicit top-level `retryable` boolean. Use the
|
|
31
|
+
provided response classifier so that this backend decision takes precedence
|
|
32
|
+
over the status list:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import {
|
|
36
|
+
createApiParserStructured,
|
|
37
|
+
createHttpClient,
|
|
38
|
+
noAuth,
|
|
39
|
+
} from "@cedvict/http-guardian";
|
|
40
|
+
import {
|
|
41
|
+
retryableEnvelopeClassifier,
|
|
42
|
+
retryPlugin,
|
|
43
|
+
} from "@cedvict/http-guardian-plugin-retry";
|
|
44
|
+
|
|
45
|
+
const api = createHttpClient({
|
|
46
|
+
baseUrl: "https://api.example.com",
|
|
47
|
+
auth: noAuth(),
|
|
48
|
+
parser: createApiParserStructured(),
|
|
49
|
+
plugins: [
|
|
50
|
+
retryPlugin({
|
|
51
|
+
isRetryableResponse: retryableEnvelopeClassifier,
|
|
52
|
+
}),
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `retryable: true` retries even when the status is absent from
|
|
58
|
+
`retryOnStatuses`.
|
|
59
|
+
- `retryable: false` prevents a retry even when the status is in that list.
|
|
60
|
+
- A missing or malformed hint falls back to `retryOnStatuses`.
|
|
61
|
+
- Request eligibility still applies first: non-idempotent methods are not
|
|
62
|
+
replayed unless `isRetryableRequest` explicitly allows them.
|
|
63
|
+
|
|
64
|
+
Custom classifiers receive a cloned `Response`, so they may read the body
|
|
65
|
+
without consuming the response later parsed by http-guardian. Return
|
|
66
|
+
`undefined` to preserve the status-based policy.
|
package/dist/index.cjs
CHANGED
|
@@ -22,6 +22,19 @@ function backoffDelayMs(attempt, base, max, jitter) {
|
|
|
22
22
|
if (jitter === "full") return Math.floor(Math.random() * exp);
|
|
23
23
|
return exp;
|
|
24
24
|
}
|
|
25
|
+
async function retryableEnvelopeClassifier(response) {
|
|
26
|
+
if (response.status < 400) return void 0;
|
|
27
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
28
|
+
if (!contentType.toLowerCase().includes("json")) return void 0;
|
|
29
|
+
try {
|
|
30
|
+
const raw = await response.json();
|
|
31
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
32
|
+
const retryable = raw["retryable"];
|
|
33
|
+
return typeof retryable === "boolean" ? retryable : void 0;
|
|
34
|
+
} catch {
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
25
38
|
function retryPlugin(options = {}) {
|
|
26
39
|
const retries = options.retries ?? 3;
|
|
27
40
|
const baseDelayMs = options.baseDelayMs ?? 200;
|
|
@@ -30,6 +43,7 @@ function retryPlugin(options = {}) {
|
|
|
30
43
|
const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];
|
|
31
44
|
const retryOnNetworkError = options.retryOnNetworkError ?? true;
|
|
32
45
|
const isRetryableRequest = options.isRetryableRequest ?? ((ctx) => isIdempotent(ctx.method));
|
|
46
|
+
const isRetryableResponse = options.isRetryableResponse;
|
|
33
47
|
const wait = options.wait ?? ((ms) => sleep(ms));
|
|
34
48
|
const guard = async (ctx, next) => {
|
|
35
49
|
if (!isRetryableRequest(ctx)) return next(ctx);
|
|
@@ -43,7 +57,8 @@ function retryPlugin(options = {}) {
|
|
|
43
57
|
networkError = e;
|
|
44
58
|
}
|
|
45
59
|
const shouldRetryNetwork = !!networkError && retryOnNetworkError;
|
|
46
|
-
const
|
|
60
|
+
const retryableResponse = res && isRetryableResponse ? await isRetryableResponse(res.clone(), ctx) : void 0;
|
|
61
|
+
const shouldRetryStatus = !!res && (retryableResponse ?? retryOnStatuses.includes(res.status));
|
|
47
62
|
if (!shouldRetryNetwork && !shouldRetryStatus) {
|
|
48
63
|
if (networkError) throw networkError;
|
|
49
64
|
return res;
|
|
@@ -73,5 +88,6 @@ function retryPlugin(options = {}) {
|
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
exports.retryPlugin = retryPlugin;
|
|
91
|
+
exports.retryableEnvelopeClassifier = retryableEnvelopeClassifier;
|
|
76
92
|
//# sourceMappingURL=index.cjs.map
|
|
77
93
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;
|
|
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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RequestContext, HttpPlugin } from '@cedvict/http-guardian';
|
|
2
2
|
|
|
3
3
|
type RetryJitter = "none" | "full";
|
|
4
|
+
type RetryResponseClassifier = (response: Response, ctx: RequestContext) => boolean | undefined | Promise<boolean | undefined>;
|
|
4
5
|
type RetryPluginOptions = {
|
|
5
6
|
retries?: number;
|
|
6
7
|
baseDelayMs?: number;
|
|
@@ -12,11 +13,22 @@ type RetryPluginOptions = {
|
|
|
12
13
|
* By default, retries only idempotent methods.
|
|
13
14
|
*/
|
|
14
15
|
isRetryableRequest?: (ctx: RequestContext) => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Classifies a response before applying `retryOnStatuses`. Return `true` to
|
|
18
|
+
* retry, `false` to stop, or `undefined` to keep the status-based fallback.
|
|
19
|
+
* The callback receives a clone, so reading its body is safe.
|
|
20
|
+
*/
|
|
21
|
+
isRetryableResponse?: RetryResponseClassifier;
|
|
15
22
|
/**
|
|
16
23
|
* Override wait logic (useful for tests).
|
|
17
24
|
*/
|
|
18
25
|
wait?: (ms: number, ctx: RequestContext) => Promise<void>;
|
|
19
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Reads a top-level boolean `retryable` field from a JSON error envelope.
|
|
29
|
+
* Missing, malformed, and non-error bodies defer to the status-based policy.
|
|
30
|
+
*/
|
|
31
|
+
declare function retryableEnvelopeClassifier(response: Response): Promise<boolean | undefined>;
|
|
20
32
|
/**
|
|
21
33
|
* Production-grade retry plugin:
|
|
22
34
|
* - exponential backoff (+ jitter)
|
|
@@ -26,4 +38,4 @@ type RetryPluginOptions = {
|
|
|
26
38
|
*/
|
|
27
39
|
declare function retryPlugin(options?: RetryPluginOptions): HttpPlugin;
|
|
28
40
|
|
|
29
|
-
export { type RetryJitter, type RetryPluginOptions, retryPlugin };
|
|
41
|
+
export { type RetryJitter, type RetryPluginOptions, type RetryResponseClassifier, retryPlugin, retryableEnvelopeClassifier };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RequestContext, HttpPlugin } from '@cedvict/http-guardian';
|
|
2
2
|
|
|
3
3
|
type RetryJitter = "none" | "full";
|
|
4
|
+
type RetryResponseClassifier = (response: Response, ctx: RequestContext) => boolean | undefined | Promise<boolean | undefined>;
|
|
4
5
|
type RetryPluginOptions = {
|
|
5
6
|
retries?: number;
|
|
6
7
|
baseDelayMs?: number;
|
|
@@ -12,11 +13,22 @@ type RetryPluginOptions = {
|
|
|
12
13
|
* By default, retries only idempotent methods.
|
|
13
14
|
*/
|
|
14
15
|
isRetryableRequest?: (ctx: RequestContext) => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Classifies a response before applying `retryOnStatuses`. Return `true` to
|
|
18
|
+
* retry, `false` to stop, or `undefined` to keep the status-based fallback.
|
|
19
|
+
* The callback receives a clone, so reading its body is safe.
|
|
20
|
+
*/
|
|
21
|
+
isRetryableResponse?: RetryResponseClassifier;
|
|
15
22
|
/**
|
|
16
23
|
* Override wait logic (useful for tests).
|
|
17
24
|
*/
|
|
18
25
|
wait?: (ms: number, ctx: RequestContext) => Promise<void>;
|
|
19
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Reads a top-level boolean `retryable` field from a JSON error envelope.
|
|
29
|
+
* Missing, malformed, and non-error bodies defer to the status-based policy.
|
|
30
|
+
*/
|
|
31
|
+
declare function retryableEnvelopeClassifier(response: Response): Promise<boolean | undefined>;
|
|
20
32
|
/**
|
|
21
33
|
* Production-grade retry plugin:
|
|
22
34
|
* - exponential backoff (+ jitter)
|
|
@@ -26,4 +38,4 @@ type RetryPluginOptions = {
|
|
|
26
38
|
*/
|
|
27
39
|
declare function retryPlugin(options?: RetryPluginOptions): HttpPlugin;
|
|
28
40
|
|
|
29
|
-
export { type RetryJitter, type RetryPluginOptions, retryPlugin };
|
|
41
|
+
export { type RetryJitter, type RetryPluginOptions, type RetryResponseClassifier, retryPlugin, retryableEnvelopeClassifier };
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,19 @@ function backoffDelayMs(attempt, base, max, jitter) {
|
|
|
20
20
|
if (jitter === "full") return Math.floor(Math.random() * exp);
|
|
21
21
|
return exp;
|
|
22
22
|
}
|
|
23
|
+
async function retryableEnvelopeClassifier(response) {
|
|
24
|
+
if (response.status < 400) return void 0;
|
|
25
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
26
|
+
if (!contentType.toLowerCase().includes("json")) return void 0;
|
|
27
|
+
try {
|
|
28
|
+
const raw = await response.json();
|
|
29
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
30
|
+
const retryable = raw["retryable"];
|
|
31
|
+
return typeof retryable === "boolean" ? retryable : void 0;
|
|
32
|
+
} catch {
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
23
36
|
function retryPlugin(options = {}) {
|
|
24
37
|
const retries = options.retries ?? 3;
|
|
25
38
|
const baseDelayMs = options.baseDelayMs ?? 200;
|
|
@@ -28,6 +41,7 @@ function retryPlugin(options = {}) {
|
|
|
28
41
|
const retryOnStatuses = options.retryOnStatuses ?? [408, 429, 500, 502, 503, 504];
|
|
29
42
|
const retryOnNetworkError = options.retryOnNetworkError ?? true;
|
|
30
43
|
const isRetryableRequest = options.isRetryableRequest ?? ((ctx) => isIdempotent(ctx.method));
|
|
44
|
+
const isRetryableResponse = options.isRetryableResponse;
|
|
31
45
|
const wait = options.wait ?? ((ms) => sleep(ms));
|
|
32
46
|
const guard = async (ctx, next) => {
|
|
33
47
|
if (!isRetryableRequest(ctx)) return next(ctx);
|
|
@@ -41,7 +55,8 @@ function retryPlugin(options = {}) {
|
|
|
41
55
|
networkError = e;
|
|
42
56
|
}
|
|
43
57
|
const shouldRetryNetwork = !!networkError && retryOnNetworkError;
|
|
44
|
-
const
|
|
58
|
+
const retryableResponse = res && isRetryableResponse ? await isRetryableResponse(res.clone(), ctx) : void 0;
|
|
59
|
+
const shouldRetryStatus = !!res && (retryableResponse ?? retryOnStatuses.includes(res.status));
|
|
45
60
|
if (!shouldRetryNetwork && !shouldRetryStatus) {
|
|
46
61
|
if (networkError) throw networkError;
|
|
47
62
|
return res;
|
|
@@ -70,6 +85,6 @@ function retryPlugin(options = {}) {
|
|
|
70
85
|
};
|
|
71
86
|
}
|
|
72
87
|
|
|
73
|
-
export { retryPlugin };
|
|
88
|
+
export { retryPlugin, retryableEnvelopeClassifier };
|
|
74
89
|
//# sourceMappingURL=index.js.map
|
|
75
90
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";
|
|
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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cedvict/http-guardian-plugin-retry",
|
|
3
|
-
"version": "0.0.1-next.
|
|
3
|
+
"version": "0.0.1-next.16",
|
|
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-libs/npm/http-guardian.git"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@cedvict/http-guardian": "^0.0.1-next.
|
|
34
|
+
"@cedvict/http-guardian": "^0.0.1-next.16"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsup",
|