@vacbo/opencode-anthropic-fix 0.1.4 → 0.1.5

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.
@@ -3377,6 +3377,21 @@ var QUOTA_EXHAUSTED_BACKOFFS = [6e4, 3e5, 18e5, 72e5];
3377
3377
  var AUTH_FAILED_BACKOFF = 5e3;
3378
3378
  var RATE_LIMIT_EXCEEDED_BACKOFF = 3e4;
3379
3379
  var MIN_BACKOFF_MS = 2e3;
3380
+ var RETRIABLE_NETWORK_ERROR_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "UND_ERR_SOCKET"]);
3381
+ var NON_RETRIABLE_ERROR_NAMES = /* @__PURE__ */ new Set(["AbortError", "TimeoutError", "APIUserAbortError"]);
3382
+ var RETRIABLE_NETWORK_ERROR_MESSAGES = [
3383
+ "bun proxy upstream error",
3384
+ "connection reset by peer",
3385
+ "connection reset by server",
3386
+ "econnreset",
3387
+ "econnrefused",
3388
+ "epipe",
3389
+ "etimedout",
3390
+ "fetch failed",
3391
+ "network connection lost",
3392
+ "socket hang up",
3393
+ "und_err_socket"
3394
+ ];
3380
3395
  function parseRetryAfterHeader(response) {
3381
3396
  const header = response.headers.get("retry-after");
3382
3397
  if (!header) return null;
@@ -3463,6 +3478,54 @@ function bodyHasAccountError(body) {
3463
3478
  ];
3464
3479
  return typeSignals.some((signal) => errorType.includes(signal)) || messageSignals.some((signal) => message.includes(signal)) || messageSignals.some((signal) => text.includes(signal));
3465
3480
  }
3481
+ function collectErrorChain(error) {
3482
+ const queue = [error];
3483
+ const visited = /* @__PURE__ */ new Set();
3484
+ const chain = [];
3485
+ while (queue.length > 0) {
3486
+ const candidate = queue.shift();
3487
+ if (candidate == null || visited.has(candidate)) {
3488
+ continue;
3489
+ }
3490
+ visited.add(candidate);
3491
+ if (candidate instanceof Error) {
3492
+ const typedCandidate = candidate;
3493
+ chain.push(typedCandidate);
3494
+ if (typedCandidate.cause !== void 0) {
3495
+ queue.push(typedCandidate.cause);
3496
+ }
3497
+ continue;
3498
+ }
3499
+ if (typeof candidate === "object" && "cause" in candidate) {
3500
+ queue.push(candidate.cause);
3501
+ }
3502
+ }
3503
+ return chain;
3504
+ }
3505
+ function isRetriableNetworkError(error) {
3506
+ if (typeof error === "string") {
3507
+ const text = error.toLowerCase();
3508
+ return RETRIABLE_NETWORK_ERROR_MESSAGES.some((signal) => text.includes(signal));
3509
+ }
3510
+ const chain = collectErrorChain(error);
3511
+ if (chain.length === 0) {
3512
+ return false;
3513
+ }
3514
+ for (const candidate of chain) {
3515
+ if (NON_RETRIABLE_ERROR_NAMES.has(candidate.name)) {
3516
+ return false;
3517
+ }
3518
+ const code = candidate.code?.toUpperCase();
3519
+ if (code && RETRIABLE_NETWORK_ERROR_CODES.has(code)) {
3520
+ return true;
3521
+ }
3522
+ const message = candidate.message.toLowerCase();
3523
+ if (RETRIABLE_NETWORK_ERROR_MESSAGES.some((signal) => message.includes(signal))) {
3524
+ return true;
3525
+ }
3526
+ }
3527
+ return false;
3528
+ }
3466
3529
  function isAccountSpecificError(status, body) {
3467
3530
  if (status === 429) return true;
3468
3531
  if (status === 401) return true;
@@ -6382,20 +6445,36 @@ function shouldRetryStatus(status, shouldRetryHeader) {
6382
6445
  if (shouldRetryHeader === false) return false;
6383
6446
  return status === 408 || status === 409 || status === 429 || status >= 500;
6384
6447
  }
6385
- async function fetchWithRetry(doFetch, config = {}) {
6386
- const resolvedConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
6448
+ async function fetchWithRetry(doFetch, options = {}) {
6449
+ const resolvedConfig = { ...DEFAULT_RETRY_CONFIG, ...options };
6450
+ const shouldRetryError = options.shouldRetryError ?? isRetriableNetworkError;
6451
+ const shouldRetryResponse = options.shouldRetryResponse ?? ((response) => {
6452
+ const shouldRetryHeader = parseShouldRetryHeader(response);
6453
+ return shouldRetryStatus(response.status, shouldRetryHeader);
6454
+ });
6455
+ let forceFreshConnection = false;
6387
6456
  for (let attempt = 0; ; attempt++) {
6388
- const response = await doFetch();
6457
+ let response;
6458
+ try {
6459
+ response = await doFetch({ attempt, forceFreshConnection });
6460
+ } catch (error) {
6461
+ if (!shouldRetryError(error) || attempt >= resolvedConfig.maxRetries) {
6462
+ throw error;
6463
+ }
6464
+ const delayMs2 = calculateRetryDelay(attempt, resolvedConfig);
6465
+ await waitFor(delayMs2);
6466
+ forceFreshConnection = true;
6467
+ continue;
6468
+ }
6389
6469
  if (response.ok) {
6390
6470
  return response;
6391
6471
  }
6392
- const shouldRetryHeader = parseShouldRetryHeader(response);
6393
- const shouldRetry = shouldRetryStatus(response.status, shouldRetryHeader);
6394
- if (!shouldRetry || attempt >= resolvedConfig.maxRetries) {
6472
+ if (!shouldRetryResponse(response) || attempt >= resolvedConfig.maxRetries) {
6395
6473
  return response;
6396
6474
  }
6397
6475
  const delayMs = parseRetryAfterMsHeader(response) ?? parseRetryAfterHeader(response) ?? calculateRetryDelay(attempt, resolvedConfig);
6398
6476
  await waitFor(delayMs);
6477
+ forceFreshConnection = false;
6399
6478
  }
6400
6479
  }
6401
6480
 
@@ -8250,12 +8329,33 @@ ${message}`);
8250
8329
  }
8251
8330
  let response;
8252
8331
  const fetchInput = requestInput;
8253
- try {
8254
- response = await fetchWithTransport(fetchInput, {
8332
+ const buildTransportRequestInit = (headers, requestBody, forceFreshConnection) => {
8333
+ const requestHeadersForTransport = new Headers(headers);
8334
+ if (forceFreshConnection) {
8335
+ requestHeadersForTransport.set("connection", "close");
8336
+ requestHeadersForTransport.set("x-proxy-disable-keepalive", "true");
8337
+ } else {
8338
+ requestHeadersForTransport.delete("connection");
8339
+ requestHeadersForTransport.delete("x-proxy-disable-keepalive");
8340
+ }
8341
+ return {
8255
8342
  ...requestInit,
8256
- body,
8257
- headers: requestHeaders
8258
- });
8343
+ body: requestBody,
8344
+ headers: requestHeadersForTransport,
8345
+ ...forceFreshConnection ? { keepalive: false } : {}
8346
+ };
8347
+ };
8348
+ try {
8349
+ response = await fetchWithRetry(
8350
+ async ({ forceFreshConnection }) => fetchWithTransport(
8351
+ fetchInput,
8352
+ buildTransportRequestInit(requestHeaders, body, forceFreshConnection)
8353
+ ),
8354
+ {
8355
+ maxRetries: 2,
8356
+ shouldRetryResponse: () => false
8357
+ }
8358
+ );
8259
8359
  } catch (err) {
8260
8360
  const fetchError = err instanceof Error ? err : new Error(String(err));
8261
8361
  if (accountManager && account) {
@@ -8308,7 +8408,7 @@ ${message}`);
8308
8408
  });
8309
8409
  let retryCount = 0;
8310
8410
  const retried = await fetchWithRetry(
8311
- async () => {
8411
+ async ({ forceFreshConnection }) => {
8312
8412
  if (retryCount === 0) {
8313
8413
  retryCount += 1;
8314
8414
  return response;
@@ -8318,11 +8418,10 @@ ${message}`);
8318
8418
  retryCount += 1;
8319
8419
  const retryUrl = fetchInput instanceof Request ? fetchInput.url : fetchInput.toString();
8320
8420
  const retryBody = requestContext.preparedBody === void 0 ? void 0 : cloneBodyForRetry(requestContext.preparedBody);
8321
- return fetchWithTransport(retryUrl, {
8322
- ...requestInit,
8323
- body: retryBody,
8324
- headers: headersForRetry
8325
- });
8421
+ return fetchWithTransport(
8422
+ retryUrl,
8423
+ buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection)
8424
+ );
8326
8425
  },
8327
8426
  { maxRetries: 2 }
8328
8427
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vacbo/opencode-anthropic-fix",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "main": "dist/opencode-anthropic-auth-plugin.js",
5
5
  "bin": {
6
6
  "opencode-anthropic-auth": "dist/opencode-anthropic-auth-cli.mjs",
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  calculateBackoffMs,
4
4
  isAccountSpecificError,
5
+ isRetriableNetworkError,
5
6
  parseRateLimitReason,
6
7
  parseRetryAfterHeader,
7
8
  parseRetryAfterMsHeader,
@@ -235,6 +236,30 @@ describe("parseRateLimitReason", () => {
235
236
  });
236
237
  });
237
238
 
239
+ // ---------------------------------------------------------------------------
240
+ // isRetriableNetworkError
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe("isRetriableNetworkError", () => {
244
+ it("returns true for retryable connection reset codes", () => {
245
+ const error = Object.assign(new Error("socket died"), {
246
+ code: "ECONNRESET",
247
+ });
248
+
249
+ expect(isRetriableNetworkError(error)).toBe(true);
250
+ });
251
+
252
+ it("returns true for Bun proxy upstream reset messages", () => {
253
+ expect(isRetriableNetworkError(new Error("Bun proxy upstream error: Connection reset by server"))).toBe(true);
254
+ });
255
+
256
+ it("returns false for user abort errors", () => {
257
+ const error = new DOMException("The operation was aborted", "AbortError");
258
+
259
+ expect(isRetriableNetworkError(error)).toBe(false);
260
+ });
261
+ });
262
+
238
263
  // ---------------------------------------------------------------------------
239
264
  // calculateBackoffMs
240
265
  // ---------------------------------------------------------------------------
package/src/backoff.ts CHANGED
@@ -4,6 +4,26 @@ const QUOTA_EXHAUSTED_BACKOFFS = [60_000, 300_000, 1_800_000, 7_200_000];
4
4
  const AUTH_FAILED_BACKOFF = 5_000;
5
5
  const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000;
6
6
  const MIN_BACKOFF_MS = 2_000;
7
+ const RETRIABLE_NETWORK_ERROR_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "UND_ERR_SOCKET"]);
8
+ const NON_RETRIABLE_ERROR_NAMES = new Set(["AbortError", "TimeoutError", "APIUserAbortError"]);
9
+ const RETRIABLE_NETWORK_ERROR_MESSAGES = [
10
+ "bun proxy upstream error",
11
+ "connection reset by peer",
12
+ "connection reset by server",
13
+ "econnreset",
14
+ "econnrefused",
15
+ "epipe",
16
+ "etimedout",
17
+ "fetch failed",
18
+ "network connection lost",
19
+ "socket hang up",
20
+ "und_err_socket",
21
+ ];
22
+
23
+ interface ErrorWithCode extends Error {
24
+ code?: string;
25
+ cause?: unknown;
26
+ }
7
27
 
8
28
  /**
9
29
  * Parse the Retry-After header from a response.
@@ -132,6 +152,69 @@ function bodyHasAccountError(body: string | object | null | undefined): boolean
132
152
  );
133
153
  }
134
154
 
155
+ function collectErrorChain(error: unknown): ErrorWithCode[] {
156
+ const queue: unknown[] = [error];
157
+ const visited = new Set<unknown>();
158
+ const chain: ErrorWithCode[] = [];
159
+
160
+ while (queue.length > 0) {
161
+ const candidate = queue.shift();
162
+ if (candidate == null || visited.has(candidate)) {
163
+ continue;
164
+ }
165
+
166
+ visited.add(candidate);
167
+
168
+ if (candidate instanceof Error) {
169
+ const typedCandidate = candidate as ErrorWithCode;
170
+ chain.push(typedCandidate);
171
+ if (typedCandidate.cause !== undefined) {
172
+ queue.push(typedCandidate.cause);
173
+ }
174
+ continue;
175
+ }
176
+
177
+ if (typeof candidate === "object" && "cause" in candidate) {
178
+ queue.push((candidate as { cause?: unknown }).cause);
179
+ }
180
+ }
181
+
182
+ return chain;
183
+ }
184
+
185
+ /**
186
+ * Check whether an error represents a transient transport/network failure.
187
+ */
188
+ export function isRetriableNetworkError(error: unknown): boolean {
189
+ if (typeof error === "string") {
190
+ const text = error.toLowerCase();
191
+ return RETRIABLE_NETWORK_ERROR_MESSAGES.some((signal) => text.includes(signal));
192
+ }
193
+
194
+ const chain = collectErrorChain(error);
195
+ if (chain.length === 0) {
196
+ return false;
197
+ }
198
+
199
+ for (const candidate of chain) {
200
+ if (NON_RETRIABLE_ERROR_NAMES.has(candidate.name)) {
201
+ return false;
202
+ }
203
+
204
+ const code = candidate.code?.toUpperCase();
205
+ if (code && RETRIABLE_NETWORK_ERROR_CODES.has(code)) {
206
+ return true;
207
+ }
208
+
209
+ const message = candidate.message.toLowerCase();
210
+ if (RETRIABLE_NETWORK_ERROR_MESSAGES.some((signal) => message.includes(signal))) {
211
+ return true;
212
+ }
213
+ }
214
+
215
+ return false;
216
+ }
217
+
135
218
  /**
136
219
  * Check whether an HTTP response represents an account-specific error
137
220
  * that would benefit from switching to a different account.
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createProxyRequestHandler } from "./bun-proxy.js";
4
+
5
+ function makeProxyRequest(headers?: HeadersInit): Request {
6
+ const requestHeaders = new Headers(headers);
7
+ requestHeaders.set("x-proxy-url", "https://api.anthropic.com/v1/messages");
8
+ requestHeaders.set("content-type", "application/json");
9
+
10
+ return new Request("http://127.0.0.1/proxy", {
11
+ method: "POST",
12
+ headers: requestHeaders,
13
+ body: JSON.stringify({ ok: true }),
14
+ });
15
+ }
16
+
17
+ describe("createProxyRequestHandler", () => {
18
+ it("forwards retry requests with keepalive disabled to the upstream fetch", async () => {
19
+ const upstreamFetch = vi.fn(async (_input, init?: RequestInit) => {
20
+ expect(init?.keepalive).toBe(false);
21
+ const forwardedHeaders = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers);
22
+ expect(forwardedHeaders.get("connection")).toBe("close");
23
+ expect(forwardedHeaders.get("x-proxy-disable-keepalive")).toBeNull();
24
+ return new Response("ok", { status: 200 });
25
+ });
26
+ const handler = createProxyRequestHandler({
27
+ fetchImpl: upstreamFetch as typeof fetch,
28
+ allowHosts: ["api.anthropic.com"],
29
+ requestTimeoutMs: 50,
30
+ });
31
+
32
+ const response = await handler(makeProxyRequest({ "x-proxy-disable-keepalive": "true" }));
33
+
34
+ await expect(response.text()).resolves.toBe("ok");
35
+ expect(upstreamFetch).toHaveBeenCalledTimes(1);
36
+ });
37
+ });
package/src/bun-proxy.ts CHANGED
@@ -8,6 +8,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;
8
8
  const DEFAULT_PARENT_EXIT_CODE = 1;
9
9
  const DEFAULT_PARENT_POLL_INTERVAL_MS = 5_000;
10
10
  const HEALTH_PATH = "/__health";
11
+ const PROXY_DISABLE_KEEPALIVE_HEADER = "x-proxy-disable-keepalive";
11
12
  const DEBUG_ENABLED = process.env.OPENCODE_ANTHROPIC_DEBUG === "1";
12
13
 
13
14
  interface ProxyRequestHandlerOptions {
@@ -85,11 +86,16 @@ function createDefaultParentWatcherFactory(): ParentWatcherFactory {
85
86
  });
86
87
  }
87
88
 
88
- function sanitizeForwardHeaders(source: Headers): Headers {
89
+ function sanitizeForwardHeaders(source: Headers, forceFreshConnection = false): Headers {
89
90
  const headers = new Headers(source);
90
- ["x-proxy-url", "host", "connection", "content-length"].forEach((headerName) => {
91
+ [PROXY_DISABLE_KEEPALIVE_HEADER, "x-proxy-url", "host", "connection", "content-length"].forEach((headerName) => {
91
92
  headers.delete(headerName);
92
93
  });
94
+
95
+ if (forceFreshConnection) {
96
+ headers.set("connection", "close");
97
+ }
98
+
93
99
  return headers;
94
100
  }
95
101
 
@@ -161,11 +167,13 @@ async function createUpstreamInit(req: Request, signal: AbortSignal): Promise<Re
161
167
  const method = req.method || "GET";
162
168
  const hasBody = method !== "GET" && method !== "HEAD";
163
169
  const bodyText = hasBody ? await req.text() : "";
170
+ const forceFreshConnection = req.headers.get(PROXY_DISABLE_KEEPALIVE_HEADER) === "true";
164
171
 
165
172
  return {
166
173
  method,
167
- headers: sanitizeForwardHeaders(req.headers),
174
+ headers: sanitizeForwardHeaders(req.headers, forceFreshConnection),
168
175
  signal,
176
+ ...(forceFreshConnection ? { keepalive: false } : {}),
169
177
  ...(hasBody && bodyText.length > 0 ? { body: bodyText } : {}),
170
178
  };
171
179
  }
package/src/index.ts CHANGED
@@ -518,12 +518,40 @@ export async function AnthropicAuthPlugin({
518
518
 
519
519
  let response: Response;
520
520
  const fetchInput = requestInput as string | URL | Request;
521
- try {
522
- response = await fetchWithTransport(fetchInput, {
521
+ const buildTransportRequestInit = (
522
+ headers: Headers,
523
+ requestBody: RequestInit["body"],
524
+ forceFreshConnection: boolean,
525
+ ): RequestInit => {
526
+ const requestHeadersForTransport = new Headers(headers);
527
+ if (forceFreshConnection) {
528
+ requestHeadersForTransport.set("connection", "close");
529
+ requestHeadersForTransport.set("x-proxy-disable-keepalive", "true");
530
+ } else {
531
+ requestHeadersForTransport.delete("connection");
532
+ requestHeadersForTransport.delete("x-proxy-disable-keepalive");
533
+ }
534
+
535
+ return {
523
536
  ...requestInit,
524
- body,
525
- headers: requestHeaders,
526
- });
537
+ body: requestBody,
538
+ headers: requestHeadersForTransport,
539
+ ...(forceFreshConnection ? { keepalive: false } : {}),
540
+ };
541
+ };
542
+
543
+ try {
544
+ response = await fetchWithRetry(
545
+ async ({ forceFreshConnection }) =>
546
+ fetchWithTransport(
547
+ fetchInput,
548
+ buildTransportRequestInit(requestHeaders, body, forceFreshConnection),
549
+ ),
550
+ {
551
+ maxRetries: 2,
552
+ shouldRetryResponse: () => false,
553
+ },
554
+ );
527
555
  } catch (err) {
528
556
  const fetchError = err instanceof Error ? err : new Error(String(err));
529
557
  if (accountManager && account) {
@@ -582,7 +610,7 @@ export async function AnthropicAuthPlugin({
582
610
 
583
611
  let retryCount = 0;
584
612
  const retried = await fetchWithRetry(
585
- async () => {
613
+ async ({ forceFreshConnection }) => {
586
614
  if (retryCount === 0) {
587
615
  retryCount += 1;
588
616
  return response;
@@ -596,11 +624,10 @@ export async function AnthropicAuthPlugin({
596
624
  requestContext.preparedBody === undefined
597
625
  ? undefined
598
626
  : cloneBodyForRetry(requestContext.preparedBody);
599
- return fetchWithTransport(retryUrl, {
600
- ...requestInit,
601
- body: retryBody,
602
- headers: headersForRetry,
603
- });
627
+ return fetchWithTransport(
628
+ retryUrl,
629
+ buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection),
630
+ );
604
631
  },
605
632
  { maxRetries: 2 },
606
633
  );
@@ -149,4 +149,31 @@ describe("fetchWithRetry", () => {
149
149
  expect(doFetch).toHaveBeenCalledTimes(2);
150
150
  expect(elapsedMs).toBeGreaterThanOrEqual(2900);
151
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
+ });
152
179
  });
@@ -1,4 +1,9 @@
1
- import { parseRetryAfterHeader, parseRetryAfterMsHeader, parseShouldRetryHeader } from "../backoff.js";
1
+ import {
2
+ isRetriableNetworkError,
3
+ parseRetryAfterHeader,
4
+ parseRetryAfterMsHeader,
5
+ parseShouldRetryHeader,
6
+ } from "../backoff.js";
2
7
 
3
8
  export interface RetryConfig {
4
9
  maxRetries: number;
@@ -7,6 +12,16 @@ export interface RetryConfig {
7
12
  jitterFraction: number;
8
13
  }
9
14
 
15
+ export interface RetryAttemptContext {
16
+ attempt: number;
17
+ forceFreshConnection: boolean;
18
+ }
19
+
20
+ export interface RetryOptions extends Partial<RetryConfig> {
21
+ shouldRetryError?: (error: unknown) => boolean;
22
+ shouldRetryResponse?: (response: Response) => boolean;
23
+ }
24
+
10
25
  const DEFAULT_RETRY_CONFIG: RetryConfig = {
11
26
  maxRetries: 2,
12
27
  initialDelayMs: 500,
@@ -31,22 +46,41 @@ export function shouldRetryStatus(status: number, shouldRetryHeader: boolean | n
31
46
  }
32
47
 
33
48
  export async function fetchWithRetry(
34
- doFetch: () => Promise<Response>,
35
- config: Partial<RetryConfig> = {},
49
+ doFetch: (context: RetryAttemptContext) => Promise<Response>,
50
+ options: RetryOptions = {},
36
51
  ): Promise<Response> {
37
- const resolvedConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
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;
38
62
 
39
63
  for (let attempt = 0; ; attempt++) {
40
- const response = await doFetch();
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
+ }
41
78
 
42
79
  if (response.ok) {
43
80
  return response;
44
81
  }
45
82
 
46
- const shouldRetryHeader = parseShouldRetryHeader(response);
47
- const shouldRetry = shouldRetryStatus(response.status, shouldRetryHeader);
48
-
49
- if (!shouldRetry || attempt >= resolvedConfig.maxRetries) {
83
+ if (!shouldRetryResponse(response) || attempt >= resolvedConfig.maxRetries) {
50
84
  return response;
51
85
  }
52
86
 
@@ -56,5 +90,6 @@ export async function fetchWithRetry(
56
90
  calculateRetryDelay(attempt, resolvedConfig);
57
91
 
58
92
  await waitFor(delayMs);
93
+ forceFreshConnection = false;
59
94
  }
60
95
  }