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
9 changes: 6 additions & 3 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ export function isThroughputLimitText(message: string): boolean {
}

/** Codes that say nothing about the cause, so the message decides (#8127). */
const GENERIC_FAILURE_CODES: ReadonlySet<string> = new Set(["server_error", "upstream_error", "api_error", "internal_error"]);
/** Generic 5xx responses use `upstream_server_error`; 503 uses `server_is_overloaded`. */
const GENERIC_FAILURE_CODES: ReadonlySet<string> = new Set(["server_error", "upstream_error", "upstream_server_error", "api_error", "internal_error"]);
const NON_OVERFLOW_ERROR_TYPES: ReadonlySet<string> = new Set(["rate_limit_error", "authentication_error", "permission_error"]);
const NON_OVERFLOW_STATUSES: ReadonlySet<number> = new Set([401, 403, 429]);
/**
Expand All @@ -161,8 +162,10 @@ function assertsOverflow(message: string): boolean {
if (tooLong && !NEGATION_RE.test(tooLong[1] ?? "") && !CONDITIONAL_LEAD_RE.test(text.slice(0, tooLong.index))) return true;
const counts = STATED_COUNTS_RE.exec(text);
if (counts && Number(counts[2]) > Number(counts[1])) return true;
const exceeds = EXCEEDS_TEXT_RE.exec(text);
return exceeds !== null && !NEGATION_RE.test(exceeds[0]);
for (const exceeds of text.matchAll(new RegExp(EXCEEDS_TEXT_RE, "gi"))) {
if (!NEGATION_RE.test(exceeds[0]) && !CONDITIONAL_LEAD_RE.test(text.slice(0, exceeds.index))) return true;
}
return false;
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/lib/context-overflow-verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ function isParameterValidationRecord(record: Record<string, unknown>): boolean {
}

function isDefiniteContextOverflowMessage(text: string): boolean {
if (PARAMETER_VALIDATION_MARKERS.some(marker => marker.test(text))) return false;
const normalized = text.toLowerCase();
return normalized === "context_length_exceeded"
|| DEFINITE_CONTEXT_OVERFLOW_PHRASES.some(phrase => normalized.includes(phrase))
Expand All @@ -83,17 +82,18 @@ function isDefiniteContextOverflowMessage(text: string): boolean {
* arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed.
*
* Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters.
* An optional caller matcher specializes the verdict on the unwrapped, validated leaf.
* Exported for the sidecar loops, which classify their own provider failures with the same rule.
*/
export function isDefiniteContextOverflow(status: number, message: string): boolean {
export function isDefiniteContextOverflow(status: number, message: string, matchMessage = isDefiniteContextOverflowMessage): boolean {
if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false;
if (message.length > 16_384) return false;
let text = message.trim();
// One pass per unwrapped envelope, plus one for the leaf the last envelope yields.
for (let unwrapped = 0; unwrapped <= MAX_CONTEXT_OVERFLOW_ENVELOPES; unwrapped += 1) {
const providerPrefix = /^Provider error \d{3}:\s*/.exec(text);
if (providerPrefix) text = text.slice(providerPrefix[0].length).trim();
if (!text.startsWith("{")) return isDefiniteContextOverflowMessage(text);
if (!text.startsWith("{")) return !PARAMETER_VALIDATION_MARKERS.some(marker => marker.test(text)) && matchMessage(text);
if (unwrapped === MAX_CONTEXT_OVERFLOW_ENVELOPES) return false;
let payload: unknown;
try { payload = JSON.parse(text); } catch { return false; }
Expand Down
82 changes: 81 additions & 1 deletion src/lib/upstream-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* the shared abort helpers from here).
*/
import { clearableDeadline } from "./abort";
import { isDefiniteContextOverflow } from "./context-overflow-verdict";
import { errorDestinationLabel, errorDestinationText, type ErrorDestinationLabel } from "./error-destination";
export { errorDestinationLabel, providerErrorDestinationLabel, errorDestinationText } from "./error-destination";
import { redactSecretString } from "./redact";
Expand Down Expand Up @@ -721,6 +722,82 @@ export async function fetchWithResetRetry(
} finally { opts.onSendsConsumed?.close?.(); }
}

/** Bytes of a 5xx body read to look for a context-overflow verdict; the verdict caps its input too. */
const CONTEXT_OVERFLOW_PEEK_BYTES = 16_384;
/** A body that has not arrived by then is not evidence; the peek must never stall the retry. */
const CONTEXT_OVERFLOW_PEEK_MS = 250;
// A hypothetical, negated, or configuration-failure mention does not justify suppressing a retry.
// Inspect the unwrapped provider message, not its JSON spelling or unrelated envelope fields.
const NON_VERDICT_CONTEXT = /(?:\b(?:not|never|no|neither|nor|if|unless|whether)\b|n['\u2019]t\b)[^.!?\n]{0,100}\b(?:context(?:\s+(?:window|length))?|tokens?|(?:input|prompt)\s+is\s+too\s+long)\b|\b(?:context\s+(?:window|length)|token\s+limit|(?:input|prompt)\s+is\s+too\s+long)\b[^.!?\n]{0,40}\?/i;
const CONTEXT_CONFIG_FAILURE = /\b(?:fail(?:ed|ure)?|unable|cannot|error)\b[^.!?\n]{0,100}\b(?:configuration|settings|documentation)\b|\b(?:configuration|settings|documentation)\b[^.!?\n]{0,100}\b(?:fail(?:ed|ure)?|unavailable|missing|error)\b/i;
const CONTEXT_THROUGHPUT = /\bper (?:second|minute|hour|day)\b|\brate limit|\bquota\b|\btpm\b/i;

function retryContextOverflowMessage(message: string): boolean {
if (CONTEXT_THROUGHPUT.test(message)) return false;
return message.split(/(?<=[.!?])\s+|[\r\n]+/).some(sentence => {
if (NON_VERDICT_CONTEXT.test(sentence) || CONTEXT_CONFIG_FAILURE.test(sentence)) return false;
const withoutGenericThrottle = sentence.replace(/too many tokens/gi, "");
return isDefiniteContextOverflow(503, withoutGenericThrottle)
|| /\bprompt is too long\b/i.test(sentence);
});
}

/** Up to `maxBytes` of a clone of the body, or what arrived before the deadline. */
async function peekBodyText(res: Response, maxBytes: number, deadlineMs: number, signal?: AbortSignal): Promise<string> {
const reader = res.clone().body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
let emptyChunks = 0;
const deadlineAt = performance.now() + deadlineMs;
let timer: ReturnType<typeof setTimeout> | undefined;
let stop = () => {};
const timedOut = new Promise<null>(resolve => {
stop = () => resolve(null);
timer = setTimeout(stop, deadlineMs);
signal?.addEventListener("abort", stop, { once: true });
if (signal?.aborted) stop();
});
try {
while (bytes < maxBytes) {
if (signal?.aborted || performance.now() >= deadlineAt) break;
const chunk = await Promise.race([reader.read(), timedOut]);
if (!chunk || chunk.done) break;
if (chunk.value.byteLength === 0) {
if (++emptyChunks % 64 === 0) await new Promise<void>(resolve => setTimeout(resolve, 0));
continue;
}
emptyChunks = 0;
const bounded = chunk.value.subarray(0, maxBytes - bytes);
bytes += bounded.byteLength;
text += decoder.decode(bounded, { stream: true });
}
return text;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", stop);
reader.cancel().catch(() => {});
}
}

/**
* True when a transient-status response's body is a definite context-overflow verdict. Peeks a
* clone within a byte and time bound, so the caller still receives the untouched body. A slow
* or endless body keeps the normal retry unless its observed prefix already proves overflow.
* "Too many tokens" alone is not enough here: a 5xx
* "Too many tokens, please wait" is a throttle that the retry exists for.
*/
async function isContextOverflowResponse(res: Response, signal?: AbortSignal): Promise<boolean> {
if ((res.headers.get("content-type") ?? "").includes("text/event-stream")) return false;
try {
const text = await peekBodyText(res, CONTEXT_OVERFLOW_PEEK_BYTES, CONTEXT_OVERFLOW_PEEK_MS, signal);
return text.length > 0 && isDefiniteContextOverflow(res.status, text, retryContextOverflowMessage);
} catch {
return false;
}
}

/**
* fetchWithResetRetry plus the caller-selected transient-5xx policy, PRE-STREAM only.
* A received HTTP error follows that policy; an ambiguous reset's non-replayable
Expand Down Expand Up @@ -782,10 +859,13 @@ export async function fetchWithTransientRetry(
// thing about its own failure, so the ladder stops and hands the response back untouched.
if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res)
|| res.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER) === REPLAY_REFUSAL_NO_RETRY_VALUE) return res;
if (opts.abortSignal?.aborted || Date.now() - attemptStart > slowAttemptMs) return res;
// A 5xx that says the input does not fit is deterministic: resending the same body is
// refused the same way, so it goes back for the caller to classify (#8127).
if (await isContextOverflowResponse(res, opts.abortSignal)) return res;
// Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives
// a response whose body we just cancelled.
if (opts.abortSignal?.aborted) return res;
if (Date.now() - attemptStart > slowAttemptMs) return res;
const instructedDelay = retryAfterDelayMs(res.headers);
// The deadline is the CALLER'S, not this module's default. Reading the constant directly
// broke it in both directions: a caller with a 30s budget slept the full 45s an upstream
Expand Down
10 changes: 6 additions & 4 deletions src/protocols/encoders/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,12 +408,14 @@ function createAnthropicMessageWriter(
}
const message = typeof error.message === "string" ? error.message : "upstream request failed";
const deterministic = error.code === UNDECLARED_TOOL_CALL_ERROR_CODE;
const overflow = isContextOverflowError(error, message);
const transportFailure = isEncoderTransportFailure(terminal);
const classified = transportFailure ? { ...error, code: "upstream_stream_error", status: 502 } : error;
const overflow = isContextOverflowError(classified, message);
const original = encoderOriginalErrorEvidence(terminal);
fail(anthropicFailedStatus(error, message), message, !deterministic,
fail(anthropicFailedStatus(classified, message), message, !deterministic,
deterministic ? error.code as string : overflow ? "context_length_exceeded" : undefined,
hasProxyErrorMessage(options.proxyErrorContext, message, { type: typeof error.type === "string" ? error.type : undefined, code: typeof error.code === "string" ? error.code : undefined }), isEncoderTransportFailure(terminal) ? "transport_failed" : undefined,
{ ...(original ?? { message, type: typeof error.type === "string" ? error.type : undefined, code: typeof error.code === "string" ? error.code : undefined }), status: original?.status ?? anthropicFailedStatus(error, message) });
hasProxyErrorMessage(options.proxyErrorContext, message, { type: typeof error.type === "string" ? error.type : undefined, code: typeof error.code === "string" ? error.code : undefined }), transportFailure ? "transport_failed" : undefined,
{ ...(original ?? { message, type: typeof error.type === "string" ? error.type : undefined, code: typeof error.code === "string" ? error.code : undefined }), status: original?.status ?? anthropicFailedStatus(classified, message) });
},
overflow() {
fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit", true);
Expand Down
7 changes: 6 additions & 1 deletion src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,7 +1338,12 @@ async function handleClaudeMessagesWithBudget(
|| (![401, 403, 429].includes(response.status) && Boolean(nested || flat)
&& isContextOverflowError(errorObject, message));
} catch {
if (text) message = `upstream error (${response.status}): ${text.slice(0, 400)}`;
// A plain-text gateway body carries the same verdict with no envelope to read a code from.
const inspected = text.trim().slice(0, 16_384);
const plain = inspected.slice(0, 400);
contextError = Boolean(inspected) && ![401, 402, 403, 429].includes(response.status)
&& isContextOverflowError({}, inspected);
if (text) message = contextError ? plain : `upstream error (${response.status}): ${text.slice(0, 400)}`;
}
} catch { /* keep fallback message */ }
const upstreamRetryAfter = response.headers.get("retry-after");
Expand Down
23 changes: 18 additions & 5 deletions structure/data-planes/error-detail.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,17 @@ rows to client or catalog model identities and validated provider ids, omitting
is interpolated into those local messages. No serialized flag, code or header mints identity.
The shared builders live in `src/combos/resolve.ts`, `src/server/admission-model-scope.ts`,
`src/server/responses/input-admission.ts` and `src/oauth/generic-account-failover.ts`.
`src/lib/upstream-retry.ts` owns the retry constructors.
`src/lib/upstream-retry.ts` owns the retry constructors. For a transient 5xx, `fetchWithTransientRetry`
does not resend a response whose bounded body prefix proves context overflow. It reads at most 16,384
bytes from a clone within 250ms or until cancellation, leaving the returned body intact. Event streams
and bodies without an observed verdict retain the normal retry; a verdict observed before a slow or
endless body stalls still stops retry. Hypothetical, negated, question-form and configuration-failure
mentions are excluded per sentence in the unwrapped provider message, so they cannot hide a definite
overflow in another sentence. A throttle worded only as "too many tokens" is not a verdict, while an
explicit "prompt is too long" remains a verdict when no throughput signal is present.
Body inspection time does not count toward the upstream slow-attempt budget, and empty chunks cannot
starve the deadline or cancellation. The retry and cancellation
contracts are exercised in `tests/lib/upstream-retry.test.ts`.
`InvalidSendBudgetError` displays only a finite non-negative number or `invalid`.
`UpstreamRetryEvidenceError` uses fixed retry-evidence wording and retains the original rejection
only as `Error.cause`. Chat request-read failures keep typed local size/encoding messages; other
Expand Down Expand Up @@ -368,11 +378,14 @@ formatter message
before selecting the fixed incomplete or compaction sentence. Internal overflow decisions and HTTP
status stay separate
from this client-message check: a coded raw-collector throughput refusal still answers 400 without
selecting the compaction sentence. The shared `isContextOverflowError` source and its #8128 suite
remain unchanged; `tests/claude-integration/messages-overflow-baseline-contract.test.ts` binds their
source bytes. The executable overflow golden compares actual client recovery-phrase recognition,
selecting the compaction sentence. The shared `isContextOverflowError` adds `upstream_server_error`
to the generic codes and ignores conditional context-window matches while still checking later matches;
its remaining source and the #8128 suite stay byte-identical, enforced by
`tests/claude-integration/messages-overflow-baseline-contract.test.ts`. The executable overflow golden compares actual client recovery-phrase recognition,
HTTP status and the original internal decisions independently on every fixture/path row.
The direct Messages writer also applies that shared verdict to the emitted code: a definite generic-coded overflow carries `context_length_exceeded`, the fixed compaction sentence and HTTP 400 on collection, even with `retryable: true`; original error evidence and proxy/transport metadata remain separate. `tests/helpers/messages-overflow-assertions.ts` records the five affected fixture ids only on direct-stream/direct-fold as explicit expected changes to the immutable golden. All other paths keep their baseline comparisons; neither the fixture data nor the shared predicate is regenerated.
The direct Messages writer also applies that shared verdict to the emitted code: a definite generic-coded overflow carries `context_length_exceeded`, the fixed compaction sentence and HTTP 400 on collection, even with `retryable: true`; original error evidence and proxy/transport metadata remain separate. `tests/helpers/messages-overflow-assertions.ts` records the affected fixture ids only on direct-stream/direct-fold as explicit expected changes to the immutable golden. All other paths keep their baseline comparisons; the fixture data is not regenerated.
The translated non-2xx plain-text path classifies up to 16,384 characters before selecting the bounded
400-character display text, so an overflow visible to the retry peek is not lost to display truncation.
The direct encoder retains original adapter fields by terminal identity independently of shared
classification. Buffered passthrough and count_tokens preserve the upstream status and closed
type/code. The type chosen
Expand Down
2 changes: 1 addition & 1 deletion structure/data-planes/protocol-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/ou
`chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`,
`anthropicIncompleteOutcome`, `anthropicFailedStatus`, `isContextOverflowError`, the message snapshot, the web-search pair),
so the frames a client receives are the converters' frames. A fold is the encoded stream read by
the existing collector. Messages terminal errors use the shared overflow verdict for their code as well as their status: definite generic-coded refusals carry `context_length_exceeded` even when marked retryable; the deterministic undeclared-tool refusal keeps its code. `tests/responses/protocol-direct-encoders-messages.test.ts` checks stream/fold parity for all four generic codes, plus an `upstream_error` type variant. The original adapter evidence and proxy/transport metadata still reach the [owned Messages formatter](error-detail.md#messages-error-bodies-and-headers). Only producer-marked watchdog/EOF terminals retain local Chat wording; upstream copies of their reason or message follow [client error normalization](error-detail.md#client-error-bodies-chat). Explicit incomplete reasons use the [shared normalization contract](../transports/responses-wire-shapes.md#adapter-incomplete-reasons) before the writers classify the outcome.
the existing collector. Messages terminal errors use the shared overflow verdict for their code as well as their status: definite generic-coded refusals carry `context_length_exceeded` even when marked retryable; the deterministic undeclared-tool refusal keeps its code. Producer-marked reader exceptions retain transport classification even when their text contains overflow wording; `tests/claude-integration/messages-error-normalization.test.ts` checks that they do not acquire a context-overflow code. `tests/responses/protocol-direct-encoders-messages.test.ts` checks stream/fold parity for all four generic codes, plus an `upstream_error` type variant. The original adapter evidence and proxy/transport metadata still reach the [owned Messages formatter](error-detail.md#messages-error-bodies-and-headers). Only producer-marked watchdog/EOF terminals retain local Chat wording; upstream copies of their reason or message follow [client error normalization](error-detail.md#client-error-bodies-chat). Explicit incomplete reasons use the [shared normalization contract](../transports/responses-wire-shapes.md#adapter-incomplete-reasons) before the writers classify the outcome.

Deliberate equivalences, pinned by the parity tests: a Chat function call is delivered as one
complete tool-call chunk when it completes, as the converter always did; Messages streams
Expand Down
Loading
Loading