retry stream capacity errors in gateway

This commit is contained in:
2026-06-29 20:54:25 +08:00
parent ab0572baf6
commit 1d7016bf65
3 changed files with 371 additions and 117 deletions
+310 -110
View File
@@ -6,6 +6,7 @@ import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:f
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import zlib from "node:zlib";
import { TextDecoder } from "node:util"; import { TextDecoder } from "node:util";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -23,6 +24,7 @@ const PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/probe`;
const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`; const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`;
const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`; const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`;
const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`;
const STATUS_REASONING_COUNT_LIMIT = 24;
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
profile_name: "default", profile_name: "default",
@@ -1091,6 +1093,14 @@ async function writeProfile(runtime, payload) {
function buildMetricsSnapshot(monitor) { function buildMetricsSnapshot(monitor) {
const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0; const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0;
const inspectedResponseCount = monitor.inspected_response_count; const inspectedResponseCount = monitor.inspected_response_count;
const reasoningEntries = Object.entries(monitor.observed_reasoning_counts).sort((left, right) => {
const countDelta = Number(right[1] || 0) - Number(left[1] || 0);
if (countDelta !== 0) {
return countDelta;
}
return Number(left[0] || 0) - Number(right[0] || 0);
});
const visibleReasoningEntries = reasoningEntries.slice(0, STATUS_REASONING_COUNT_LIMIT);
return { return {
started_at: monitor.started_at, started_at: monitor.started_at,
persistent_since: monitor.persistent_since, persistent_since: monitor.persistent_since,
@@ -1101,7 +1111,12 @@ function buildMetricsSnapshot(monitor) {
reasoning_516_ratio: reasoning_516_ratio:
inspectedResponseCount === 0 ? 0 : reasoning516Count / inspectedResponseCount, inspectedResponseCount === 0 ? 0 : reasoning516Count / inspectedResponseCount,
token_totals: { ...monitor.token_totals }, token_totals: { ...monitor.token_totals },
observed_reasoning_counts: { ...monitor.observed_reasoning_counts }, observed_reasoning_counts: Object.fromEntries(visibleReasoningEntries),
observed_reasoning_counts_total_keys: reasoningEntries.length,
observed_reasoning_counts_omitted: Math.max(
0,
reasoningEntries.length - visibleReasoningEntries.length,
),
}; };
} }
@@ -1758,12 +1773,65 @@ async function restoreRuntimeState(runtime, state) {
]); ]);
} }
function jsonResponse(res, statusCode, payload, headers = {}) { function pickContentEncoding(acceptEncoding = "") {
const value = `${acceptEncoding}`.toLowerCase();
if (value.includes("br")) {
return "br";
}
if (value.includes("gzip")) {
return "gzip";
}
return null;
}
function shouldCompressContent(headers = {}) {
const contentType = `${headers["content-type"] || headers["Content-Type"] || ""}`.toLowerCase();
return (
contentType.startsWith("text/") ||
contentType.includes("javascript") ||
contentType.includes("json") ||
contentType.includes("xml") ||
contentType.includes("svg")
);
}
function maybeCompressBody(body, headers = {}, acceptEncoding = "") {
if (!body || body.length < 1024 || !shouldCompressContent(headers)) {
return { body, encoding: null };
}
const encoding = pickContentEncoding(acceptEncoding);
if (encoding === "br") {
return { body: zlib.brotliCompressSync(body), encoding };
}
if (encoding === "gzip") {
return { body: zlib.gzipSync(body), encoding };
}
return { body, encoding: null };
}
function respondBuffer(res, statusCode, body, headers = {}, acceptEncoding = "") {
const { body: responseBody, encoding } = maybeCompressBody(body, headers, acceptEncoding);
res.writeHead(statusCode, { res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8", "content-length": responseBody.length,
vary: "accept-encoding",
...(encoding ? { "content-encoding": encoding } : {}),
...headers, ...headers,
}); });
res.end(JSON.stringify(payload)); res.end(responseBody);
}
function jsonResponse(req, res, statusCode, payload, headers = {}) {
const body = Buffer.from(JSON.stringify(payload));
respondBuffer(
res,
statusCode,
body,
{
"content-type": "application/json; charset=utf-8",
...headers,
},
req?.headers?.["accept-encoding"] || "",
);
} }
@@ -1794,26 +1862,31 @@ function safeJoinStatic(root, requestPath) {
return fullPath; return fullPath;
} }
async function serveStaticFile(res, filePath) { async function serveStaticFile(req, res, filePath) {
try { try {
const body = await readFile(filePath); const body = await readFile(filePath);
res.writeHead(200, { respondBuffer(
"content-type": contentTypeForFile(filePath), res,
"cache-control": filePath.includes(`${path.sep}assets${path.sep}`) 200,
? "public, max-age=31536000, immutable" body,
: "no-cache", {
}); "content-type": contentTypeForFile(filePath),
res.end(body); "cache-control": filePath.includes(`${path.sep}assets${path.sep}`)
? "public, max-age=31536000, immutable"
: "no-cache",
},
req?.headers?.["accept-encoding"] || "",
);
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
async function serveManagementUi(res, requestPathname) { async function serveManagementUi(req, res, requestPathname) {
const uiPrefix = `${UI_PATH}/`; const uiPrefix = `${UI_PATH}/`;
if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) { if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) {
return serveStaticFile(res, path.join(UI_STATIC_ROOT, "index.html")); return serveStaticFile(req, res, path.join(UI_STATIC_ROOT, "index.html"));
} }
if (!requestPathname.startsWith(uiPrefix)) { if (!requestPathname.startsWith(uiPrefix)) {
@@ -1822,7 +1895,7 @@ async function serveManagementUi(res, requestPathname) {
const staticPath = safeJoinStatic(UI_STATIC_ROOT, requestPathname.slice(uiPrefix.length)); const staticPath = safeJoinStatic(UI_STATIC_ROOT, requestPathname.slice(uiPrefix.length));
if (!staticPath) { if (!staticPath) {
jsonResponse(res, 403, { jsonResponse(req, res, 403, {
error: { error: {
message: "invalid static path", message: "invalid static path",
code: "invalid_static_path", code: "invalid_static_path",
@@ -1831,11 +1904,11 @@ async function serveManagementUi(res, requestPathname) {
return true; return true;
} }
if (await serveStaticFile(res, staticPath)) { if (await serveStaticFile(req, res, staticPath)) {
return true; return true;
} }
return serveStaticFile(res, path.join(UI_STATIC_ROOT, "index.html")); return serveStaticFile(req, res, path.join(UI_STATIC_ROOT, "index.html"));
} }
function buildEditableConfig(currentConfig, payload) { function buildEditableConfig(currentConfig, payload) {
@@ -1913,8 +1986,8 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const pathname = normalizePath(requestUrl.pathname); const pathname = normalizePath(requestUrl.pathname);
if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) { if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) {
if (!(await serveManagementUi(res, requestUrl.pathname))) { if (!(await serveManagementUi(req, res, requestUrl.pathname))) {
jsonResponse(res, 503, { jsonResponse(req, res, 503, {
error: { error: {
message: "UI assets were not built. Run: npm run build:ui", message: "UI assets were not built. Run: npm run build:ui",
code: "ui_not_built", code: "ui_not_built",
@@ -1926,7 +1999,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
if (pathname === STATUS_API_PATH && req.method === "GET") { if (pathname === STATUS_API_PATH && req.method === "GET") {
const state = await readRuntimeState(runtime); const state = await readRuntimeState(runtime);
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`, listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`,
config: sanitizeConfigForStatus(runtime.config), config: sanitizeConfigForStatus(runtime.config),
@@ -1949,7 +2022,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const limitRaw = requestUrl.searchParams.get("limit"); const limitRaw = requestUrl.searchParams.get("limit");
const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10); const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10);
const limit = limitRaw === null ? 500 : Number.parseInt(limitRaw, 10); const limit = limitRaw === null ? 500 : Number.parseInt(limitRaw, 10);
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
...await buildPersistentLogsSnapshot( ...await buildPersistentLogsSnapshot(
runtime, runtime,
@@ -1967,7 +2040,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const filter = requestUrl.searchParams.get("filter") || "all"; const filter = requestUrl.searchParams.get("filter") || "all";
const limit = limitRaw === null ? 50 : Number.parseInt(limitRaw, 10); const limit = limitRaw === null ? 50 : Number.parseInt(limitRaw, 10);
const offset = offsetRaw === null ? 0 : Number.parseInt(offsetRaw, 10); const offset = offsetRaw === null ? 0 : Number.parseInt(offsetRaw, 10);
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
...await buildPersistentRequestsSnapshot(runtime, { ...await buildPersistentRequestsSnapshot(runtime, {
limit: Number.isInteger(limit) ? limit : 50, limit: Number.isInteger(limit) ? limit : 50,
@@ -1980,7 +2053,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
} }
if (pathname === PROFILES_API_PATH && req.method === "GET") { if (pathname === PROFILES_API_PATH && req.method === "GET") {
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
profiles_dir: runtime.paths.profilesDir, profiles_dir: runtime.paths.profilesDir,
active_profile: runtime.config.profile_name || "default", active_profile: runtime.config.profile_name || "default",
@@ -1993,7 +2066,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
const payload = parseJsonSafely(body); const payload = parseJsonSafely(body);
if (!payload) { if (!payload) {
jsonResponse(res, 400, { jsonResponse(req, res, 400, {
error: { error: {
message: "profile 保存请求必须是有效 JSON", message: "profile 保存请求必须是有效 JSON",
code: "invalid_json", code: "invalid_json",
@@ -2008,7 +2081,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
applied = await applyProfileConfig(runtime, result.name); applied = await applyProfileConfig(runtime, result.name);
} }
runtime.logger(`[profile] saved name=${result.name} path=${result.file_path}`); runtime.logger(`[profile] saved name=${result.name} path=${result.file_path}`);
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
message: applied ? "profile 已保存并已热应用" : "profile 已保存", message: applied ? "profile 已保存并已热应用" : "profile 已保存",
saved_profile: result, saved_profile: result,
@@ -2024,7 +2097,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
const payload = parseJsonSafely(body); const payload = parseJsonSafely(body);
if (!payload) { if (!payload) {
jsonResponse(res, 400, { jsonResponse(req, res, 400, {
error: { error: {
message: "profile probe 请求必须是有效 JSON", message: "profile probe 请求必须是有效 JSON",
code: "invalid_json", code: "invalid_json",
@@ -2037,7 +2110,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
runtime.logger( runtime.logger(
`[profile-probe] profile=${result.profile} auth=${result.auth_mode}/${result.auth_source} upstream=${result.upstream_base_url}`, `[profile-probe] profile=${result.profile} auth=${result.auth_mode}/${result.auth_source} upstream=${result.upstream_base_url}`,
); );
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
...result, ...result,
}); });
@@ -2049,7 +2122,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const payload = parseJsonSafely(body); const payload = parseJsonSafely(body);
const profileName = `${payload?.profile || ""}`.trim(); const profileName = `${payload?.profile || ""}`.trim();
if (!profileName) { if (!profileName) {
jsonResponse(res, 400, { jsonResponse(req, res, 400, {
error: { error: {
message: "缺少 profile", message: "缺少 profile",
code: "profile_required", code: "profile_required",
@@ -2059,7 +2132,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
} }
const result = await applyProfileConfig(runtime, profileName); const result = await applyProfileConfig(runtime, profileName);
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
message: "profile 已热切换,无需重启 gateway", message: "profile 已热切换,无需重启 gateway",
...result, ...result,
@@ -2070,7 +2143,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
if (pathname.startsWith(PROFILE_ITEM_API_PREFIX) && req.method === "DELETE") { if (pathname.startsWith(PROFILE_ITEM_API_PREFIX) && req.method === "DELETE") {
const rawName = pathname.slice(PROFILE_ITEM_API_PREFIX.length); const rawName = pathname.slice(PROFILE_ITEM_API_PREFIX.length);
if (!rawName || rawName.includes("/")) { if (!rawName || rawName.includes("/")) {
jsonResponse(res, 400, { jsonResponse(req, res, 400, {
error: { error: {
message: "无效的 profile 名称", message: "无效的 profile 名称",
code: "invalid_profile_name", code: "invalid_profile_name",
@@ -2082,7 +2155,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const profileName = decodeURIComponent(rawName); const profileName = decodeURIComponent(rawName);
const result = await deleteProfile(runtime, profileName); const result = await deleteProfile(runtime, profileName);
runtime.logger(`[profile] deleted name=${result.name} path=${result.file_path}`); runtime.logger(`[profile] deleted name=${result.name} path=${result.file_path}`);
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
message: "profile 已删除", message: "profile 已删除",
deleted_profile: result, deleted_profile: result,
@@ -2097,7 +2170,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
const payload = parseJsonSafely(body); const payload = parseJsonSafely(body);
if (!payload) { if (!payload) {
jsonResponse(res, 400, { jsonResponse(req, res, 400, {
error: { error: {
message: "配置保存请求必须是有效 JSON", message: "配置保存请求必须是有效 JSON",
code: "invalid_json", code: "invalid_json",
@@ -2113,7 +2186,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
`[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, `[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} endpoints=${nextConfig.endpoints.join(",")}`,
); );
const state = await readRuntimeState(runtime); const state = await readRuntimeState(runtime);
jsonResponse(res, 200, { jsonResponse(req, res, 200, {
ok: true, ok: true,
message: "配置已保存并立即生效", message: "配置已保存并立即生效",
config: sanitizeConfigForStatus(runtime.config), config: sanitizeConfigForStatus(runtime.config),
@@ -2132,7 +2205,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
if (pathname === RESTORE_API_PATH && req.method === "POST") { if (pathname === RESTORE_API_PATH && req.method === "POST") {
const state = await readRuntimeState(runtime); const state = await readRuntimeState(runtime);
if (!state) { if (!state) {
jsonResponse(res, 409, { jsonResponse(req, res, 409, {
error: { error: {
message: "当前未检测到安装状态,无法恢复 Codex 原设置", message: "当前未检测到安装状态,无法恢复 Codex 原设置",
code: "state_not_found", code: "state_not_found",
@@ -2143,7 +2216,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
await restoreRuntimeState(runtime, state); await restoreRuntimeState(runtime, state);
runtime.logger(`[restore] restored via UI state_root=${runtime.paths.stateRoot}`); runtime.logger(`[restore] restored via UI state_root=${runtime.paths.stateRoot}`);
jsonResponse(res, 202, { jsonResponse(req, res, 202, {
ok: true, ok: true,
message: "原设置已恢复,gateway 即将关闭", message: "原设置已恢复,gateway 即将关闭",
}); });
@@ -2366,6 +2439,18 @@ function findRetryableUpstreamErrorMatch(config, upstreamStatusCode, parsedBody,
return null; return null;
} }
return matchRetryableMessage(config, parsedBody, bodyText);
}
function matchRetryableMessage(config, parsedBody, bodyText) {
const retryableMessages = normalizePhraseList(
config.retryable_error_messages,
DEFAULT_CONFIG.retryable_error_messages,
);
if (retryableMessages.length === 0) {
return null;
}
const normalizedPatterns = retryableMessages.map((message) => ({ const normalizedPatterns = retryableMessages.map((message) => ({
original: message, original: message,
normalized: message.toLowerCase(), normalized: message.toLowerCase(),
@@ -2395,6 +2480,32 @@ function findRetryableUpstreamErrorMatch(config, upstreamStatusCode, parsedBody,
return null; return null;
} }
function isRetryableStreamErrorShape(parsedBody, eventName = "") {
if (`${eventName || ""}`.trim().toLowerCase() === "error") {
return true;
}
if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
return false;
}
if (Object.hasOwn(parsedBody, "error")) {
return true;
}
if (typeof parsedBody.type === "string" && parsedBody.type.toLowerCase().includes("error")) {
return true;
}
if (typeof parsedBody.event === "string" && parsedBody.event.toLowerCase().includes("error")) {
return true;
}
return Number.isInteger(parsedBody.status) && parsedBody.status >= 400;
}
function findRetryableStreamErrorMatch(config, parsedBody, bodyText, eventName = "") {
if (!isRetryableStreamErrorShape(parsedBody, eventName)) {
return null;
}
return matchRetryableMessage(config, parsedBody, bodyText);
}
function isExpectedStreamTermination(error) { function isExpectedStreamTermination(error) {
if (!error) { if (!error) {
return false; return false;
@@ -2535,13 +2646,14 @@ async function fetchUpstreamWithRetry(upstreamUrl, init, config, logger, request
}; };
} }
function inspectSseChunk(state, chunk) { function inspectSseChunk(state, chunk, config) {
const decoded = state.decoder.decode(chunk, { stream: true }); const decoded = state.decoder.decode(chunk, { stream: true });
state.buffer += decoded; state.buffer += decoded;
const result = { const result = {
reasoning: null, reasoning: null,
usage: null, usage: null,
retryable_upstream_error: null,
}; };
const blocks = state.buffer.split(/\r?\n\r?\n/); const blocks = state.buffer.split(/\r?\n\r?\n/);
@@ -2552,6 +2664,10 @@ function inspectSseChunk(state, chunk) {
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trimEnd()) .map((line) => line.trimEnd())
.filter(Boolean); .filter(Boolean);
const eventName = lines
.filter((line) => line.startsWith("event:"))
.map((line) => line.replace(/^event:\s?/, "").trim())
.find(Boolean) || "";
const dataLines = lines const dataLines = lines
.filter((line) => line.startsWith("data:")) .filter((line) => line.startsWith("data:"))
.map((line) => line.replace(/^data:\s?/, "")); .map((line) => line.replace(/^data:\s?/, ""));
@@ -2563,8 +2679,9 @@ function inspectSseChunk(state, chunk) {
if (payloadText === "[DONE]") { if (payloadText === "[DONE]") {
continue; continue;
} }
let parsed = null;
try { try {
const parsed = JSON.parse(payloadText); parsed = JSON.parse(payloadText);
const reasoning = extractReasoningTokens(parsed); const reasoning = extractReasoningTokens(parsed);
if (reasoning !== null) { if (reasoning !== null) {
result.reasoning = reasoning; result.reasoning = reasoning;
@@ -2573,6 +2690,15 @@ function inspectSseChunk(state, chunk) {
} catch { } catch {
// ignore malformed SSE payloads // ignore malformed SSE payloads
} }
const retryableUpstreamError = findRetryableStreamErrorMatch(
config,
parsed,
payloadText,
eventName,
);
if (retryableUpstreamError) {
result.retryable_upstream_error = retryableUpstreamError;
}
} }
return result; return result;
} }
@@ -2764,8 +2890,27 @@ async function handleStreaming({
const chunkBuffer = Buffer.from(value); const chunkBuffer = Buffer.from(value);
const now = new Date(); const now = new Date();
const inspection = inspectSseChunk(sseState, value, config);
const retryableUpstreamError = inspection.retryable_upstream_error;
if (retryableUpstreamError) {
abortController.abort();
reader.cancel().catch(() => {});
return {
inspected: true,
matched: true,
retry_requested: strict502Mode || !wroteAnyChunk,
retryable_upstream_error: retryableUpstreamError,
upstream_status_code: upstreamResponse.status,
reasoning_tokens: observedReasoning,
usage: observedUsage,
error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`,
match_reason: "retryable_upstream_error",
response_bytes_received: requestEntry.response_bytes_received,
stream_chunk_count: requestEntry.stream_chunk_count,
};
}
markAndPersistFirstResponse(runtime, requestEntry, now); markAndPersistFirstResponse(runtime, requestEntry, now);
const inspection = inspectSseChunk(sseState, value);
const reasoning = inspection.reasoning; const reasoning = inspection.reasoning;
const usageUpdated = Boolean(inspection.usage); const usageUpdated = Boolean(inspection.usage);
observedUsage = mergeUsageSnapshots(observedUsage, inspection.usage); observedUsage = mergeUsageSnapshots(observedUsage, inspection.usage);
@@ -2830,6 +2975,10 @@ async function handleStreaming({
async function proxyRequest(runtime, req, res) { async function proxyRequest(runtime, req, res) {
const { logger } = runtime; const { logger } = runtime;
const config = runtime.config; const config = runtime.config;
const maxUpstreamAttempts = normalizePositiveInteger(
config.upstream_fetch_retry_attempts,
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
);
const requestStartedAt = new Date(); const requestStartedAt = new Date();
const requestStartedMs = Date.now(); const requestStartedMs = Date.now();
const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
@@ -2880,6 +3029,7 @@ async function proxyRequest(runtime, req, res) {
const { requestJson, remapped, forwardedModel } = remapRequestModel(config, parsedRequestJson); const { requestJson, remapped, forwardedModel } = remapRequestModel(config, parsedRequestJson);
const requestBody = remapped ? Buffer.from(JSON.stringify(requestJson)) : rawRequestBody; const requestBody = remapped ? Buffer.from(JSON.stringify(requestJson)) : rawRequestBody;
const requestIsStream = Boolean(requestJson?.stream); const requestIsStream = Boolean(requestJson?.stream);
let totalUpstreamAttempts = 0;
requestEntry.request_body_bytes = rawRequestBody.length; requestEntry.request_body_bytes = rawRequestBody.length;
requestEntry.model = requestJson?.model || null; requestEntry.model = requestJson?.model || null;
requestEntry.requested_model = parsedRequestJson?.model || null; requestEntry.requested_model = parsedRequestJson?.model || null;
@@ -2889,7 +3039,6 @@ async function proxyRequest(runtime, req, res) {
upsertRequestEntry(runtime, requestEntry); upsertRequestEntry(runtime, requestEntry);
const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl);
const abortController = new AbortController();
const upstreamAuth = await resolveUpstreamAuth(config); const upstreamAuth = await resolveUpstreamAuth(config);
requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth }); requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth });
upsertRequestEntry(runtime, requestEntry); upsertRequestEntry(runtime, requestEntry);
@@ -2899,57 +3048,129 @@ async function proxyRequest(runtime, req, res) {
); );
} }
const { while (totalUpstreamAttempts < maxUpstreamAttempts) {
response: upstreamResponse, const abortController = new AbortController();
attempt_count: upstreamAttemptCount, const {
retryable_upstream_error: terminalRetryableUpstreamError, response: upstreamResponse,
} = await fetchUpstreamWithRetry(upstreamUrl, { attempt_count: upstreamAttemptCount,
method: req.method, retryable_upstream_error: terminalRetryableUpstreamError,
headers: cloneHeadersForUpstream(req.headers, upstreamAuth), } = await fetchUpstreamWithRetry(upstreamUrl, {
body: requestBody.length > 0 ? requestBody : undefined, method: req.method,
signal: abortController.signal, headers: cloneHeadersForUpstream(req.headers, upstreamAuth),
}, config, logger, { method: req.method, pathname }); body: requestBody.length > 0 ? requestBody : undefined,
signal: abortController.signal,
}, {
...config,
upstream_fetch_retry_attempts: Math.max(1, maxUpstreamAttempts - totalUpstreamAttempts),
}, logger, { method: req.method, pathname });
const shouldInspect = matchPath(config, pathname); totalUpstreamAttempts += upstreamAttemptCount;
const responseContentType = upstreamResponse.headers.get("content-type");
const responseIsStream = isSseContentType(responseContentType) || ( const shouldInspect = matchPath(config, pathname);
requestIsStream && const responseContentType = upstreamResponse.headers.get("content-type");
!isJsonContentType(responseContentType) && const responseIsStream = isSseContentType(responseContentType) || (
!isUpstreamErrorStatus(upstreamResponse.status) requestIsStream &&
); !isJsonContentType(responseContentType) &&
requestEntry.response_stream = responseIsStream; !isUpstreamErrorStatus(upstreamResponse.status)
requestEntry.inspected = shouldInspect;
requestEntry.upstream_status_code = upstreamResponse.status;
requestEntry.upstream_attempt_count = upstreamAttemptCount;
requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth, upstreamResponse });
upsertRequestEntry(runtime, requestEntry);
if (isUpstreamErrorStatus(upstreamResponse.status)) {
logger?.(
`[upstream] status=${upstreamResponse.status} profile=${config.profile_name || "default"} path=${requestEntry.upstream.path} auth=${requestEntry.upstream.auth_mode}/${requestEntry.upstream.auth_source} content_type=${requestEntry.upstream.content_type || "-"}`,
); );
} requestEntry.response_stream = responseIsStream;
requestEntry.inspected = shouldInspect;
if (!shouldInspect) { requestEntry.upstream_status_code = upstreamResponse.status;
markRequestFirstResponse(requestEntry); requestEntry.upstream_attempt_count = totalUpstreamAttempts;
requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth, upstreamResponse });
upsertRequestEntry(runtime, requestEntry); upsertRequestEntry(runtime, requestEntry);
copyHeadersToClient(upstreamResponse.headers, res); if (isUpstreamErrorStatus(upstreamResponse.status)) {
res.writeHead(upstreamResponse.status); logger?.(
const body = Buffer.from(await upstreamResponse.arrayBuffer()); `[upstream] status=${upstreamResponse.status} profile=${config.profile_name || "default"} path=${requestEntry.upstream.path} auth=${requestEntry.upstream.auth_mode}/${requestEntry.upstream.auth_source} content_type=${requestEntry.upstream.content_type || "-"}`,
res.end(body); );
recordRequestEntry( }
runtime,
finalizeRequestEntry(requestEntry, {
status_code: upstreamResponse.status,
upstream_status_code: upstreamResponse.status,
inspected: false,
}),
config.request_history_limit,
);
return;
}
if (responseIsStream) { if (!shouldInspect) {
const result = await handleStreaming({ markRequestFirstResponse(requestEntry);
upsertRequestEntry(runtime, requestEntry);
copyHeadersToClient(upstreamResponse.headers, res);
res.writeHead(upstreamResponse.status);
const body = Buffer.from(await upstreamResponse.arrayBuffer());
res.end(body);
recordRequestEntry(
runtime,
finalizeRequestEntry(requestEntry, {
status_code: upstreamResponse.status,
upstream_status_code: upstreamResponse.status,
inspected: false,
}),
config.request_history_limit,
);
return;
}
if (responseIsStream) {
const result = await handleStreaming({
runtime,
config,
logger,
monitor: runtime.monitor,
pathname,
upstreamResponse,
res,
abortController,
requestEntry,
});
if (result.retry_requested) {
if (totalUpstreamAttempts < maxUpstreamAttempts) {
const backoffMs = computeRetryBackoffMs(config, totalUpstreamAttempts);
logger?.(
`[retry] upstream retryable stream error attempt=${totalUpstreamAttempts} next_attempt=${totalUpstreamAttempts + 1} status=${upstreamResponse.status} path=${pathname || "-"} reason=${JSON.stringify(result.retryable_upstream_error?.matched_pattern)} backoff_ms=${backoffMs}`,
);
if (backoffMs > 0) {
await sleep(backoffMs);
}
continue;
}
recordInspectedResponse(runtime.monitor, result.reasoning_tokens, true);
if (config.log_match) {
logger?.(
`[match] stream path=${pathname} upstream_status=${upstreamResponse.status} retryable_error=${JSON.stringify(result.retryable_upstream_error?.matched_pattern)} action=status_${config.non_stream_status_code}`,
);
}
const blockedBody = buildRetryableUpstreamErrorBody(
pathname,
upstreamResponse.status,
result.retryable_upstream_error?.matched_message || result.retryable_upstream_error?.matched_pattern,
config.non_stream_status_code,
);
res.writeHead(config.non_stream_status_code, {
"content-type": "application/json; charset=utf-8",
"x-codex-retry-gateway-reason": "upstream-error-retry-triggered",
});
res.end(blockedBody);
recordRequestEntry(
runtime,
finalizeRequestEntry(requestEntry, {
response_stream: true,
...result,
matched: true,
status_code: config.non_stream_status_code,
upstream_status_code: upstreamResponse.status,
}),
config.request_history_limit,
);
return;
}
recordRequestEntry(
runtime,
finalizeRequestEntry(requestEntry, {
response_stream: true,
...result,
}),
config.request_history_limit,
);
return;
}
const result = await handleNonStreaming({
runtime, runtime,
config, config,
logger, logger,
@@ -2957,43 +3178,22 @@ async function proxyRequest(runtime, req, res) {
pathname, pathname,
upstreamResponse, upstreamResponse,
res, res,
abortController,
requestEntry, requestEntry,
terminalRetryableUpstreamError,
}); });
recordRequestEntry( recordRequestEntry(
runtime, runtime,
finalizeRequestEntry(requestEntry, { finalizeRequestEntry(requestEntry, {
response_stream: true, response_stream: false,
...result, ...result,
}), }),
config.request_history_limit, config.request_history_limit,
); );
return; return;
} }
const result = await handleNonStreaming({
runtime,
config,
logger,
monitor: runtime.monitor,
pathname,
upstreamResponse,
res,
requestEntry,
terminalRetryableUpstreamError,
});
recordRequestEntry(
runtime,
finalizeRequestEntry(requestEntry, {
response_stream: false,
...result,
}),
config.request_history_limit,
);
return;
} catch (error) { } catch (error) {
if (Number.isInteger(error?.gatewayAttemptCount)) { if (Number.isInteger(error?.gatewayAttemptCount)) {
requestEntry.upstream_attempt_count = error.gatewayAttemptCount; requestEntry.upstream_attempt_count = (requestEntry.upstream_attempt_count || 0) + error.gatewayAttemptCount;
} }
recordRequestEntry( recordRequestEntry(
runtime, runtime,
+45
View File
@@ -80,6 +80,21 @@ function createTerminatedSseResponse(res, chunks, destroyDelayMs = 20) {
}, destroyDelayMs); }, destroyDelayMs);
} }
function createCapacityErrorSseResponse(
res,
message = "Selected model is at capacity. Please try a different model.",
intervalMs = 20,
) {
createSseResponse(
res,
[
'event: error\n',
`data: ${JSON.stringify({ error: { message, type: "server_error" } })}\n\n`,
],
intervalMs,
);
}
function startFakeUpstream(port) { function startFakeUpstream(port) {
const failBeforeResponseCounts = new Map(); const failBeforeResponseCounts = new Map();
const capacityBeforeSuccessCounts = new Map(); const capacityBeforeSuccessCounts = new Map();
@@ -143,6 +158,13 @@ function startFakeUpstream(port) {
const capacityCount = (capacityBeforeSuccessCounts.get(capacityKey) || 0) + 1; const capacityCount = (capacityBeforeSuccessCounts.get(capacityKey) || 0) + 1;
capacityBeforeSuccessCounts.set(capacityKey, capacityCount); capacityBeforeSuccessCounts.set(capacityKey, capacityCount);
if (capacityCount <= parsed.test_capacity_before_success_times) { if (capacityCount <= parsed.test_capacity_before_success_times) {
if (parsed.stream) {
createCapacityErrorSseResponse(
res,
parsed.test_capacity_message || "Selected model is at capacity. Please try a different model.",
);
return;
}
createJsonResponse( createJsonResponse(
res, res,
parsed.test_capacity_status ?? 503, parsed.test_capacity_status ?? 503,
@@ -157,6 +179,13 @@ function startFakeUpstream(port) {
return; return;
} }
} }
if (parsed.stream && parsed.test_capacity_error) {
createCapacityErrorSseResponse(
res,
parsed.test_capacity_message || "Selected model is at capacity. Please try a different model.",
);
return;
}
if (parsed.stream) { if (parsed.stream) {
createSseResponse(res, [ createSseResponse(res, [
'data: {"type":"response.output_text.delta","delta":"hello"}\n\n', 'data: {"type":"response.output_text.delta","delta":"hello"}\n\n',
@@ -434,6 +463,22 @@ async function run() {
"stream+capacity error 返回体未标记 retry trigger", "stream+capacity error 返回体未标记 retry trigger",
); );
const streamCapacityRecoveredResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ stream: true, test_capacity_before_success_times: 2, test_reasoning_tokens: 128 }),
});
const streamCapacityRecoveredText = await streamCapacityRecoveredResponse.text();
assert(streamCapacityRecoveredResponse.status === 200, `stream capacity 抖动后未自动恢复: ${streamCapacityRecoveredResponse.status}`);
assert(streamCapacityRecoveredText.includes("hello"), "stream capacity 恢复后未拿到正常 SSE 内容");
const requestsAfterStreamCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
const requestsAfterStreamCapacityRecovery = await requestsAfterStreamCapacityRecoveryResponse.json();
const streamCapacityRecoveredEntry = requestsAfterStreamCapacityRecovery?.entries?.find(
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
);
assert(streamCapacityRecoveredEntry, "stream capacity 抖动恢复后的请求记录未保留重试次数");
for (const streamPath of [ for (const streamPath of [
"/responses", "/responses",
"/v1/responses", "/v1/responses",
+16 -7
View File
@@ -39,6 +39,8 @@ type Metrics = {
cached_tokens?: number; cached_tokens?: number;
}; };
observed_reasoning_counts?: Record<string, number>; observed_reasoning_counts?: Record<string, number>;
observed_reasoning_counts_total_keys?: number;
observed_reasoning_counts_omitted?: number;
}; };
type StatusPayload = { type StatusPayload = {
@@ -222,7 +224,7 @@ const api = {
restore: "/__codex_retry_gateway/api/restore", restore: "/__codex_retry_gateway/api/restore",
}; };
const REQUEST_PAGE_SIZE = 40; const REQUEST_PAGE_SIZE = 20;
const LOG_PAGE_SIZE = 200; const LOG_PAGE_SIZE = 200;
const zhNumberFormatter = new Intl.NumberFormat("zh-CN"); const zhNumberFormatter = new Intl.NumberFormat("zh-CN");
@@ -666,7 +668,7 @@ export default function App() {
}, [page, requestQuery, requestFilter, latestLogSeq, requestLimit]); }, [page, requestQuery, requestFilter, latestLogSeq, requestLimit]);
useEffect(() => { useEffect(() => {
if (page === "overview" || page === "rules") { if (page === "overview" || page === "rules" || page === "requests") {
return; return;
} }
loadPageData(page, { incrementalLogs: false }).catch((error) => { loadPageData(page, { incrementalLogs: false }).catch((error) => {
@@ -997,11 +999,18 @@ export default function App() {
{reasoningChips.length === 0 ? ( {reasoningChips.length === 0 ? (
<span className="chip"> reasoning </span> <span className="chip"> reasoning </span>
) : ( ) : (
reasoningChips.map(([reasoning, count]) => ( <>
<span className="chip" key={reasoning}> {reasoningChips.map(([reasoning, count]) => (
reasoning {reasoning}: {count} <span className="chip" key={reasoning}>
</span> reasoning {reasoning}: {count}
)) </span>
))}
{(metrics.observed_reasoning_counts_omitted || 0) > 0 ? (
<span className="chip">
{metrics.observed_reasoning_counts_omitted}
</span>
) : null}
</>
)} )}
</div> </div>
</Card> </Card>