@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
package/src/request/metadata.ts
CHANGED
|
@@ -5,90 +5,90 @@
|
|
|
5
5
|
import type { RequestBodyMetadata, RequestMetadata } from "../types.js";
|
|
6
6
|
|
|
7
7
|
export function extractFileIds(parsed: unknown): string[] {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
9
|
+
const obj = parsed as Record<string, unknown>;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const ids: string[] = [];
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
const collectFromContent = (content: unknown): void => {
|
|
14
|
+
if (!Array.isArray(content)) return;
|
|
15
|
+
for (const block of content) {
|
|
16
|
+
if (!block || typeof block !== "object") continue;
|
|
17
|
+
const b = block as Record<string, unknown>;
|
|
18
|
+
if ((b.type === "document" || b.type === "file") && b.source && typeof b.source === "object") {
|
|
19
|
+
const src = b.source as Record<string, unknown>;
|
|
20
|
+
if (typeof src.file_id === "string") {
|
|
21
|
+
ids.push(src.file_id);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
22
24
|
}
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
};
|
|
25
|
+
};
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
if (Array.isArray(obj.messages)) {
|
|
28
|
+
for (const msg of obj.messages) {
|
|
29
|
+
if (!msg || typeof msg !== "object") continue;
|
|
30
|
+
collectFromContent((msg as Record<string, unknown>).content);
|
|
31
|
+
}
|
|
31
32
|
}
|
|
32
|
-
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
if (Array.isArray(obj.system)) {
|
|
35
|
+
collectFromContent(obj.system);
|
|
36
|
+
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
return ids;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function parseRequestBodyMetadata(
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
body: string | undefined,
|
|
43
|
+
debugLog?: (...args: unknown[]) => void,
|
|
44
44
|
): RequestBodyMetadata {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
if (!body || typeof body !== "string") {
|
|
46
|
+
return { model: "", tools: [], messages: [], hasFileReferences: false };
|
|
47
|
+
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(body);
|
|
51
|
+
const model = typeof parsed?.model === "string" ? parsed.model : "";
|
|
52
|
+
const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
|
|
53
|
+
const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
|
|
54
|
+
const hasFileReferences = extractFileIds(parsed).length > 0;
|
|
55
|
+
return { model, tools, messages, hasFileReferences };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
debugLog?.("extractFileIds failed:", (err as Error).message);
|
|
58
|
+
return { model: "", tools: [], messages: [], hasFileReferences: false };
|
|
59
|
+
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export function getAccountIdentifier(account: { id?: string; accountUuid?: string } | null | undefined): string {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
// Prefer env-provided account UUID (v2.1.51+), then account record fields
|
|
64
|
+
const envUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID?.trim();
|
|
65
|
+
if (envUuid) return envUuid;
|
|
66
|
+
if (account?.accountUuid && typeof account.accountUuid === "string") {
|
|
67
|
+
return account.accountUuid;
|
|
68
|
+
}
|
|
69
|
+
if (account?.id && typeof account.id === "string") {
|
|
70
|
+
return account.id;
|
|
71
|
+
}
|
|
72
|
+
return "";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export function buildRequestMetadata(input: {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
persistentUserId: string;
|
|
77
|
+
accountId: string;
|
|
78
|
+
sessionId: string;
|
|
79
79
|
}): RequestMetadata {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
const metadata: RequestMetadata = {
|
|
81
|
+
user_id: JSON.stringify({
|
|
82
|
+
device_id: input.persistentUserId,
|
|
83
|
+
account_uuid: input.accountId,
|
|
84
|
+
session_id: input.sessionId,
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
const orgUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID?.trim();
|
|
89
|
+
if (orgUuid) metadata.organization_uuid = orgUuid;
|
|
90
|
+
const userEmail = process.env.CLAUDE_CODE_USER_EMAIL?.trim();
|
|
91
|
+
if (userEmail) metadata.user_email = userEmail;
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
return metadata;
|
|
94
94
|
}
|
|
@@ -2,178 +2,178 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { calculateRetryDelay, fetchWithRetry, shouldRetryStatus } from "./retry.js";
|
|
3
3
|
|
|
4
4
|
function makeResponse(status: number, headers?: HeadersInit): Response {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
return new Response(null, {
|
|
6
|
+
status,
|
|
7
|
+
headers,
|
|
8
|
+
});
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const FAST_RETRY_CONFIG = {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
initialDelayMs: 1,
|
|
13
|
+
maxDelayMs: 8,
|
|
14
|
+
jitterFraction: 0,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
describe("shouldRetryStatus", () => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
it("honors x-should-retry overrides before status logic", () => {
|
|
19
|
+
expect(shouldRetryStatus(400, true)).toBe(true);
|
|
20
|
+
expect(shouldRetryStatus(503, false)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("retries only the supported fallback statuses", () => {
|
|
24
|
+
expect(shouldRetryStatus(408, null)).toBe(true);
|
|
25
|
+
expect(shouldRetryStatus(409, null)).toBe(true);
|
|
26
|
+
expect(shouldRetryStatus(429, null)).toBe(true);
|
|
27
|
+
expect(shouldRetryStatus(529, null)).toBe(true);
|
|
28
|
+
expect(shouldRetryStatus(400, null)).toBe(false);
|
|
29
|
+
});
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
describe("calculateRetryDelay", () => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
33
|
+
it("matches the Stainless exponential backoff formula with jitter and max cap", () => {
|
|
34
|
+
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
calculateRetryDelay(0, {
|
|
38
|
+
maxRetries: 2,
|
|
39
|
+
initialDelayMs: 500,
|
|
40
|
+
maxDelayMs: 8000,
|
|
41
|
+
jitterFraction: 0.25,
|
|
42
|
+
}),
|
|
43
|
+
).toBe(438);
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
calculateRetryDelay(3, {
|
|
47
|
+
maxRetries: 2,
|
|
48
|
+
initialDelayMs: 500,
|
|
49
|
+
maxDelayMs: 8000,
|
|
50
|
+
jitterFraction: 0.25,
|
|
51
|
+
}),
|
|
52
|
+
).toBe(3500);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
calculateRetryDelay(5, {
|
|
56
|
+
maxRetries: 2,
|
|
57
|
+
initialDelayMs: 500,
|
|
58
|
+
maxDelayMs: 8000,
|
|
59
|
+
jitterFraction: 0.25,
|
|
60
|
+
}),
|
|
61
|
+
).toBe(7000);
|
|
62
|
+
});
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe("fetchWithRetry", () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("does not retry when x-should-retry is false", async () => {
|
|
113
|
-
const doFetch = vi
|
|
114
|
-
.fn<() => Promise<Response>>()
|
|
115
|
-
.mockResolvedValue(makeResponse(503, { "x-should-retry": "false" }));
|
|
116
|
-
|
|
117
|
-
const response = await fetchWithRetry(doFetch);
|
|
118
|
-
|
|
119
|
-
expect(response.status).toBe(503);
|
|
120
|
-
expect(doFetch).toHaveBeenCalledTimes(1);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("prefers retry-after-ms over calculated backoff", async () => {
|
|
124
|
-
const doFetch = vi
|
|
125
|
-
.fn<() => Promise<Response>>()
|
|
126
|
-
.mockResolvedValueOnce(makeResponse(503, { "retry-after-ms": "2000" }))
|
|
127
|
-
.mockResolvedValueOnce(makeResponse(200));
|
|
128
|
-
|
|
129
|
-
const startedAt = Date.now();
|
|
130
|
-
const response = await fetchWithRetry(doFetch);
|
|
131
|
-
const elapsedMs = Date.now() - startedAt;
|
|
132
|
-
|
|
133
|
-
expect(response.status).toBe(200);
|
|
134
|
-
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
135
|
-
expect(elapsedMs).toBeGreaterThanOrEqual(1900);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("falls back to retry-after when retry-after-ms is absent", async () => {
|
|
139
|
-
const doFetch = vi
|
|
140
|
-
.fn<() => Promise<Response>>()
|
|
141
|
-
.mockResolvedValueOnce(makeResponse(503, { "retry-after": "3" }))
|
|
142
|
-
.mockResolvedValueOnce(makeResponse(200));
|
|
143
|
-
|
|
144
|
-
const startedAt = Date.now();
|
|
145
|
-
const response = await fetchWithRetry(doFetch);
|
|
146
|
-
const elapsedMs = Date.now() - startedAt;
|
|
147
|
-
|
|
148
|
-
expect(response.status).toBe(200);
|
|
149
|
-
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
150
|
-
expect(elapsedMs).toBeGreaterThanOrEqual(2900);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("retries thrown retryable network errors and marks the next attempt as fresh-connection", async () => {
|
|
154
|
-
const forceFreshConnectionByAttempt: boolean[] = [];
|
|
155
|
-
const doFetch = vi.fn(async ({ forceFreshConnection = false }: { forceFreshConnection?: boolean } = {}) => {
|
|
156
|
-
forceFreshConnectionByAttempt.push(forceFreshConnection);
|
|
157
|
-
if (forceFreshConnectionByAttempt.length === 1) {
|
|
158
|
-
throw Object.assign(new Error("Connection reset by server"), { code: "ECONNRESET" });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return makeResponse(200);
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("retries a 529 once and returns the next success", async () => {
|
|
75
|
+
const doFetch = vi
|
|
76
|
+
.fn<() => Promise<Response>>()
|
|
77
|
+
.mockResolvedValueOnce(makeResponse(529))
|
|
78
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
79
|
+
|
|
80
|
+
const response = await fetchWithRetry(doFetch, FAST_RETRY_CONFIG);
|
|
81
|
+
|
|
82
|
+
expect(response.status).toBe(200);
|
|
83
|
+
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("retries twice before succeeding on the third attempt", async () => {
|
|
87
|
+
const doFetch = vi
|
|
88
|
+
.fn<() => Promise<Response>>()
|
|
89
|
+
.mockResolvedValueOnce(makeResponse(529))
|
|
90
|
+
.mockResolvedValueOnce(makeResponse(529))
|
|
91
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
92
|
+
|
|
93
|
+
const response = await fetchWithRetry(doFetch, FAST_RETRY_CONFIG);
|
|
94
|
+
|
|
95
|
+
expect(response.status).toBe(200);
|
|
96
|
+
expect(doFetch).toHaveBeenCalledTimes(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns the last failure after exhausting the retry budget", async () => {
|
|
100
|
+
const doFetch = vi
|
|
101
|
+
.fn<() => Promise<Response>>()
|
|
102
|
+
.mockResolvedValueOnce(makeResponse(529))
|
|
103
|
+
.mockResolvedValueOnce(makeResponse(529))
|
|
104
|
+
.mockResolvedValueOnce(makeResponse(529));
|
|
105
|
+
|
|
106
|
+
const response = await fetchWithRetry(doFetch, FAST_RETRY_CONFIG);
|
|
107
|
+
|
|
108
|
+
expect(response.status).toBe(529);
|
|
109
|
+
expect(doFetch).toHaveBeenCalledTimes(3);
|
|
162
110
|
});
|
|
163
111
|
|
|
164
|
-
|
|
112
|
+
it("does not retry when x-should-retry is false", async () => {
|
|
113
|
+
const doFetch = vi
|
|
114
|
+
.fn<() => Promise<Response>>()
|
|
115
|
+
.mockResolvedValue(makeResponse(503, { "x-should-retry": "false" }));
|
|
165
116
|
|
|
166
|
-
|
|
167
|
-
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
168
|
-
expect(forceFreshConnectionByAttempt).toEqual([false, true]);
|
|
169
|
-
});
|
|
117
|
+
const response = await fetchWithRetry(doFetch);
|
|
170
118
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
throw new DOMException("The operation was aborted", "AbortError");
|
|
119
|
+
expect(response.status).toBe(503);
|
|
120
|
+
expect(doFetch).toHaveBeenCalledTimes(1);
|
|
174
121
|
});
|
|
175
122
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
123
|
+
it("prefers retry-after-ms over calculated backoff", async () => {
|
|
124
|
+
const doFetch = vi
|
|
125
|
+
.fn<() => Promise<Response>>()
|
|
126
|
+
.mockResolvedValueOnce(makeResponse(503, { "retry-after-ms": "2000" }))
|
|
127
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
128
|
+
|
|
129
|
+
const startedAt = Date.now();
|
|
130
|
+
const response = await fetchWithRetry(doFetch);
|
|
131
|
+
const elapsedMs = Date.now() - startedAt;
|
|
132
|
+
|
|
133
|
+
expect(response.status).toBe(200);
|
|
134
|
+
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
135
|
+
expect(elapsedMs).toBeGreaterThanOrEqual(1900);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("falls back to retry-after when retry-after-ms is absent", async () => {
|
|
139
|
+
const doFetch = vi
|
|
140
|
+
.fn<() => Promise<Response>>()
|
|
141
|
+
.mockResolvedValueOnce(makeResponse(503, { "retry-after": "3" }))
|
|
142
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
143
|
+
|
|
144
|
+
const startedAt = Date.now();
|
|
145
|
+
const response = await fetchWithRetry(doFetch);
|
|
146
|
+
const elapsedMs = Date.now() - startedAt;
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(200);
|
|
149
|
+
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
150
|
+
expect(elapsedMs).toBeGreaterThanOrEqual(2900);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("retries thrown retryable network errors and marks the next attempt as fresh-connection", async () => {
|
|
154
|
+
const forceFreshConnectionByAttempt: boolean[] = [];
|
|
155
|
+
const doFetch = vi.fn(async ({ forceFreshConnection = false }: { forceFreshConnection?: boolean } = {}) => {
|
|
156
|
+
forceFreshConnectionByAttempt.push(forceFreshConnection);
|
|
157
|
+
if (forceFreshConnectionByAttempt.length === 1) {
|
|
158
|
+
throw Object.assign(new Error("Connection reset by server"), { code: "ECONNRESET" });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return makeResponse(200);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const response = await fetchWithRetry(doFetch, FAST_RETRY_CONFIG);
|
|
165
|
+
|
|
166
|
+
expect(response.status).toBe(200);
|
|
167
|
+
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
168
|
+
expect(forceFreshConnectionByAttempt).toEqual([false, true]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not retry user abort errors", async () => {
|
|
172
|
+
const doFetch = vi.fn(async () => {
|
|
173
|
+
throw new DOMException("The operation was aborted", "AbortError");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await expect(fetchWithRetry(doFetch, FAST_RETRY_CONFIG)).rejects.toThrow(/aborted/i);
|
|
177
|
+
expect(doFetch).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
179
|
});
|
package/src/request/retry.ts
CHANGED
|
@@ -1,95 +1,95 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
isRetriableNetworkError,
|
|
3
|
+
parseRetryAfterHeader,
|
|
4
|
+
parseRetryAfterMsHeader,
|
|
5
|
+
parseShouldRetryHeader,
|
|
6
6
|
} from "../backoff.js";
|
|
7
7
|
|
|
8
8
|
export interface RetryConfig {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
maxRetries: number;
|
|
10
|
+
initialDelayMs: number;
|
|
11
|
+
maxDelayMs: number;
|
|
12
|
+
jitterFraction: number;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface RetryAttemptContext {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
attempt: number;
|
|
17
|
+
forceFreshConnection: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface RetryOptions extends Partial<RetryConfig> {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
shouldRetryError?: (error: unknown) => boolean;
|
|
22
|
+
shouldRetryResponse?: (response: Response) => boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
maxRetries: 2,
|
|
27
|
+
initialDelayMs: 500,
|
|
28
|
+
maxDelayMs: 8000,
|
|
29
|
+
jitterFraction: 0.25,
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
function waitFor(ms: number): Promise<void> {
|
|
33
|
-
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function calculateRetryDelay(attempt: number, config: RetryConfig): number {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
const delay = Math.min(config.initialDelayMs * Math.pow(2, attempt), config.maxDelayMs);
|
|
38
|
+
const jitter = 1 - Math.random() * config.jitterFraction;
|
|
39
|
+
return Math.round(delay * jitter);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function shouldRetryStatus(status: number, shouldRetryHeader: boolean | null): boolean {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
if (shouldRetryHeader === true) return true;
|
|
44
|
+
if (shouldRetryHeader === false) return false;
|
|
45
|
+
return status === 408 || status === 409 || status === 429 || status >= 500;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export async function fetchWithRetry(
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
doFetch: (context: RetryAttemptContext) => Promise<Response>,
|
|
50
|
+
options: RetryOptions = {},
|
|
51
51
|
): Promise<Response> {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
52
|
+
const resolvedConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...options };
|
|
53
|
+
const shouldRetryError = options.shouldRetryError ?? isRetriableNetworkError;
|
|
54
|
+
const shouldRetryResponse =
|
|
55
|
+
options.shouldRetryResponse ??
|
|
56
|
+
((response: Response) => {
|
|
57
|
+
const shouldRetryHeader = parseShouldRetryHeader(response);
|
|
58
|
+
return shouldRetryStatus(response.status, shouldRetryHeader);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let forceFreshConnection = false;
|
|
62
|
+
|
|
63
|
+
for (let attempt = 0; ; attempt++) {
|
|
64
|
+
let response: Response;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
response = await doFetch({ attempt, forceFreshConnection });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!shouldRetryError(error) || attempt >= resolvedConfig.maxRetries) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const delayMs = calculateRetryDelay(attempt, resolvedConfig);
|
|
74
|
+
await waitFor(delayMs);
|
|
75
|
+
forceFreshConnection = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (response.ok) {
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!shouldRetryResponse(response) || attempt >= resolvedConfig.maxRetries) {
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const delayMs =
|
|
88
|
+
parseRetryAfterMsHeader(response) ??
|
|
89
|
+
parseRetryAfterHeader(response) ??
|
|
90
|
+
calculateRetryDelay(attempt, resolvedConfig);
|
|
91
|
+
|
|
92
|
+
await waitFor(delayMs);
|
|
93
|
+
forceFreshConnection = false;
|
|
77
94
|
}
|
|
78
|
-
|
|
79
|
-
if (response.ok) {
|
|
80
|
-
return response;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!shouldRetryResponse(response) || attempt >= resolvedConfig.maxRetries) {
|
|
84
|
-
return response;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const delayMs =
|
|
88
|
-
parseRetryAfterMsHeader(response) ??
|
|
89
|
-
parseRetryAfterHeader(response) ??
|
|
90
|
-
calculateRetryDelay(attempt, resolvedConfig);
|
|
91
|
-
|
|
92
|
-
await waitFor(delayMs);
|
|
93
|
-
forceFreshConnection = false;
|
|
94
|
-
}
|
|
95
95
|
}
|