Files
OmniRoute/tests/unit/direct-response-start-timeout-reasoning-504.test.ts
initguru d70f43d4b4 fix(sse): retry empty_response 502 + reasoning-aware direct response-start timeout (#12906)
* 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>
2026-09-17 02:30:15 -03:00

90 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});