From 980c662cec268c17b64a06bafff0f618bcd9d4df Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:33:11 +0900 Subject: [PATCH 1/2] fix(retries): refuse transient 5xx after an operator-authorized reset replacement --- src/lib/upstream-retry.ts | 7 ++++++- tests/lib/upstream-retry.test.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 23d1f0c4ffd..e72c0b010cd 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -605,7 +605,12 @@ export async function fetchWithResetRetry( // rethrow, abort), so a per-send report is the only shape that is correct on all of them. opts.onSendsConsumed?.(1); try { - return await doFetch(attempt === 0 ? firstRecovery : "connection-reset"); + const response = await doFetch(attempt === 0 ? firstRecovery : "connection-reset"); + if (spentOperatorReplacement && isTransientUpstreamStatus(response.status)) { + cancelResponseBodyBestEffort(response); + return replayRefusalResponse(); + } + return response; } catch (err) { if (opts.abortSignal?.aborted) throw err; if (!isConnectionResetError(err)) { diff --git a/tests/lib/upstream-retry.test.ts b/tests/lib/upstream-retry.test.ts index f55d8ecf300..0b30d71627b 100644 --- a/tests/lib/upstream-retry.test.ts +++ b/tests/lib/upstream-retry.test.ts @@ -586,6 +586,20 @@ describe("operator-granted replacement of an ambiguous reset", () => { expect(mock.calls).toHaveLength(2); }); + test("a transient response after a replacement settles as the refusal", async () => { + silenceWarn(); + const mock = mockDoFetch([ + bunResetError(), new Response("busy", { status: 502 }), new Response("duplicate"), + ]); + const response = await fetchWithTransientRetry(mock.doFetch, { + attempts: 3, claimAmbiguousResend: () => true, + }); + expect(response.status).toBe(429); + expect(isNonReplayableResponse(response)).toBe(true); + expect((await response.json()).error.code).toBe(UPSTREAM_RESET_REPLAY_REFUSED_CODE); + expect(mock.calls).toHaveLength(2); + }); + test("the transient layer carries the grant into its inner reset layer", async () => { silenceWarn(); const reports: number[] = []; From c1fd0ccf0891fb0c130f9d351ade482dc4cec386 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:09:42 +0900 Subject: [PATCH 2/2] fix(retries): word the replay refusal for the post-response path too --- src/lib/errors.ts | 8 ++++---- src/lib/upstream-retry.ts | 8 ++++---- tests/usage/request-log.test.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 18b27d34827..348b0a75f5d 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -258,12 +258,12 @@ export function isClientClosedMessage(text: string): boolean { /** * Ambiguous-reset refusal wording owned by this proxy (src/lib/upstream-retry.ts): - * the upstream connection closed before any response arrived, so the request may - * already have been processed and automatic replay was stopped. Matched narrowly - * so a provider-sent message is never relabeled by it. + * the upstream exchange did not complete reliably, so the request may already have + * been processed and automatic replay was stopped. Matched narrowly so a + * provider-sent message is never relabeled by it. */ export function isUpstreamResetReplayRefusedMessage(text: string): boolean { - return text.toLowerCase().includes("connection closed before a response was received"); + return text.toLowerCase().includes("did not complete reliably"); } export function classifyError(status: number, type: string, message: string): OcxErrorPayload { diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index e72c0b010cd..f1b76c2e326 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -569,7 +569,7 @@ export function replayRefusalResponse(): Response { const response = new Response(JSON.stringify({ error: { type: "upstream_error", code: UPSTREAM_RESET_REPLAY_REFUSED_CODE, - message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.", + message: "The upstream exchange did not complete reliably. The request may already have been processed; automatic replay was stopped.", } }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json", ...REPLAY_REFUSAL_CLIENT_HEADERS }, @@ -594,9 +594,9 @@ export async function fetchWithResetRetry( if (attempts === 0) throw new SendBudgetExhaustedError(opts.label); let lastError: unknown; let sawReset = false; - // True once this leg has spent the request's operator allowance. From that point the leg can - // only settle as the refusal: a second send of a possibly-executed turn is already out, and - // handing the client anything it would retry compounds it. + // True once this leg has spent the request's operator allowance. From that point the leg + // settles as the refusal or an unambiguous answer: a second send of a possibly-executed turn + // is already out, and handing the client anything it would retry compounds it. let spentOperatorReplacement = false; for (let attempt = 0; attempt < attempts; attempt++) { if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal); diff --git a/tests/usage/request-log.test.ts b/tests/usage/request-log.test.ts index ae4c9b9493d..36c59c30b7c 100644 --- a/tests/usage/request-log.test.ts +++ b/tests/usage/request-log.test.ts @@ -832,7 +832,7 @@ describe("request log metadata", () => { expect(requestLogErrorCode(429)).toBe("rate_limit_exceeded"); expect(requestLogErrorCode( 429, - "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.", + "The upstream exchange did not complete reliably. The request may already have been processed; automatic replay was stopped.", )).toBe("upstream_reset_replay_refused"); expect(requestLogErrorCode(499)).toBe("client_closed_request"); expect(requestLogErrorCode(502, "client closed request during web-search")).toBe("client_closed_request");