Add strict 502 mode and upstream retry handling for streaming responses
This commit is contained in:
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user