mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +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>
90 lines
3.4 KiB
TypeScript
90 lines
3.4 KiB
TypeScript
// Regression guard for the 504 regression introduced by 142ae9349
|
||
// "fix(network): bound direct-path response-start timeout".
|
||
//
|
||
// Root cause: directResponseStartTimeout resolved a FLAT timeout
|
||
// (OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS or 30s default) with zero
|
||
// awareness of reasoning effort. GLM-5.2 reasoning.effort=max has ~78s
|
||
// TTFB; the stream-readiness layer already allows 180s for reasoning
|
||
// models (streamReadinessPolicy claude_format_heavy_reasoning /
|
||
// codex_gpt_5_5_high_reasoning bumps), but the fetch layer below it
|
||
// cut the request at 30s (×2 = 60s 504) — and even 90s was still short.
|
||
//
|
||
// The fix: resolveDirectHeadersTimeoutMs inspects the serialized
|
||
// request body for a high/max reasoning effort selector and raises the
|
||
// per-attempt TTFB budget to align with the stream-readiness ceiling
|
||
// (180s) so the fetch layer no longer pre-empts a warm reasoning
|
||
// response that the readiness layer would have permitted.
|
||
import { test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { resolveDirectHeadersTimeoutMs } from "../../open-sse/utils/directResponseStartTimeout.ts";
|
||
|
||
const REASONING_HIGH_BODY = JSON.stringify({
|
||
model: "glm-5.2",
|
||
reasoning_effort: "high",
|
||
messages: [{ role: "user", content: "hi" }],
|
||
});
|
||
const REASONING_MAX_BODY = JSON.stringify({
|
||
model: "glm-5.3",
|
||
reasoning: { effort: "max" },
|
||
messages: [{ role: "user", content: "hi" }],
|
||
});
|
||
const NON_REASONING_BODY = JSON.stringify({
|
||
model: "gpt-4o-mini",
|
||
messages: [{ role: "user", content: "hi" }],
|
||
});
|
||
|
||
test("flat default is 30s when no body and no env override", () => {
|
||
assert.equal(
|
||
resolveDirectHeadersTimeoutMs({ OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS: undefined }),
|
||
30_000
|
||
);
|
||
});
|
||
|
||
test("env override is honored when no reasoning body is present", () => {
|
||
assert.equal(
|
||
resolveDirectHeadersTimeoutMs({ OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS: "90000" }),
|
||
90_000
|
||
);
|
||
});
|
||
|
||
test("reasoning_effort=high body raises TTFB budget to the readiness ceiling (180s)", () => {
|
||
const got = resolveDirectHeadersTimeoutMs(
|
||
{ OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS: undefined },
|
||
REASONING_HIGH_BODY
|
||
);
|
||
assert.equal(got, 180_000, "high reasoning must align with the 180s readiness ceiling");
|
||
});
|
||
|
||
test("reasoning.effort=max nested body raises TTFB budget to the readiness ceiling (180s)", () => {
|
||
const got = resolveDirectHeadersTimeoutMs(
|
||
{ OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS: undefined },
|
||
REASONING_MAX_BODY
|
||
);
|
||
assert.equal(got, 180_000, "max reasoning must align with the 180s readiness ceiling");
|
||
});
|
||
|
||
test("reasoning body never yields a budget BELOW an explicit env override above the ceiling", () => {
|
||
// Operator override is a floor; reasoning awareness only raises, never lowers.
|
||
const got = resolveDirectHeadersTimeoutMs(
|
||
{ OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS: "240000" },
|
||
REASONING_HIGH_BODY
|
||
);
|
||
assert.equal(got, 240_000, "explicit override above ceiling is preserved");
|
||
});
|
||
|
||
test("non-reasoning body keeps the flat default (zombie-socket detection preserved)", () => {
|
||
const got = resolveDirectHeadersTimeoutMs(
|
||
{ OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS: undefined },
|
||
NON_REASONING_BODY
|
||
);
|
||
assert.equal(got, 30_000, "non-reasoning requests keep 30s to detect zombie sockets");
|
||
});
|
||
|
||
test("non-reasoning body keeps the env override (no reasoning bump applied)", () => {
|
||
const got = resolveDirectHeadersTimeoutMs(
|
||
{ OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS: "90000" },
|
||
NON_REASONING_BODY
|
||
);
|
||
assert.equal(got, 90_000);
|
||
});
|