Add strict 502 mode and upstream retry handling for streaming responses

This commit is contained in:
nonononull
2026-06-26 11:24:00 +08:00
parent 9cc6bc9b31
commit d2e1acbaf4
7 changed files with 2997 additions and 2817 deletions
+4 -2
View File
@@ -7,7 +7,7 @@
- 保持 Codex 继续使用现有 `auth.json` - 保持 Codex 继续使用现有 `auth.json`
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 - 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
- 非流式命中 `reasoning_tokens = 516` 时返回 `502` - 非流式命中 `reasoning_tokens = 516` 时返回 `502`
- 流式命中时直接断开连接,让 Codex 自行重试 - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 默认同时拦截 root 路径和 `/v1` 路径: - 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses` - `/responses`
- `/chat/completions` - `/chat/completions`
@@ -191,7 +191,9 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json
- `non_stream_status_code` - `non_stream_status_code`
- 默认 `502` - 默认 `502`
- `stream_action` - `stream_action`
- 默认 `disconnect` - 默认 `strict_502`
- `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502`
- `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接
- `log_match` - `log_match`
- 是否记录命中日志 - 是否记录命中日志
+1 -1
View File
@@ -6,7 +6,7 @@
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
"reasoning_equals": [516], "reasoning_equals": [516],
"non_stream_status_code": 502, "non_stream_status_code": 502,
"stream_action": "disconnect", "stream_action": "strict_502",
"log_match": true, "log_match": true,
"health_path": "/__codex_retry_gateway/health" "health_path": "/__codex_retry_gateway/health"
} }
+28 -2
View File
@@ -7,9 +7,9 @@
- 只解决 Codex 已可访问上游时的 `reasoning_tokens = 516` 重试问题 - 只解决 Codex 已可访问上游时的 `reasoning_tokens = 516` 重试问题
- 不替代 `cc-switch` 的协议路由转换 - 不替代 `cc-switch` 的协议路由转换
- 流式场景默认策略是: - 流式场景默认策略是:
-实时透传 -缓存上游流
- 一旦检测到命中 `516` - 一旦检测到命中 `516`
- 直接断开连接 - 统一返回 `502`
### 当前已知限制 ### 当前已知限制
@@ -111,6 +111,32 @@
- `.sh` 优先选择 `node.exe` - `.sh` 优先选择 `node.exe`
- 在 WSL / Bash 场景下把路径参数转换回 Windows 路径后再交给 `node.exe` - 在 WSL / Bash 场景下把路径参数转换回 Windows 路径后再交给 `node.exe`
14. 上游流式连接中途终止时被误记为网关错误,首次瞬断也缺少最小重试
- 现象:
- 日志出现:
- `TypeError: terminated`
- `TypeError: fetch failed`
- 其中一部分来自上游 SSE 中途断流,另一部分来自上游首次连接瞬时失败
- 根因:
- `handleStreaming()` 直接把 `reader.read()` 抛出的 `AbortError` / `TypeError: terminated` 冒到统一错误处理
- `proxyRequest()` 对上游 `fetch()` 没有做一次轻量重试,首个瞬断会直接返回 `502`
- 处理:
- 新增预期流终止识别:
- `AbortError`
- `TypeError: terminated`
- 这两类在流式处理中按“连接已结束”收口,不再记 `[error]`
- 新增上游 `fetch failed` 的一次自动重试
- 新增严格 `502` 流式模式:
- 默认不再抢先透传 `200` 头和首个 chunk
- 先缓存流,再根据 `reasoning_tokens` 决定透传或返回 `502`
- 验证:
- `scripts/test-gateway-e2e.mjs`
- 新增 `/responses` 流式覆盖
- 新增“上游半路断流不刷 error 日志”断言
- 新增“首次 fetch failed 后第二次成功恢复”断言
- 新增“流式 `516` 统一返回 `502`,不再先透传半截 chunk”断言
- `scripts/test-install-restore.mjs` 继续通过
### 2026-06-26 实测证据 ### 2026-06-26 实测证据
- 假上游 E2E - 假上游 E2E
+92 -12
View File
@@ -24,7 +24,7 @@ const DEFAULT_CONFIG = {
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
reasoning_equals: [516], reasoning_equals: [516],
non_stream_status_code: 502, non_stream_status_code: 502,
stream_action: "disconnect", stream_action: "strict_502",
log_match: true, log_match: true,
health_path: "/__codex_retry_gateway/health", health_path: "/__codex_retry_gateway/health",
}; };
@@ -63,7 +63,7 @@ function printHelp() {
"说明:", "说明:",
" 独立 Codex 本地重试网关。", " 独立 Codex 本地重试网关。",
" 非流式命中 reasoning_tokens=516 时返回 502。", " 非流式命中 reasoning_tokens=516 时返回 502。",
" 流式命中时默认直接断开连接,交给 Codex 自身重试。", " 流式命中时默认缓存并返回 502,避免半截流返回。",
"", "",
].join("\n"), ].join("\n"),
); );
@@ -153,6 +153,16 @@ function buildBlockedBody(pathname, reasoning, statusCode) {
}); });
} }
function buildGatewayErrorBody(message) {
return JSON.stringify({
error: {
message,
type: "codex_retry_gateway_error",
code: "gateway_error",
},
});
}
function createMonitor() { function createMonitor() {
return { return {
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
@@ -1198,6 +1208,42 @@ function reasoningMatched(config, reasoning) {
return reasoning !== null && config.reasoning_equals.includes(reasoning); return reasoning !== null && config.reasoning_equals.includes(reasoning);
} }
function isExpectedStreamTermination(error) {
if (!error) {
return false;
}
if (error.name === "AbortError") {
return true;
}
return error instanceof TypeError && error.message === "terminated";
}
function isRetryableUpstreamFetchError(error) {
if (!error) {
return false;
}
return error instanceof TypeError && error.message === "fetch failed";
}
async function fetchUpstreamWithRetry(upstreamUrl, init, logger) {
const maxAttempts = 2;
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await fetch(upstreamUrl, init);
} catch (error) {
lastError = error;
if (!isRetryableUpstreamFetchError(error) || attempt === maxAttempts) {
break;
}
logger?.(`[retry] upstream fetch failed attempt=${attempt} url=${upstreamUrl}`);
}
}
throw lastError;
}
function inspectSseChunk(state, chunk) { function inspectSseChunk(state, chunk) {
const decoded = state.decoder.decode(chunk, { stream: true }); const decoded = state.decoder.decode(chunk, { stream: true });
state.buffer += decoded; state.buffer += decoded;
@@ -1280,9 +1326,7 @@ async function handleStreaming({
res, res,
abortController, abortController,
}) { }) {
copyHeadersToClient(upstreamResponse.headers, res); const strict502Mode = config.stream_action !== "disconnect";
res.writeHead(upstreamResponse.status);
const reader = upstreamResponse.body.getReader(); const reader = upstreamResponse.body.getReader();
const sseState = { const sseState = {
decoder: new TextDecoder("utf8"), decoder: new TextDecoder("utf8"),
@@ -1291,14 +1335,46 @@ async function handleStreaming({
let wroteAnyChunk = false; let wroteAnyChunk = false;
let observedReasoning = null; let observedReasoning = null;
const bufferedChunks = [];
if (!strict502Mode) {
copyHeadersToClient(upstreamResponse.headers, res);
res.writeHead(upstreamResponse.status);
}
while (true) { while (true) {
const { done, value } = await reader.read(); let readResult;
try {
readResult = await reader.read();
} catch (error) {
if (isExpectedStreamTermination(error)) {
recordInspectedResponse(monitor, observedReasoning, false);
if (strict502Mode) {
logger?.(`[stream] upstream terminated before completion path=${pathname} action=status_502`);
res.writeHead(502, { "content-type": "application/json; charset=utf-8" });
res.end(buildGatewayErrorBody("upstream stream terminated before completion"));
} else {
res.end();
}
return;
}
throw error;
}
const { done, value } = readResult;
if (done) { if (done) {
recordInspectedResponse(monitor, observedReasoning, false); recordInspectedResponse(monitor, observedReasoning, false);
if (strict502Mode) {
copyHeadersToClient(upstreamResponse.headers, res);
res.writeHead(upstreamResponse.status);
res.end(Buffer.concat(bufferedChunks));
} else {
res.end(); res.end();
}
return; return;
} }
const chunkBuffer = Buffer.from(value);
const reasoning = inspectSseChunk(sseState, value); const reasoning = inspectSseChunk(sseState, value);
if (Number.isInteger(reasoning)) { if (Number.isInteger(reasoning)) {
observedReasoning = reasoning; observedReasoning = reasoning;
@@ -1311,14 +1387,14 @@ async function handleStreaming({
); );
} }
if (!wroteAnyChunk) { if (strict502Mode || !wroteAnyChunk) {
abortController.abort();
reader.cancel().catch(() => {});
const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code); const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code);
if (!res.headersSent) {
res.writeHead(config.non_stream_status_code, { res.writeHead(config.non_stream_status_code, {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"x-codex-retry-gateway-reason": "reasoning-guard-triggered", "x-codex-retry-gateway-reason": "reasoning-guard-triggered",
}); });
}
res.end(blockedBody); res.end(blockedBody);
} else { } else {
abortController.abort(); abortController.abort();
@@ -1328,8 +1404,12 @@ async function handleStreaming({
return; return;
} }
if (strict502Mode) {
bufferedChunks.push(chunkBuffer);
} else {
wroteAnyChunk = true; wroteAnyChunk = true;
res.write(Buffer.from(value)); res.write(chunkBuffer);
}
} }
} }
@@ -1367,12 +1447,12 @@ async function proxyRequest(runtime, req, res) {
const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl);
const abortController = new AbortController(); const abortController = new AbortController();
const upstreamResponse = await fetch(upstreamUrl, { const upstreamResponse = await fetchUpstreamWithRetry(upstreamUrl, {
method: req.method, method: req.method,
headers: cloneHeadersForUpstream(req.headers), headers: cloneHeadersForUpstream(req.headers),
body: requestBody.length > 0 ? requestBody : undefined, body: requestBody.length > 0 ? requestBody : undefined,
signal: abortController.signal, signal: abortController.signal,
}); }, logger);
const shouldInspect = matchPath(config, pathname); const shouldInspect = matchPath(config, pathname);
const responseIsStream = const responseIsStream =
+1 -1
View File
@@ -418,7 +418,7 @@ export async function installForCurrentProvider({
existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null
? 502 ? 502
: Number.parseInt(`${existingGatewayConfig.non_stream_status_code}`, 10), : Number.parseInt(`${existingGatewayConfig.non_stream_status_code}`, 10),
stream_action: existingGatewayConfig?.stream_action || "disconnect", stream_action: existingGatewayConfig?.stream_action || "strict_502",
log_match: existingGatewayConfig?.log_match === undefined ? true : Boolean(existingGatewayConfig.log_match), log_match: existingGatewayConfig?.log_match === undefined ? true : Boolean(existingGatewayConfig.log_match),
health_path: existingGatewayConfig?.health_path || DEFAULT_HEALTH_PATH, health_path: existingGatewayConfig?.health_path || DEFAULT_HEALTH_PATH,
}; };
+1 -1
View File
@@ -62,7 +62,7 @@ $gatewayConfig = [ordered]@{
endpoints = @($mergedEndpoints) endpoints = @($mergedEndpoints)
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516) reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516)
non_stream_status_code = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.non_stream_status_code) { [int]$existingGatewayConfig.non_stream_status_code } else { 502 } non_stream_status_code = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.non_stream_status_code) { [int]$existingGatewayConfig.non_stream_status_code } else { 502 }
stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "disconnect" } stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "strict_502" }
log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true } log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true }
health_path = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) { [string]$existingGatewayConfig.health_path } else { "/__codex_retry_gateway/health" } health_path = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) { [string]$existingGatewayConfig.health_path } else { "/__codex_retry_gateway/health" }
} }
+80 -8
View File
@@ -4,7 +4,7 @@ import http from "node:http";
import net from "node:net"; import net from "node:net";
import { once } from "node:events"; import { once } from "node:events";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -63,7 +63,25 @@ function createSseResponse(res, chunks) {
}); });
} }
function createTerminatedSseResponse(res, chunks, destroyDelayMs = 20) {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
"x-upstream-test": "sse-terminated",
});
for (const chunk of chunks) {
res.write(chunk);
}
setTimeout(() => {
res.socket?.destroy();
}, destroyDelayMs);
}
function startFakeUpstream(port) { function startFakeUpstream(port) {
const failBeforeResponseCounts = new Map();
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const responsePaths = new Set(["/responses", "/v1/responses"]); const responsePaths = new Set(["/responses", "/v1/responses"]);
const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]); const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]);
@@ -90,11 +108,37 @@ function startFakeUpstream(port) {
req.on("end", () => { req.on("end", () => {
const parsed = JSON.parse(body || "{}"); const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128; const reasoning = parsed.test_reasoning_tokens ?? 128;
if (parsed.test_fail_before_response_once) {
const failKey = `${req.url}:fail-before-response-once`;
const failCount = (failBeforeResponseCounts.get(failKey) || 0) + 1;
failBeforeResponseCounts.set(failKey, failCount);
if (failCount === 1) {
res.socket?.destroy();
return;
}
}
if (parsed.test_force_terminate) {
createTerminatedSseResponse(res, [
'data: {"type":"response.output_text.delta","delta":"hello"}\n\n',
]);
return;
}
if (parsed.stream) {
createSseResponse(res, [
'data: {"type":"response.output_text.delta","delta":"hello"}\n\n',
`data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`,
"data: [DONE]\n\n",
]);
return;
}
createJsonResponse( createJsonResponse(
res, res,
200, 200,
{ {
id: "resp_test", id: "resp_test",
retry_attempt: parsed.test_fail_before_response_once
? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0
: 0,
usage: { usage: {
output_tokens_details: { output_tokens_details: {
reasoning_tokens: reasoning, reasoning_tokens: reasoning,
@@ -232,7 +276,7 @@ async function run() {
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
reasoning_equals: [516], reasoning_equals: [516],
non_stream_status_code: 502, non_stream_status_code: 502,
stream_action: "disconnect", stream_action: "strict_502",
log_match: true, log_match: true,
health_path: "/__codex_retry_gateway/health", health_path: "/__codex_retry_gateway/health",
}; };
@@ -279,17 +323,32 @@ async function run() {
); );
} }
for (const streamPath of ["/chat/completions", "/v1/chat/completions"]) { const recoveredResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_fail_before_response_once: true }),
});
const recoveredBody = await recoveredResponse.json();
assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`);
assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求");
for (const streamPath of [
"/responses",
"/v1/responses",
"/chat/completions",
"/v1/chat/completions",
]) {
const blockedStream = await readSseUntilClose( const blockedStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}${streamPath}`, `http://127.0.0.1:${gatewayPort}${streamPath}`,
{ stream: true, test_reasoning_tokens: 516 }, { stream: true, test_reasoning_tokens: 516 },
); );
assert(blockedStream.status === 200, `${streamPath} 516 首状态异常: ${blockedStream.status}`); assert(blockedStream.status === 502, `${streamPath} 516 未返回 502: ${blockedStream.status}`);
assert(blockedStream.text.includes('"content":"hello"'), `${streamPath} 流式 516 未先透传正常 chunk`); assert(!blockedStream.text.includes("hello"), `${streamPath} 严格 502 模式不应先透传正常 chunk`);
assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 流式 516 不应完整结束`); assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 严格 502 模式不应回放 DONE`);
const blockedStreamBody = JSON.parse(blockedStream.text);
assert( assert(
blockedStream.closedByError || blockedStream.text.includes("[[reader-error:"), blockedStreamBody?.error?.code === "reasoning_guard_triggered",
`${streamPath} 流式 516 未表现为中途断开`, `${streamPath} 流式 516 返回体不正确`,
); );
const okStream = await readSseUntilClose( const okStream = await readSseUntilClose(
@@ -301,6 +360,19 @@ async function run() {
assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`); assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`);
} }
const terminatedStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}/responses`,
{ stream: true, test_force_terminate: true },
);
assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`);
await new Promise((resolve) => setTimeout(resolve, 120));
const logText = await readFile(logPath, "utf8");
assert(
!logText.includes("[error] TypeError: terminated"),
"上游半路断流后不应记录 terminated error 日志",
);
process.stdout.write("PASS codex-retry-gateway e2e\n"); process.stdout.write("PASS codex-retry-gateway e2e\n");
} finally { } finally {
gateway.child.kill(); gateway.child.kill();