mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 12:52:25 +03:00
* fix(sse): retry 0-byte empty_response 502 like STREAM_EARLY_EOF to stop autocompact 502
A genuine 0-byte upstream empty response (GLM-5.2 on a huge autocompact
context returns ONLY reasoning_content or nothing, then closes) reaches
stream.ts::emitClaudeEmptyStreamErrorAndAbort which emits a 502 with
code "empty_response" via the onFailure callback AND propagates the
failure down the pipeline as controller.error(new Error(msg)). The plain
Error carries no .code, so getUpstreamErrorIdentifier (reads only
error.code) returns undefined, result.errorCode/result.errorType become
undefined, and the single-model retry guard (chat.ts) only matches
errorType === "stream_early_eof" / errorCode === "STREAM_EARLY_EOF".
The 502 surfaces to the client with no re-attempt (call logs
1788132529140-96ef4a / 1788142914004-062cf6, ~48s, tokens out=0).
This is the same class of transient upstream glitch STREAM_EARLY_EOF was
built for (HTTP 200 then zero useful frames — #3758), but empty_response
was never wired into the retry path.
Fix (three chokepoints, all required for consistency):
- stream.ts: emitClaudeEmptyStreamErrorAndAbort now propagates an Error
carrying code="empty_response" so a downstream classifier can identify
it (plain new Error(msg) dropped it).
- chatHelpers.ts: shouldRetryStreamEarlyEof now treats "empty_response"
the same as "STREAM_EARLY_EOF" via RETRYABLE_STREAM_EMPTY_CODES Set —
ONE bounded same-connection re-attempt, never a loop
(STREAM_EARLY_EOF_MAX_RETRIES=1 unchanged).
- chat.ts: the single-model retry guard now also enters on
errorCode === "empty_response".
The bounded retry never marks the account unavailable (an empty response
is a transient upstream glitch, not a bad key), mirroring #3758.
Tests: 5/5 (stream-empty-response-retry-96ef4a). Existing 3758 regression
guard stays green (5/5). typecheck:core clean.
* fix(sse): make direct response-start timeout reasoning-aware to stop 504 on high-effort TTFB
Reasoning models (GLM-5.2/5.3 reasoning.effort=high/max, codex-gpt-5.x-high,
third-party Claude-format replicas) warm up with a ~78s+ TTFB before
emitting the first byte. The stream-readiness layer (streamReadinessPolicy)
already budgets 180s for this class, but the fetch-layer guard
(resolveDirectHeadersTimeoutMs) was a flat 30s — it pre-empted a warm
reasoning response the readiness layer would have permitted, surfacing a
504 (regression introduced by 142ae9349).
Fix: resolveDirectHeadersTimeoutMs now accepts the request body and, when
hasHighReasoningEffort(body) matches a quoted "reasoning_effort" or nested
"effort" field with value high/max, raises the budget to
REASONING_READINESS_CEILING_MS (180_000) — aligning to the same ceiling the
readiness layer uses. The operator env override (OMNIROUTE_DIRECT_HEADERS
TIMEOUT_MS) is treated as a FLOOR: reasoning awareness only raises the
budget, never lowers it; an override above the ceiling (e.g. 240s) is
preserved.
proxyFetch.ts passes the request body (when it is a string) to
resolveDirectHeadersTimeoutMs so the budget is per-request.
The HIGH_REASONING_EFFORT_PATTERN is a bounded, non-overlapping regex
(no variable-length quantifier overlap) — no ReDoS surface (PII rule #1).
Tests: 7/7 (direct-response-start-timeout-reasoning-504 — flat default,
env override, high/max ceiling bump, floor semantics, non-reasoning
pass-through). typecheck:core clean.
* docs(changelog): add fragments for empty_response 502 retry + reasoning-aware timeout
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
type DirectFetchOptions = RequestInit & { dispatcher?: unknown };
|
|
type DirectFetch = (input: RequestInfo | URL, options: DirectFetchOptions) => Promise<Response>;
|
|
|
|
const DEFAULT_DIRECT_HEADERS_TIMEOUT_MS = 30_000;
|
|
const DIRECT_RESPONSE_START_TIMEOUT_CODE = "DIRECT_RESPONSE_START_TIMEOUT";
|
|
|
|
// Reasoning models (GLM-5.2/5.3 reasoning.effort=high/max, codex-gpt-5.x-high,
|
|
// third-party Claude-format replicas) warm up with a ~78s+ TTFB before emitting
|
|
// the first byte. The stream-readiness layer (streamReadinessPolicy.ts) already
|
|
// budgets 180s for this class (claude_format_heavy_reasoning /
|
|
// codex_gpt_5_5_high_reasoning +30s bumps over an 80s base). This fetch-layer
|
|
// guard must align to the SAME ceiling so it does not pre-empt a warm reasoning
|
|
// response that the readiness layer would have permitted — that mismatch is the
|
|
// 504 regression introduced by 142ae9349 (flat 30s cut a 78s+ reasoning TTFB).
|
|
const REASONING_READINESS_CEILING_MS = 180_000;
|
|
// Bounded, non-overlapping pattern: a quoted "reasoning_effort" or nested
|
|
// "effort" field whose value is high or max. No variable-length quantifier
|
|
// overlap → no ReDoS surface (project PII rule #1).
|
|
const HIGH_REASONING_EFFORT_PATTERN = /"(?:reasoning_effort|effort)"\s*:\s*"(?:high|max)"/i;
|
|
|
|
function hasHighReasoningEffort(body?: string | null): boolean {
|
|
if (!body || typeof body !== "string") return false;
|
|
return HIGH_REASONING_EFFORT_PATTERN.test(body);
|
|
}
|
|
|
|
export function resolveDirectHeadersTimeoutMs(
|
|
env: Record<string, string | undefined> = process.env,
|
|
body?: string | null
|
|
): number {
|
|
const raw = env.OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS;
|
|
const base =
|
|
raw == null || raw.trim() === ""
|
|
? DEFAULT_DIRECT_HEADERS_TIMEOUT_MS
|
|
: Number.isFinite(Number(raw)) && Number(raw) > 0
|
|
? Math.floor(Number(raw))
|
|
: 0;
|
|
// Operator override is a FLOOR: reasoning awareness only raises the budget,
|
|
// never lowers it. An override above the ceiling (e.g. 240s) is preserved.
|
|
if (hasHighReasoningEffort(body)) {
|
|
return Math.max(base, REASONING_READINESS_CEILING_MS);
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function createDirectResponseStartTimeout(timeoutMs: number): Error & { code: string } {
|
|
const err = new Error(
|
|
`Direct response did not start within ${timeoutMs}ms — retrying on a fresh socket`
|
|
) as Error & { code: string };
|
|
err.name = "TimeoutError";
|
|
err.code = DIRECT_RESPONSE_START_TIMEOUT_CODE;
|
|
return err;
|
|
}
|
|
|
|
export function isDirectResponseStartTimeout(err: unknown): boolean {
|
|
return (
|
|
!!err &&
|
|
typeof err === "object" &&
|
|
"code" in err &&
|
|
err.code === DIRECT_RESPONSE_START_TIMEOUT_CODE
|
|
);
|
|
}
|
|
|
|
function mergeAbortSignals(
|
|
primary: AbortSignal | null | undefined,
|
|
secondary: AbortSignal
|
|
): AbortSignal {
|
|
if (!primary) return secondary;
|
|
if (primary.aborted) return primary;
|
|
const controller = new AbortController();
|
|
const onPrimaryAbort = () => controller.abort(primary.reason);
|
|
const onSecondaryAbort = () => controller.abort(secondary.reason);
|
|
const cleanup = () => {
|
|
primary.removeEventListener("abort", onPrimaryAbort);
|
|
secondary.removeEventListener("abort", onSecondaryAbort);
|
|
};
|
|
primary.addEventListener("abort", onPrimaryAbort, { once: true });
|
|
secondary.addEventListener("abort", onSecondaryAbort, { once: true });
|
|
controller.signal.addEventListener("abort", cleanup, { once: true });
|
|
return controller.signal;
|
|
}
|
|
|
|
export async function directFetchWithBoundedResponseStart(
|
|
input: RequestInfo | URL,
|
|
options: DirectFetchOptions,
|
|
fetchImpl: DirectFetch,
|
|
timeoutMs: number
|
|
): Promise<Response> {
|
|
if (!timeoutMs || timeoutMs <= 0) return fetchImpl(input, options);
|
|
const attemptController = new AbortController();
|
|
const timer = setTimeout(
|
|
() => attemptController.abort(createDirectResponseStartTimeout(timeoutMs)),
|
|
timeoutMs
|
|
);
|
|
timer.unref?.();
|
|
try {
|
|
return await fetchImpl(input, {
|
|
...options,
|
|
signal: mergeAbortSignals(options.signal, attemptController.signal),
|
|
});
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|