@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.
- package/dist/opencode-anthropic-auth-plugin.js +116 -17
- package/package.json +1 -1
- package/src/backoff.test.ts +25 -0
- package/src/backoff.ts +83 -0
- package/src/bun-proxy.test.ts +37 -0
- package/src/bun-proxy.ts +11 -3
- package/src/index.ts +38 -11
- package/src/request/retry.test.ts +27 -0
- package/src/request/retry.ts +44 -9
|
@@ -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,
|
|
6386
|
-
const resolvedConfig = { ...DEFAULT_RETRY_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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8254
|
-
|
|
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:
|
|
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(
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
});
|
|
8421
|
+
return fetchWithTransport(
|
|
8422
|
+
retryUrl,
|
|
8423
|
+
buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection)
|
|
8424
|
+
);
|
|
8326
8425
|
},
|
|
8327
8426
|
{ maxRetries: 2 }
|
|
8328
8427
|
);
|
package/package.json
CHANGED
package/src/backoff.test.ts
CHANGED
|
@@ -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
|
-
|
|
522
|
-
|
|
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:
|
|
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(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
});
|
package/src/request/retry.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
49
|
+
doFetch: (context: RetryAttemptContext) => Promise<Response>,
|
|
50
|
+
options: RetryOptions = {},
|
|
36
51
|
): Promise<Response> {
|
|
37
|
-
const resolvedConfig: RetryConfig = { ...DEFAULT_RETRY_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
|
-
|
|
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
|
-
|
|
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
|
}
|