@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.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -5,90 +5,90 @@
5
5
  import type { RequestBodyMetadata, RequestMetadata } from "../types.js";
6
6
 
7
7
  export function extractFileIds(parsed: unknown): string[] {
8
- if (!parsed || typeof parsed !== "object") return [];
9
- const obj = parsed as Record<string, unknown>;
8
+ if (!parsed || typeof parsed !== "object") return [];
9
+ const obj = parsed as Record<string, unknown>;
10
10
 
11
- const ids: string[] = [];
11
+ const ids: string[] = [];
12
12
 
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);
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
- 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);
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
- if (Array.isArray(obj.system)) {
35
- collectFromContent(obj.system);
36
- }
34
+ if (Array.isArray(obj.system)) {
35
+ collectFromContent(obj.system);
36
+ }
37
37
 
38
- return ids;
38
+ return ids;
39
39
  }
40
40
 
41
41
  export function parseRequestBodyMetadata(
42
- body: string | undefined,
43
- debugLog?: (...args: unknown[]) => void,
42
+ body: string | undefined,
43
+ debugLog?: (...args: unknown[]) => void,
44
44
  ): RequestBodyMetadata {
45
- if (!body || typeof body !== "string") {
46
- return { model: "", tools: [], messages: [], hasFileReferences: false };
47
- }
45
+ if (!body || typeof body !== "string") {
46
+ return { model: "", tools: [], messages: [], hasFileReferences: false };
47
+ }
48
48
 
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
- }
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
- // 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 "";
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
- persistentUserId: string;
77
- accountId: string;
78
- sessionId: string;
76
+ persistentUserId: string;
77
+ accountId: string;
78
+ sessionId: string;
79
79
  }): RequestMetadata {
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
- };
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
- 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;
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
- return metadata;
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
- return new Response(null, {
6
- status,
7
- headers,
8
- });
5
+ return new Response(null, {
6
+ status,
7
+ headers,
8
+ });
9
9
  }
10
10
 
11
11
  const FAST_RETRY_CONFIG = {
12
- initialDelayMs: 1,
13
- maxDelayMs: 8,
14
- jitterFraction: 0,
12
+ initialDelayMs: 1,
13
+ maxDelayMs: 8,
14
+ jitterFraction: 0,
15
15
  };
16
16
 
17
17
  describe("shouldRetryStatus", () => {
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
- });
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
- 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
- });
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
- 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);
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
- const response = await fetchWithRetry(doFetch, FAST_RETRY_CONFIG);
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
- expect(response.status).toBe(200);
167
- expect(doFetch).toHaveBeenCalledTimes(2);
168
- expect(forceFreshConnectionByAttempt).toEqual([false, true]);
169
- });
117
+ const response = await fetchWithRetry(doFetch);
170
118
 
171
- it("does not retry user abort errors", async () => {
172
- const doFetch = vi.fn(async () => {
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
- await expect(fetchWithRetry(doFetch, FAST_RETRY_CONFIG)).rejects.toThrow(/aborted/i);
177
- expect(doFetch).toHaveBeenCalledTimes(1);
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
  });
@@ -1,95 +1,95 @@
1
1
  import {
2
- isRetriableNetworkError,
3
- parseRetryAfterHeader,
4
- parseRetryAfterMsHeader,
5
- parseShouldRetryHeader,
2
+ isRetriableNetworkError,
3
+ parseRetryAfterHeader,
4
+ parseRetryAfterMsHeader,
5
+ parseShouldRetryHeader,
6
6
  } from "../backoff.js";
7
7
 
8
8
  export interface RetryConfig {
9
- maxRetries: number;
10
- initialDelayMs: number;
11
- maxDelayMs: number;
12
- jitterFraction: number;
9
+ maxRetries: number;
10
+ initialDelayMs: number;
11
+ maxDelayMs: number;
12
+ jitterFraction: number;
13
13
  }
14
14
 
15
15
  export interface RetryAttemptContext {
16
- attempt: number;
17
- forceFreshConnection: boolean;
16
+ attempt: number;
17
+ forceFreshConnection: boolean;
18
18
  }
19
19
 
20
20
  export interface RetryOptions extends Partial<RetryConfig> {
21
- shouldRetryError?: (error: unknown) => boolean;
22
- shouldRetryResponse?: (response: Response) => boolean;
21
+ shouldRetryError?: (error: unknown) => boolean;
22
+ shouldRetryResponse?: (response: Response) => boolean;
23
23
  }
24
24
 
25
25
  const DEFAULT_RETRY_CONFIG: RetryConfig = {
26
- maxRetries: 2,
27
- initialDelayMs: 500,
28
- maxDelayMs: 8000,
29
- jitterFraction: 0.25,
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
- return new Promise((resolve) => setTimeout(resolve, ms));
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
34
  }
35
35
 
36
36
  export function calculateRetryDelay(attempt: number, config: RetryConfig): number {
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);
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
- if (shouldRetryHeader === true) return true;
44
- if (shouldRetryHeader === false) return false;
45
- return status === 408 || status === 409 || status === 429 || status >= 500;
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
- doFetch: (context: RetryAttemptContext) => Promise<Response>,
50
- options: RetryOptions = {},
49
+ doFetch: (context: RetryAttemptContext) => Promise<Response>,
50
+ options: RetryOptions = {},
51
51
  ): Promise<Response> {
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;
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
  }