Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ arrives, the proxy cannot tell whether the model already processed the request,
to send it again and answers HTTP 429 with `upstream_reset_replay_refused`. The status is
deliberate: a 5xx here is an instruction to most clients, including Codex, to send the whole
turn again, which is the duplicate the refusal exists to prevent. No `Retry-After` is
attached, and the proxy performs no key rotation, account failover or same-target replay on
it, nor does it record the refusal as rate-limit or quota evidence against the credential it
was holding. Tool-call side requests such as vision and web search are replayed normally, because
repeating them cannot duplicate a turn.
attached, and the response carries `x-should-retry: false`, which the official OpenAI and
Anthropic SDKs read before their own status rules — without it those clients retry a 429 on
their own schedule and resend the turn anyway. The answer is identical on `/v1/responses` and
on `/v1/chat/completions`, whether the request is forwarded natively or translated. The proxy
performs no key rotation, account failover or same-target replay on it, nor does it record the
refusal as rate-limit or quota evidence against the credential it was holding. Tool-call side
requests such as vision and web search are replayed normally, because repeating them cannot
duplicate a turn.

A native Responses provider can opt into replacing that send with
[`retryOnReset`](providers.md#provider-entries-ocxproviderconfig). The same grant covers the
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@
"remote-workspace-protocol.test.ts": "clients",
"remote-workspace-rpc-framing.test.ts": "clients",
"remove-tree-helper.test.ts": "lib",
"replay-refusal-parity.test.ts": "server",
"repo-hygiene.test.ts": "ci-workflows",
"request-decompress.test.ts": "usage",
"request-evidence.test.ts": "usage",
Expand Down
10 changes: 8 additions & 2 deletions src/bridge/errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {
applyReplayRefusalClientHeaders,
isNonReplayableUpstreamCode,
isReplayRefusalCode,
markReplayRefusalResponse,
markResponseNonReplayable,
REPLAY_REFUSED_STATUS,
retainReplayRefusal,
} from "../lib/upstream-retry";
import {
adapterFailureFromMessage,
Expand Down Expand Up @@ -45,6 +46,11 @@ export function formatErrorResponse(
&& retryAfter.length <= 128) {
headers.set("Retry-After", retryAfter);
}
// The refusal's client policy, restated here for the same reason its status is: this
// formatter is the last thing several adapter and combo paths touch before the client,
// and no wait of its own does not stop a client that retries every 429 by default.
const refusal = isReplayRefusalCode(error.code) && replayBlocked;
if (refusal) applyReplayRefusalClientHeaders(headers);
const response = new Response(JSON.stringify({ error }), {
status: finalStatus,
headers,
Expand All @@ -53,6 +59,6 @@ export function formatErrorResponse(
// Re-wrapping is where the refusal loses its provenance: combo failure consumption parses
// the JSON and builds a new Response, and the code alone does not tell a later quota
// recorder that no upstream produced this status. Carry the narrower marker across too.
if (replayBlocked && isReplayRefusalCode(error.code)) markReplayRefusalResponse(response);
if (refusal) retainReplayRefusal(response);
return response;
}
58 changes: 54 additions & 4 deletions src/lib/upstream-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,55 @@ export function isReplayRefusalCode(code: unknown): boolean {
/** Client-facing status for {@link UPSTREAM_RESET_REPLAY_REFUSED_CODE}. */
export const REPLAY_REFUSED_STATUS = 429;

/**
* The header every surface attaches to a replay refusal, and its only accepted value.
*
* Dropping `Retry-After` is necessary and not sufficient. The Stainless-generated clients --
* `openai` and `anthropic` for both Python and Node, which is what most callers of this proxy
* actually are -- decide from a status table (408, 409, 429 and every 5xx) and compute their own
* backoff when no wait is named, so a 429 with no header is still resent. `x-should-retry` is
* the one signal each of them reads BEFORE that table, and `"false"` is the exact string they
* compare against.
*/
export const REPLAY_REFUSAL_NO_RETRY_HEADER = "x-should-retry";
export const REPLAY_REFUSAL_NO_RETRY_VALUE = "false";

/** Spreadable form for the surfaces that build their headers as an object literal. */
export const REPLAY_REFUSAL_CLIENT_HEADERS: Readonly<Record<string, string>> = Object.freeze({
[REPLAY_REFUSAL_NO_RETRY_HEADER]: REPLAY_REFUSAL_NO_RETRY_VALUE,
});

/**
* Apply the one client-facing retry policy a refusal carries: no wait, and no automatic resend.
*
* Kept as a single function rather than two rules each surface repeats, because the two halves
* are only correct together -- a surface that removed the wait but not the suppression still
* hands a retrying client a turn it may already have run.
*/
export function applyReplayRefusalClientHeaders(headers: Headers): void {
headers.delete("retry-after");
headers.set(REPLAY_REFUSAL_NO_RETRY_HEADER, REPLAY_REFUSAL_NO_RETRY_VALUE);
}

/**
* Mark a response that re-wraps a refusal as the same refusal.
*
* The verdict has to be a property of the result the surfaces pass around, because the thing it
* would otherwise be read from is the status, and 429 is exactly what a refusal and a real rate
* limit have in common. Every formatter between the helper that made the refusal and the client
* builds a new Response, so each of them restates the verdict rather than dropping it.
*/
export function retainReplayRefusal<T extends Response>(response: T): T {
markResponseNonReplayable(response);
markReplayRefusalResponse(response);
return response;
}

/** Carry the verdict from a response onto the one that replaces it. */
export function carryReplayRefusal<T extends Response>(source: Response, rewrapped: T): T {
return isReplayRefusalResponse(source) ? retainReplayRefusal(rewrapped) : rewrapped;
}

// 1 initial + 2 retries: the pool may hold more than one stale socket.
const RESET_RETRY_MAX_ATTEMPTS = 3;
const RESET_RETRY_BASE_DELAY_MS = 150;
Expand Down Expand Up @@ -521,10 +570,11 @@ export function replayRefusalResponse(): Response {
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.",
} }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json" } });
markResponseNonReplayable(response);
markReplayRefusalResponse(response);
return response;
} }), {
status: REPLAY_REFUSED_STATUS,
headers: { "content-type": "application/json", ...REPLAY_REFUSAL_CLIENT_HEADERS },
});
return retainReplayRefusal(response);
}

/**
Expand Down
42 changes: 40 additions & 2 deletions src/server/chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ import {
import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
import { redactSecretString } from "../lib/redact";
import { resolveClientRetryAfter } from "../lib/retry-after";
import {
applyReplayRefusalClientHeaders,
isReplayRefusalCode,
isReplayRefusalResponse,
REPLAY_REFUSAL_CLIENT_HEADERS,
REPLAY_REFUSED_STATUS,
retainReplayRefusal,
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
} from "../lib/upstream-retry";
import { estimateTokens } from "../lib/token-estimate";
import { captureRouteStaticPolicy, NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
import { evidenceFromBody } from "../routing/request-evidence";
Expand Down Expand Up @@ -419,18 +428,30 @@ async function handleChatCompletionsWithBudget(
: "invalid_request_error"),
message,
);
// The same verdict the native Chat surface reads, from the same two places: the response
// this wrapper still holds, and the code a body kept through an intermediate formatter.
// Not the status -- a refusal and a real rate limit are both 429, which is the whole
// reason this surface used to report one as the other.
const replayRefusal = isReplayRefusalResponse(upstream) || isReplayRefusalCode(upstreamCode);
if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
classified.code = CYBER_POLICY_ERROR_CODE;
classified.type = cyberPolicyErrorType(upstreamType);
} else if (replayRefusal) {
// 429 classifies as a rate limit, which already carries a code, so the empty-code branch
// below could never restore this one -- the translated client was told the provider
// throttled the turn, and handed a two-second wait to send it again.
classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE;
} else if (upstreamCode === "model_not_found") {
// Structured model_not_found must win over classifyError's generic remaps.
classified.code = "model_not_found";
classified.type = "invalid_request_error";
} else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
classified.code = upstreamCode;
}
const status = isCyberPolicyCode(classified.code) ? 400 : upstream.status;
const retryAfter = isCyberPolicyCode(classified.code)
const status = isCyberPolicyCode(classified.code) ? 400
: replayRefusal ? REPLAY_REFUSED_STATUS
: upstream.status;
const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal
? undefined
: resolveClientRetryAfter({
status: upstream.status,
Expand All @@ -449,9 +470,12 @@ async function handleChatCompletionsWithBudget(
headers: {
"Content-Type": "application/json",
...(retryAfter ? { "Retry-After": retryAfter } : {}),
...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}),
},
});
if (replayRefusal) retainReplayRefusal(rewritten);
return logIds
// Deferred logging re-wraps this response and carries the verdict with it.
? responseWithDeferredRequestLog(rewritten, logIds.requestId, logIds.start, logCtx)
: rewritten;
}
Expand Down Expand Up @@ -518,11 +542,25 @@ async function handleChatCompletionsWithBudget(
} else if (isCyberPolicyCode(error?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
classified.code = CYBER_POLICY_ERROR_CODE;
classified.type = cyberPolicyErrorType(error?.type);
} else if (isReplayRefusalCode(error?.code)) {
// The refusal can also arrive as a failed Responses envelope rather than a non-2xx.
// Reporting that as the 502 below would invite the four resends the refusal prevents.
classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE;
} else if (error?.code === "model_not_found") {
// Same deliberate preserve as the non-OK path: structured code beats generic classify.
classified.code = "model_not_found";
classified.type = "invalid_request_error";
}
if (isReplayRefusalCode(classified.code)) {
const refusal = chatCompletionsErrorResponse(
REPLAY_REFUSED_STATUS, message, classified.type, classified.code,
);
const headers = new Headers(refusal.headers);
applyReplayRefusalClientHeaders(headers);
return finishJson(retainReplayRefusal(
new Response(refusal.body, { status: refusal.status, headers }),
));
}
return finishJson(chatCompletionsErrorResponse(
classified.code === "translation_buffer_limit"
? 502
Expand Down
18 changes: 14 additions & 4 deletions src/server/chat-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
isReplayRefusalCode,
isReplayRefusalResponse,
prepareSameTarget429Wait,
REPLAY_REFUSAL_CLIENT_HEADERS,
REPLAY_REFUSED_STATUS,
retainReplayRefusal,
type UpstreamSendRecovery,
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
} from "../lib/upstream-retry";
Expand Down Expand Up @@ -497,10 +500,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
: response.status >= 500 ? "server_error" : "invalid_request_error"),
clientMessage,
);
// The verdict is read once, from the response that carries it and from the code a
// re-wrapped body kept -- never from the status, which a real rate limit shares.
const replayRefusal = isReplayRefusalResponse(response) || isReplayRefusalCode(upstreamCode);
if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
classified.code = CYBER_POLICY_ERROR_CODE;
classified.type = cyberPolicyErrorType(upstreamType);
} else if (isReplayRefusalResponse(response) || isReplayRefusalCode(upstreamCode)) {
} else if (replayRefusal) {
// 429 classifies as a rate limit and a rate limit already carries a code, so the branch
// below -- which only fills an EMPTY code -- could never restore this one. Without it the
// client is told the provider throttled the turn, when what happened is that this proxy
Expand All @@ -512,25 +518,29 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
} else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
classified.code = upstreamCode;
}
const status = isCyberPolicyCode(classified.code) ? 400 : response.status;
const status = isCyberPolicyCode(classified.code) ? 400
: replayRefusal ? REPLAY_REFUSED_STATUS
: response.status;
// A refusal this proxy made has no wait to report. Synthesizing one here would hand the
// client the default two-second retry for a rate limit that never happened, which is the
// duplicate send the refusal exists to prevent.
const retryAfter = isCyberPolicyCode(classified.code) || isReplayRefusalCode(classified.code)
const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal
? undefined
: resolveClientRetryAfter({
status: response.status,
message: classified.message,
upstreamRetryAfter: response.headers.get("retry-after"),
});
finishLog(status, classified.message);
return new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), {
const rewritten = new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), {
status,
headers: {
"Content-Type": "application/json",
...(retryAfter ? { "Retry-After": retryAfter } : {}),
...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}),
},
});
return replayRefusal ? retainReplayRefusal(rewritten) : rewritten;
}

const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
Expand Down
8 changes: 6 additions & 2 deletions src/server/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../lib/errors";
import { redactSecretString } from "../lib/redact";
import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
import { carryReplayRefusal } from "../lib/upstream-retry";
import { isUsageDebugEnabled } from "../usage/debug";
import {
addRequestLog,
Expand Down Expand Up @@ -833,11 +834,14 @@ export function responseWithDeferredRequestLog(
}, addLog);
},
});
return new Response(body, {
// Logging re-wraps the response, and an in-process verdict does not survive a re-wrap on
// its own. A replay refusal that lost it here would read to a later quota recorder or
// Retry-After synthesizer as a 429 some upstream produced.
return carryReplayRefusal(response, new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}));
}
if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
logCtx.usageDebugBodyKind = response.body ? "other" : "none";
Expand Down
35 changes: 27 additions & 8 deletions src/server/responses/passthrough-error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { formatErrorResponse } from "../../bridge";
import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
import { isReplayRefusalCode, UPSTREAM_RESET_REPLAY_REFUSED_CODE } from "../../lib/upstream-retry";
import {
applyReplayRefusalClientHeaders,
isReplayRefusalCode,
retainReplayRefusal,
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
} from "../../lib/upstream-retry";
import {
resolveClientRetryAfter,
validateClientRetryAfterHeader,
Expand Down Expand Up @@ -62,6 +67,10 @@ function isReplayRefusalBody(body: string): boolean {
* - a replay refusal this proxy wrote gets none and keeps none: the whole point of the
* refusal is that the turn may already be running, and the synthetic default for a
* retryable 429 is a direct instruction to the client to send it a second time
*
* A refusal also leaves with the shared no-retry header and the in-process marker, so the
* verdict survives this re-wrap as a property of the response rather than as a status a later
* reader would have to guess from.
*/
export function formatPassthroughUpstreamError(
status: number,
Expand All @@ -84,11 +93,10 @@ export function formatPassthroughUpstreamError(
const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
const cyberPolicyFailure = isCyberPolicyBody(trimmed);
const replayRefusal = options?.replayRefusal === true || isReplayRefusalBody(trimmed);
// Two different reasons to answer with no wait at all, handled the same way: a hard policy
// block will not become servable, and a refusal we made was never a rate limit.
const suppressRetryAfter = cyberPolicyFailure
|| options?.replayRefusal === true
|| isReplayRefusalBody(trimmed);
const suppressRetryAfter = cyberPolicyFailure || replayRefusal;
const resolved = suppressRetryAfter
? undefined
: resolveClientRetryAfter({
Expand All @@ -105,7 +113,9 @@ export function formatPassthroughUpstreamError(
&& upstreamRetryAfter !== undefined
&& originalValid === undefined);

if (!needsSet && !needsDelete) {
// A refusal always takes the rewriting path: it has a header to add even when the
// upstream named no wait for it to remove.
if (!needsSet && !needsDelete && !replayRefusal) {
return new Response(bodyText, {
status,
...(options?.statusText ? { statusText: options.statusText } : {}),
Expand All @@ -118,21 +128,30 @@ export function formatPassthroughUpstreamError(
: new Headers({ "Content-Type": "application/json" });
if (needsSet) headers.set("Retry-After", resolved!);
else headers.delete("Retry-After");
return new Response(bodyText, {
if (replayRefusal) applyReplayRefusalClientHeaders(headers);
const rewritten = new Response(bodyText, {
status,
...(options?.statusText ? { statusText: options.statusText } : {}),
headers,
});
return replayRefusal ? retainReplayRefusal(rewritten) : rewritten;
}

const response = formatErrorResponse(
status,
"upstream_error",
`Provider error ${status}: (empty body)`,
resolved !== undefined ? { retryAfter: resolved } : undefined,
// Provenance is all that is left when the bounded read returned nothing display-safe, and
// it is enough: the formatter allowlists this code, restates the refusal status and marks
// the response, so an unreadable refusal reaches the client as the same refusal.
replayRefusal
? { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE }
: resolved !== undefined ? { retryAfter: resolved } : undefined,
);
const headers = new Headers(response.headers);
headers.set("Content-Type", "application/json");
if (resolved !== undefined) headers.set("Retry-After", resolved);
return new Response(response.body, { status: response.status, headers });
if (replayRefusal) applyReplayRefusalClientHeaders(headers);
const wrapped = new Response(response.body, { status: response.status, headers });
return replayRefusal ? retainReplayRefusal(wrapped) : wrapped;
}
Loading
Loading