From f9498922d5f94f4ea9f2e9fac23e186b72d8576f Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 03:05:47 +0800 Subject: [PATCH 1/4] feat(retry): align retry policy with upstream session/retry.ts (default 3, cap 5) --- .env.example | 2 + CHANGELOG.md | 1 + README.md | 1 + config.json.example | 3 +- docs/configuration.md | 6 +- index.js | 3 + src/proxy.js | 140 +++++++++++++++++++++------- src/retry/policy.js | 92 ++++++++++++++++++ tests/backend-error-surface.test.js | 78 +++++++++++++++- tests/retry-policy.test.js | 81 ++++++++++++++++ 10 files changed, 371 insertions(+), 36 deletions(-) create mode 100644 src/retry/policy.js create mode 100644 tests/retry-policy.test.js diff --git a/.env.example b/.env.example index 96cb922..f35f580 100644 --- a/.env.example +++ b/.env.example @@ -27,5 +27,7 @@ OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 # OPENCODE_SERVER_PORT=10001 # OPENCODE_USE_ISOLATED_HOME=false # OPENCODE_PROXY_REQUEST_TIMEOUT_MS=180000 +# Retry budget after the initial attempt (0-5, default 3; total attempts = 1+n) +# OPENCODE_PROXY_RETRY_MAX_RETRIES=3 # OPENCODE_PROXY_DEBUG=false # OPENCODE_ZEN_API_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1570878..7264250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Anthropic Messages API**:新增 `POST /v1/messages`(`max_tokens` 必填,支持 `system`/`tools(input_schema)`/`tool_choice{auto,any,tool,none}`/`thinking→reasoning`/`image`;`tool_use.id` 原样往返;非流式回 `message` 对象,流式回 `message_start/content_block_*/message_delta/message_stop` 无 `[DONE]`;认证同时支持 `x-api-key`;CORS 放行 `x-api-key/anthropic-version`)。新增 `src/converters/anthropic.js` 纯函数转换层与 `tests/messages-anthropic.test.js`(9 例)。附带修复 `EXTERNAL_TOOL_PREFIX` 缺 import 的 latent `ReferenceError`。 +- **官方对齐重试策略**:移植上游 `session/retry.ts`(`2s×2ⁿ⁻¹` 指数退避 +25% jitter;优先 `retry-after-ms`/`retry-after`,header 等待 clamp 30s;`5xx` 强制、`isRetryable`、`ContextOverflow` 永不)。重试次数 `n` 经 `OPENCODE_PROXY_RETRY_MAX_RETRIES` 控制(默认 3,总尝试 1+n,上限对齐官方 5)。chat 双路由与 messages 非串流改用新退避;responses 非串流新增同结构重试迴圈(仅无产出且 transient 时;responses/messages 串流保持单次尝试)。新增 `src/retry/policy.js` 与 `tests/retry-policy.test.js`(11 例)及接线测试 4 例。 ### Fixed diff --git a/README.md b/README.md index 368bc46..35f5651 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ curl -X POST http://127.0.0.1:10000/v1/messages \ | `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒) | | `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒) | | `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒) | +| `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`) | | `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode 后端地址 | | `OPENCODE_SERVER_PASSWORD` | - | OpenCode 后端密码 | | `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 | diff --git a/config.json.example b/config.json.example index c00cdec..d467a4c 100644 --- a/config.json.example +++ b/config.json.example @@ -14,5 +14,6 @@ "DEBUG": false, "OPENCODE_SERVER_URL": "http://127.0.0.1:10001", "OPENCODE_PATH": "opencode", - "REQUEST_TIMEOUT_MS": 180000 + "REQUEST_TIMEOUT_MS": 180000, + "RETRY_MAX_RETRIES": 3 } diff --git a/docs/configuration.md b/docs/configuration.md index a84231f..51abd41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,6 +47,9 @@ | `CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒) | | `CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒) | | `REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒) | +| `RETRY_MAX_RETRIES` / `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`) | + +> 重试退避移植自上游 `session/retry.ts`(`2s×2ⁿ⁻¹` +25% jitter),但 `retry-after` 等待 clamp 在 30s(上游近无界;网关面对自带超时的客户端不宜久睡)。旧部署注意:默认总尝试由 3 次变为 1+3=4 次,如需接近旧次数可设 `2`。 ### 调试配置 @@ -81,7 +84,8 @@ "DEBUG": false, "OPENCODE_SERVER_URL": "http://127.0.0.1:10001", "OPENCODE_PATH": "opencode", - "REQUEST_TIMEOUT_MS": 180000 + "REQUEST_TIMEOUT_MS": 180000, + "RETRY_MAX_RETRIES": 3 } ``` diff --git a/index.js b/index.js index 831e316..514f523 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ import { startProxy } from './src/proxy.js'; +import { resolveMaxRetries } from './src/retry/policy.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -91,6 +92,7 @@ const finalConfig = { METRICS_REQUIRE_AUTH: parseBool(process.env.OPENCODE_METRICS_REQUIRE_AUTH, parseBool(fileConfig.METRICS_REQUIRE_AUTH, defaultConfig.METRICS_REQUIRE_AUTH)), USE_ISOLATED_HOME: parseBool(process.env.OPENCODE_USE_ISOLATED_HOME, parseBool(fileConfig.USE_ISOLATED_HOME, false)), REQUEST_TIMEOUT_MS: parseInt(process.env.OPENCODE_PROXY_REQUEST_TIMEOUT_MS) || fileConfig.REQUEST_TIMEOUT_MS || 180000, + RETRY_MAX_RETRIES: process.env.OPENCODE_PROXY_RETRY_MAX_RETRIES ?? fileConfig.RETRY_MAX_RETRIES ?? 3, DEBUG: parseBool(process.env.OPENCODE_PROXY_DEBUG, parseBool(fileConfig.DEBUG, false)), ZEN_API_KEY: process.env.OPENCODE_ZEN_API_KEY || fileConfig.ZEN_API_KEY || '', PROMPT_MODE: process.env.OPENCODE_PROXY_PROMPT_MODE || fileConfig.PROMPT_MODE || defaultConfig.PROMPT_MODE, @@ -140,6 +142,7 @@ console.log(` - Metrics Enabled: ${finalConfig.METRICS_ENABLED ? 'Yes' : 'No'}` console.log(` - Metrics Require Auth: ${finalConfig.METRICS_REQUIRE_AUTH ? 'Yes' : 'No'}`); console.log(` - Use Isolated Home: ${finalConfig.USE_ISOLATED_HOME ? 'Yes' : 'No'}`); console.log(` - Request Timeout: ${finalConfig.REQUEST_TIMEOUT_MS}ms`); +console.log(` - Max Retries: ${resolveMaxRetries(finalConfig.RETRY_MAX_RETRIES)} (raw: ${finalConfig.RETRY_MAX_RETRIES}, total attempts 1+n)`); console.log(` - Prompt Mode: ${finalConfig.PROMPT_MODE}`); console.log(` - Omit System Prompt: ${finalConfig.OMIT_SYSTEM_PROMPT ? 'Yes' : 'No'}`); console.log(` - Auto Cleanup Conversations: ${finalConfig.AUTO_CLEANUP_CONVERSATIONS ? 'Yes' : 'No'}`); diff --git a/src/proxy.js b/src/proxy.js index 04fb585..9f7113e 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -11,6 +11,7 @@ import path from 'path'; import os from 'os'; import { buildExternalToolRegistry, findExternalToolByName } from './tool-runtime/registry.js'; import { EXTERNAL_TOOL_PREFIX } from './tool-runtime/contracts.js'; +import { resolveMaxRetries, computeRetryDelay } from './retry/policy.js'; import { validateMessagesRequest, extractSystemText, @@ -55,6 +56,14 @@ function isTransientUpstreamError(error) { .join(' '); if (!message) return false; + // Upstream opencode never retries context overflow (retry.ts); neither do we. + // A 500 carrying a context message must surface immediately, not burn retries. + if (/context.?length|context.?overflow|context_length_exceeded/i.test(message)) return false; + + // Provider-marked retryable (SDK ApiError.data.isRetryable) is transient, + // whatever the status code. Explicit false defers to the checks below. + if (error.isRetryable === true || error.data?.isRetryable === true) return true; + const transientSignatures = [ /insufficient balance/i, /credits?error/i, @@ -66,7 +75,25 @@ function isTransientUpstreamError(error) { /internal server error/i, /bad gateway/i, /service unavailable/i, - /stream error/i + /stream error/i, + // Transport-layer flakes (upstream retry.ts message patterns): the main + // reason opencode "retries many times". + /terminated/i, + /fetch failed/i, + /network error/i, + /upstream connect/i, + /econnreset/i, + /etimedout/i, + /eai_again/i, + /getaddrinfo/i, + /socket hang up/i, + /zlib error/i, + /header.?timeout/i, + /try again (?:later|in\b)/i, + /try your request again/i, + /retry your request/i, + /resource exhausted/i, + /(?:currently|temporarily) at capacity/i ]; if (transientSignatures.some((re) => re.test(message))) return true; @@ -125,6 +152,13 @@ function normalizeBackendError(raw) { err.code = raw?.code ?? raw?.type ?? raw?.name ?? 'upstream_error'; err.type = raw?.type ?? raw?.code ?? raw?.name ?? 'upstream_error'; if (raw?.data !== undefined) err.data = raw.data; + // Preserve provider retry signals for the retry policy (see src/retry/policy.js): + // responseHeaders (retry-after-ms / retry-after) and isRetryable live on + // ApiError.data in the SDK type union. + const responseHeaders = raw?.responseHeaders ?? raw?.data?.responseHeaders ?? null; + if (responseHeaders && typeof responseHeaders === 'object') err.responseHeaders = responseHeaders; + if (typeof raw?.isRetryable === 'boolean') err.isRetryable = raw.isRetryable; + else if (typeof raw?.data?.isRetryable === 'boolean') err.isRetryable = raw.data.isRetryable; err.cause = raw; return err; } @@ -309,9 +343,10 @@ const STARTING_WAIT_ITERATIONS = 120; const STARTING_WAIT_INTERVAL_MS = 1000; const DEFAULT_REQUEST_TIMEOUT_MS = 300000; const DEFAULT_POLL_INTERVAL_MS = 500; -// Backoff base for transient upstream error retries (issue #5): 800ms, 1600ms. -const RETRY_BACKOFF_BASE_MS = 800; -const RETRY_MAX_ATTEMPTS = 3; +// Retry policy (ported from upstream opencode session/retry.ts; see src/retry/policy.js). +// Total attempts = 1 + maxRetries; maxRetries defaults to 3, configurable via +// OPENCODE_PROXY_RETRY_MAX_RETRIES, hard-capped at 5 (upstream ceiling). +// Delays are exponential with jitter and honor retry-after headers. // Reasoning models can take well over 10s before emitting their first token. // A short window here makes the event stream give up and fall back to polling on // every request, which loses true streaming. Configurable for slow backends. @@ -593,6 +628,10 @@ export function createApp(config) { OPENCODE_HOME_BASE } = config; + // Effective retry budget: total attempts = 1 + maxRetries. + const maxRetries = resolveMaxRetries(config.RETRY_MAX_RETRIES); + const maxAttempts = maxRetries + 1; + const app = express(); app.use(cors({ origin: '*', @@ -1898,7 +1937,8 @@ export function createApp(config) { }; let collected = null; - for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt += 1) { + let lastStreamAttemptError = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (attempt > 1) { // Retry on a fresh session: the failed attempt left an errored // assistant message in the old one, and re-prompting the same @@ -1921,7 +1961,7 @@ export function createApp(config) { streamedToolCalls.length = 0; completionTokens = 0; reasoningTokens = 0; - await sleep(RETRY_BACKOFF_BASE_MS * attempt); + await sleep(computeRetryDelay(attempt - 1, lastStreamAttemptError)); } try { const collectPromise = collectFromEvents( @@ -1945,10 +1985,11 @@ export function createApp(config) { if ( attemptError && nothingStreamed - && attempt < RETRY_MAX_ATTEMPTS + && attempt < maxAttempts && isTransientUpstreamError(attemptError) ) { - console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${RETRY_MAX_ATTEMPTS}), retrying:`, attemptError.data?.message || attemptError.message || attemptError.name || 'unknown'); + console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, attemptError.data?.message || attemptError.message || attemptError.name || 'unknown'); + lastStreamAttemptError = attemptError; continue; } break; @@ -2105,7 +2146,7 @@ export function createApp(config) { let content = ''; let reasoning = ''; let error = null; - for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt += 1) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (attempt > 1) { // Retry on a fresh session: the failed attempt left an errored // assistant message in the old one, and re-prompting the same @@ -2120,7 +2161,7 @@ export function createApp(config) { if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); promptParams.path.id = sessionId; requestForcedChatToolCall = makeForcedChatToolCallRequester(); - await sleep(RETRY_BACKOFF_BASE_MS * attempt); + await sleep(computeRetryDelay(attempt - 1, error)); } const attemptStart = Date.now(); await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); @@ -2131,15 +2172,15 @@ export function createApp(config) { error = collected.error || null; // Bounded retry for upstream throttling mislabeled as billing // errors (401 CreditsError etc.); only when nothing usable was - // produced, so real failures still surface after RETRY_MAX_ATTEMPTS. + // produced, so real failures still surface after maxAttempts. if ( error && !content && !reasoning - && attempt < RETRY_MAX_ATTEMPTS + && attempt < maxAttempts && isTransientUpstreamError(error) ) { - console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${RETRY_MAX_ATTEMPTS}), retrying:`, error.data?.message || error.message || error.name || 'unknown'); + console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, error.data?.message || error.message || error.name || 'unknown'); continue; } break; @@ -2542,17 +2583,19 @@ export function createApp(config) { internalToolContext.allowedToolNames ); - const requestForcedResponsesToolCall = createForcedToolCallRequester({ + const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); + const makeForcedResponsesToolCallRequester = () => createForcedToolCallRequester({ mode: externalToolChoice.mode, sessionId, systemWithGuard, requiredTool: externalToolChoice.requiredTool || externalToolRegistry[0]?.namespacedName, providerID: pID, modelID: mID, - toolOverrides: await getToolOverridesForMode(toolMode, internalToolContext), + toolOverrides, requestTimeoutMs: REQUEST_TIMEOUT_MS, forbidThinkBlock: false }); + let requestForcedResponsesToolCall = makeForcedResponsesToolCallRequester(); const promptParams = { path: { id: sessionId }, @@ -2567,7 +2610,6 @@ export function createApp(config) { ...(top_p !== undefined && { top_p }) } }; - const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); if (toolOverrides && Object.keys(toolOverrides).length > 0) { promptParams.body.tools = toolOverrides; } @@ -2929,26 +2971,58 @@ export function createApp(config) { return res.end(); } - const responseRes = await client.session.prompt(promptParams); - const responseParts = responseRes.data?.parts || []; - const promptContent = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n'); - const promptReasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n'); - const promptParsedToolCalls = externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) - : []; - - content = promptParsedToolCalls.length > 0 ? '' : promptContent; - reasoning = promptReasoning; - - let promptBasedToolCalls = promptParsedToolCalls; - const shouldPollForResponses = !promptContent && !promptReasoning; - if (shouldPollForResponses) { + let responseRes = null; + let responseParts = []; + let promptContent = ''; + let promptReasoning = ''; + let promptParsedToolCalls = []; + let polledFilled = false; + let lastResponsesAttemptError = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (attempt > 1) { + // Same fresh-session rotation as chat: the failed attempt left an + // errored assistant message behind, and re-prompting it would + // append a duplicate user turn to the context. + try { await client.session.delete({ path: { id: sessionId } }); } catch (e) { + logDebug('Failed to delete retried session', { sessionId, error: e.message }); + } + const retrySessionRes = await client.session.create(); + sessionId = retrySessionRes.data?.id; + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); + promptParams.path.id = sessionId; + requestForcedResponsesToolCall = makeForcedResponsesToolCallRequester(); + await sleep(computeRetryDelay(attempt - 1, lastResponsesAttemptError)); + } + responseRes = await client.session.prompt(promptParams); + responseParts = responseRes.data?.parts || []; + promptContent = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n'); + promptReasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n'); + promptParsedToolCalls = externalToolRegistry.length > 0 + ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) + : []; + if (promptContent || promptReasoning) break; const polledResponse = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); if (polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { + if (attempt < maxAttempts && isTransientUpstreamError(polledResponse.error)) { + console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, polledResponse.error.data?.message || polledResponse.error.message || polledResponse.error.name || 'unknown'); + lastResponsesAttemptError = polledResponse.error; + continue; + } throw normalizeBackendError(polledResponse.error); } content = polledResponse.content || content; reasoning = polledResponse.reasoning || reasoning; + polledFilled = true; + break; + } + + if (!polledFilled) { + content = promptParsedToolCalls.length > 0 ? '' : promptContent; + reasoning = promptReasoning; + } + + let promptBasedToolCalls = promptParsedToolCalls; + if (polledFilled) { promptBasedToolCalls = externalToolRegistry.length > 0 ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) : []; @@ -3205,21 +3279,21 @@ export function createApp(config) { let content = ''; let reasoning = ''; let error = null; - for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt += 1) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (attempt > 1) { try { await client.session.delete({ path: { id: sessionId } }); } catch {} const r = await client.session.create(); sessionId = r.data?.id; promptParams.path.id = sessionId; requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); - await sleep(RETRY_BACKOFF_BASE_MS * attempt); + await sleep(computeRetryDelay(attempt - 1, error)); } await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); content = collected.content || ''; reasoning = collected.reasoning || ''; error = collected.error || null; - if (error && !content && !reasoning && attempt < RETRY_MAX_ATTEMPTS && isTransientUpstreamError(error)) continue; + if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) continue; break; } if (error && !content && !reasoning) { diff --git a/src/retry/policy.js b/src/retry/policy.js new file mode 100644 index 0000000..df6b27b --- /dev/null +++ b/src/retry/policy.js @@ -0,0 +1,92 @@ +/** + * Retry policy ported from upstream opencode + * (`packages/opencode/src/session/retry.ts`), adapted for this gateway. + * + * Upstream reference values: + * - RETRY_INITIAL_DELAY = 2000ms, RETRY_BACKOFF_FACTOR = 2, + * RETRY_JITTER_FACTOR = 0.25, RETRY_MAX_RETRIES = 5 + * - delay(): retry-after-ms > retry-after (seconds) > retry-after (HTTP date) + * > exponential backoff; capped at 30s without headers. + * + * Gateway deviations (deliberate, documented): + * - All waits are capped at 30s (MAX_DELAY_NO_HEADERS_MS / + * MAX_HEADER_DELAY_MS). Upstream caps headerless backoff at 30s too, but + * honors header-directed waits almost unboundedly (up to INT32_MAX — it + * once waited 700s). A gateway fronting clients with their own timeouts + * (e.g. LiteLLM) must not sleep for hours, so header values are clamped. + * - Max retries resolve through resolveMaxRetries(): default 3 (this proxy's + * historical behavior), configurable via OPENCODE_PROXY_RETRY_MAX_RETRIES, + * hard-capped at ABSOLUTE_MAX_RETRIES = 5 (upstream ceiling). + * + * All functions are pure (no express/SDK imports) for unit testing. + */ + +export const DEFAULT_MAX_RETRIES = 3; +export const ABSOLUTE_MAX_RETRIES = 5; + +export const RETRY_INITIAL_DELAY_MS = 2000; +export const RETRY_BACKOFF_FACTOR = 2; +export const RETRY_JITTER_FACTOR = 0.25; +export const MAX_DELAY_NO_HEADERS_MS = 30000; +export const MAX_HEADER_DELAY_MS = 30000; + +/** + * Resolve the configured max-retry count to an integer in [0, 5]. + * Non-numeric / missing values fall back to DEFAULT_MAX_RETRIES (3). + */ +export function resolveMaxRetries(value) { + const n = typeof value === 'number' ? value : parseInt(value, 10); + if (!Number.isFinite(n)) return DEFAULT_MAX_RETRIES; + const floored = Math.floor(n); + if (floored < 0) return 0; + if (floored > ABSOLUTE_MAX_RETRIES) return ABSOLUTE_MAX_RETRIES; + return floored; +} + +/** + * Parse a provider retry hint from response headers into milliseconds. + * Priority (upstream order): retry-after-ms > retry-after (seconds) > + * retry-after (HTTP date). Invalid / past values yield null (caller falls + * back to exponential). Header names are matched case-insensitively. + */ +export function parseRetryAfterMs(headers) { + if (!headers || typeof headers !== 'object') return null; + const lookup = (name) => { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name) return headers[key]; + } + return undefined; + }; + const msRaw = lookup('retry-after-ms'); + if (msRaw !== undefined) { + const ms = Number.parseFloat(msRaw); + if (!Number.isNaN(ms) && ms >= 0) return Math.ceil(ms); + } + const afterRaw = lookup('retry-after'); + if (afterRaw !== undefined && afterRaw !== null && afterRaw !== '') { + const seconds = Number.parseFloat(afterRaw); + if (!Number.isNaN(seconds) && seconds >= 0) return Math.ceil(seconds * 1000); + const dateMs = Date.parse(afterRaw) - Date.now(); + if (!Number.isNaN(dateMs) && dateMs > 0) return Math.ceil(dateMs); + } + return null; +} + +function exponentialDelay(attempt, random) { + const base = RETRY_INITIAL_DELAY_MS * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1); + return Math.ceil(base + base * RETRY_JITTER_FACTOR * random); +} + +/** + * Compute how long to wait before retry number `attempt` (1-based: the wait + * preceding the 2nd overall attempt is attempt=1 → ~2000ms). + * error may carry responseHeaders directly or under .data (backend shape). + */ +export function computeRetryDelay(attempt, error = null, random = Math.random()) { + const safeAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 1; + const safeRandom = typeof random === 'number' && random >= 0 && random <= 1 ? random : Math.random(); + const headers = error?.responseHeaders ?? error?.data?.responseHeaders ?? null; + const hinted = parseRetryAfterMs(headers); + if (hinted !== null) return Math.min(hinted, MAX_HEADER_DELAY_MS); + return Math.min(exponentialDelay(safeAttempt, safeRandom), MAX_DELAY_NO_HEADERS_MS); +} diff --git a/tests/backend-error-surface.test.js b/tests/backend-error-surface.test.js index a872dd7..a94b44e 100644 --- a/tests/backend-error-surface.test.js +++ b/tests/backend-error-surface.test.js @@ -8,7 +8,11 @@ import { jest } from '@jest/globals'; const BACKEND_CREDITS_ERROR = { name: 'CreditsError', - data: { message: '401: {"message":"Insufficient balance, please recharge","type":"CreditsError"}' } + data: { + message: '401: {"message":"Insufficient balance, please recharge","type":"CreditsError"}', + // Fast retry hint so the suite never waits out real backoff delays. + responseHeaders: { 'retry-after-ms': '10' } + } }; const sdkMocks = { @@ -114,4 +118,76 @@ describe('POST /v1/responses backend plain-object error', () => { expect(JSON.stringify(res.body)).toContain('Insufficient balance'); expect(JSON.stringify(res.body)).not.toContain('"Object"'); }); + + test('responses non-stream retries transient failure then succeeds', async () => { + sdkMocks.sessionMessages + .mockResolvedValueOnce([ + { info: { role: 'assistant', finish: 'stop', error: BACKEND_CREDITS_ERROR }, parts: [] } + ]) + .mockResolvedValue([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'recovered' }] } + ]); + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(res.statusCode).toBe(200); + expect(JSON.stringify(res.body)).toContain('recovered'); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(2); + }); + + describe('RETRY_MAX_RETRIES wiring (chat non-stream)', () => { + const fastTransientError = { + name: 'CreditsError', + data: { + message: '429: {"message":"Too many requests","type":"CreditsError"}', + responseHeaders: { 'retry-after-ms': '10' } + } + }; + const buildApp = (retries) => createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false, + RETRY_MAX_RETRIES: retries + }).app; + + test('default retries transient errors (1 initial + retries)', async () => { + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: fastTransientError }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + // Chat surfaces backend failures as 502 with the real message (existing behavior). + expect(res.statusCode).toBe(502); + expect(JSON.stringify(res.body)).toContain('Too many requests'); + // 1 initial session + 3 retries on fresh sessions (deterministic mock) + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(4); + }); + + test('RETRY_MAX_RETRIES=0 disables retry (single attempt)', async () => { + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: fastTransientError }, parts: [] } + ])); + const chatApp = buildApp(0); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); + + test('RETRY_MAX_RETRIES clamps to upstream ceiling of 5', async () => { + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: fastTransientError }, parts: [] } + ])); + const chatApp = buildApp(99); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + // 1 initial + exactly 5 retries after clamping 99 -> 5 + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(6); + }); + }); }); diff --git a/tests/retry-policy.test.js b/tests/retry-policy.test.js new file mode 100644 index 0000000..055e980 --- /dev/null +++ b/tests/retry-policy.test.js @@ -0,0 +1,81 @@ +import { + resolveMaxRetries, + parseRetryAfterMs, + computeRetryDelay, + DEFAULT_MAX_RETRIES, + ABSOLUTE_MAX_RETRIES, + RETRY_INITIAL_DELAY_MS, + MAX_DELAY_NO_HEADERS_MS, + MAX_HEADER_DELAY_MS +} from '../src/retry/policy.js'; + +describe('resolveMaxRetries', () => { + test('defaults to 3 for missing/invalid values', () => { + expect(resolveMaxRetries(undefined)).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries(null)).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries('')).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries('abc')).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries(NaN)).toBe(DEFAULT_MAX_RETRIES); + expect(DEFAULT_MAX_RETRIES).toBe(3); + }); + test('accepts 0-5, clamps beyond the upstream ceiling', () => { + expect(resolveMaxRetries(0)).toBe(0); + expect(resolveMaxRetries(1)).toBe(1); + expect(resolveMaxRetries(5)).toBe(5); + expect(resolveMaxRetries(99)).toBe(ABSOLUTE_MAX_RETRIES); + expect(resolveMaxRetries(-2)).toBe(0); + expect(resolveMaxRetries('4')).toBe(4); + expect(resolveMaxRetries(2.9)).toBe(2); + expect(ABSOLUTE_MAX_RETRIES).toBe(5); + }); +}); + +describe('parseRetryAfterMs', () => { + test('returns null for missing/invalid headers', () => { + expect(parseRetryAfterMs(null)).toBeNull(); + expect(parseRetryAfterMs({})).toBeNull(); + expect(parseRetryAfterMs({ 'retry-after': 'not-a-number' })).toBeNull(); + expect(parseRetryAfterMs({ 'retry-after-ms': 'abc' })).toBeNull(); + }); + test('retry-after-ms wins (upstream priority)', () => { + expect(parseRetryAfterMs({ 'retry-after-ms': '1500', 'retry-after': '30' })).toBe(1500); + }); + test('retry-after seconds convert to ms', () => { + expect(parseRetryAfterMs({ 'retry-after': '30' })).toBe(30000); + }); + test('retry-after HTTP date uses remaining time, past dates rejected', () => { + const future = new Date(Date.now() + 20000).toUTCString(); + const d = parseRetryAfterMs({ 'retry-after': future }); + expect(d).toBeGreaterThanOrEqual(19000); + expect(d).toBeLessThanOrEqual(20000); + const past = new Date(Date.now() - 5000).toUTCString(); + expect(parseRetryAfterMs({ 'retry-after': past })).toBeNull(); + }); + test('header names are case-insensitive', () => { + expect(parseRetryAfterMs({ 'Retry-After': '2' })).toBe(2000); + }); +}); + +describe('computeRetryDelay', () => { + test('exponential sequence without headers (random=0)', () => { + expect(computeRetryDelay(1, null, 0)).toBe(RETRY_INITIAL_DELAY_MS); + expect(computeRetryDelay(2, null, 0)).toBe(4000); + expect(computeRetryDelay(3, null, 0)).toBe(8000); + expect(computeRetryDelay(4, null, 0)).toBe(16000); + }); + test('caps at 30s without headers', () => { + expect(computeRetryDelay(10, null, 0)).toBe(MAX_DELAY_NO_HEADERS_MS); + expect(computeRetryDelay(10, null, 1)).toBe(MAX_DELAY_NO_HEADERS_MS); + }); + test('jitter stays within +25%', () => { + expect(computeRetryDelay(1, null, 1)).toBe(2500); + const d = computeRetryDelay(2, null, 0.5); + expect(d).toBeGreaterThan(4000); + expect(d).toBeLessThanOrEqual(5000); + }); + test('honors retry-after headers, clamped to gateway max', () => { + expect(computeRetryDelay(1, { responseHeaders: { 'retry-after-ms': '1500' } }, 0)).toBe(1500); + expect(computeRetryDelay(1, { data: { responseHeaders: { 'retry-after': '5' } } }, 0)).toBe(5000); + expect(computeRetryDelay(1, { responseHeaders: { 'retry-after': '700' } }, 0)).toBe(MAX_HEADER_DELAY_MS); + }); +}); From 22e810a69c4a635942a87bcd75ea5b5451e3a445 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 03:20:04 +0800 Subject: [PATCH 2/4] fix(retry): route prompt-layer throws through transient check; genuine auth fast-fail --- CHANGELOG.md | 2 +- src/proxy.js | 87 +++++++++++++++++++++-------- tests/backend-error-surface.test.js | 55 ++++++++++++++++++ 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7264250..02ddb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Anthropic Messages API**:新增 `POST /v1/messages`(`max_tokens` 必填,支持 `system`/`tools(input_schema)`/`tool_choice{auto,any,tool,none}`/`thinking→reasoning`/`image`;`tool_use.id` 原样往返;非流式回 `message` 对象,流式回 `message_start/content_block_*/message_delta/message_stop` 无 `[DONE]`;认证同时支持 `x-api-key`;CORS 放行 `x-api-key/anthropic-version`)。新增 `src/converters/anthropic.js` 纯函数转换层与 `tests/messages-anthropic.test.js`(9 例)。附带修复 `EXTERNAL_TOOL_PREFIX` 缺 import 的 latent `ReferenceError`。 -- **官方对齐重试策略**:移植上游 `session/retry.ts`(`2s×2ⁿ⁻¹` 指数退避 +25% jitter;优先 `retry-after-ms`/`retry-after`,header 等待 clamp 30s;`5xx` 强制、`isRetryable`、`ContextOverflow` 永不)。重试次数 `n` 经 `OPENCODE_PROXY_RETRY_MAX_RETRIES` 控制(默认 3,总尝试 1+n,上限对齐官方 5)。chat 双路由与 messages 非串流改用新退避;responses 非串流新增同结构重试迴圈(仅无产出且 transient 时;responses/messages 串流保持单次尝试)。新增 `src/retry/policy.js` 与 `tests/retry-policy.test.js`(11 例)及接线测试 4 例。 +- **官方对齐重试策略**:移植上游 `session/retry.ts`(`2s×2ⁿ⁻¹` 指数退避 +25% jitter;优先 `retry-after-ms`/`retry-after`,header 等待 clamp 30s;`5xx` 强制、`isRetryable`、`ContextOverflow` 永不)。重试次数 `n` 经 `OPENCODE_PROXY_RETRY_MAX_RETRIES` 控制(默认 3,总尝试 1+n,上限对齐官方 5)。chat 双路由与 messages 非串流改用新退避;responses 非串流新增同结构重试迴圈(仅无产出且 transient 时;responses/messages 串流保持单次尝试)。三处非串流的 prompt/poll 抛错同样走 transient 重试路由(传输层 throw 不再直接逃逸);真鉴权失败(invalid api key/unauthorized/authentication failed)立即透出不再烧退避。新增 `src/retry/policy.js` 与 `tests/retry-policy.test.js`(11 例)及接线测试 7 例。 ### Fixed diff --git a/src/proxy.js b/src/proxy.js index 9f7113e..5a01799 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -64,6 +64,13 @@ function isTransientUpstreamError(error) { // whatever the status code. Explicit false defers to the checks below. if (error.isRetryable === true || error.data?.isRetryable === true) return true; + // Genuine auth failures must surface immediately: a misconfigured key + // should not burn the full backoff. The issue-#5 mislabeled billing case + // ("Insufficient balance"/CreditsError) never carries these strings, so + // it still retries. Placed after isRetryable so a provider-explicit + // retryable flag keeps winning (upstream-faithful). + if (/invalid api key|unauthorized|authentication failed/i.test(message)) return false; + const transientSignatures = [ /insufficient balance/i, /credits?error/i, @@ -2164,12 +2171,22 @@ export function createApp(config) { await sleep(computeRetryDelay(attempt - 1, error)); } const attemptStart = Date.now(); - await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); - logDebug('Prompt sent', { sessionId, ms: Date.now() - attemptStart, attempt }); - const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - content = collected.content || ''; - reasoning = collected.reasoning || ''; - error = collected.error || null; + try { + await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + logDebug('Prompt sent', { sessionId, ms: Date.now() - attemptStart, attempt }); + const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + content = collected.content || ''; + reasoning = collected.reasoning || ''; + error = collected.error || null; + } catch (promptError) { + // Transport-level throw (fetch failed, ECONNRESET, prompt + // timeout): route through the same transient check as a + // poll-observed error. Non-matching throws (e.g. our own + // request timeout) fall through to surfacing below. + content = ''; + reasoning = ''; + error = promptError; + } // Bounded retry for upstream throttling mislabeled as billing // errors (401 CreditsError etc.); only when nothing usable was // produced, so real failures still surface after maxAttempts. @@ -2993,16 +3010,28 @@ export function createApp(config) { requestForcedResponsesToolCall = makeForcedResponsesToolCallRequester(); await sleep(computeRetryDelay(attempt - 1, lastResponsesAttemptError)); } - responseRes = await client.session.prompt(promptParams); - responseParts = responseRes.data?.parts || []; - promptContent = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n'); - promptReasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n'); - promptParsedToolCalls = externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) - : []; - if (promptContent || promptReasoning) break; - const polledResponse = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { + let polledResponse = null; + try { + responseRes = await client.session.prompt(promptParams); + responseParts = responseRes.data?.parts || []; + promptContent = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n'); + promptReasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n'); + promptParsedToolCalls = externalToolRegistry.length > 0 + ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) + : []; + if (promptContent || promptReasoning) break; + polledResponse = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + } catch (loopError) { + // Transport-level throw (fetch failed, ECONNRESET, poll timeout): + // same transient routing as a poll-observed error. + if (attempt < maxAttempts && isTransientUpstreamError(loopError)) { + console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, loopError.data?.message || loopError.message || loopError.name || 'unknown'); + lastResponsesAttemptError = loopError; + continue; + } + throw normalizeBackendError(loopError); + } + if (polledResponse && polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { if (attempt < maxAttempts && isTransientUpstreamError(polledResponse.error)) { console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, polledResponse.error.data?.message || polledResponse.error.message || polledResponse.error.name || 'unknown'); lastResponsesAttemptError = polledResponse.error; @@ -3010,9 +3039,11 @@ export function createApp(config) { } throw normalizeBackendError(polledResponse.error); } - content = polledResponse.content || content; - reasoning = polledResponse.reasoning || reasoning; - polledFilled = true; + if (polledResponse) { + content = polledResponse.content || content; + reasoning = polledResponse.reasoning || reasoning; + polledFilled = true; + } break; } @@ -3288,11 +3319,19 @@ export function createApp(config) { requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); await sleep(computeRetryDelay(attempt - 1, error)); } - await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); - const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - content = collected.content || ''; - reasoning = collected.reasoning || ''; - error = collected.error || null; + try { + await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + content = collected.content || ''; + reasoning = collected.reasoning || ''; + error = collected.error || null; + } catch (promptError) { + // Transport-level throw (fetch failed, ECONNRESET, prompt + // timeout): same transient routing as poll errors. + content = ''; + reasoning = ''; + error = promptError; + } if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) continue; break; } diff --git a/tests/backend-error-surface.test.js b/tests/backend-error-surface.test.js index a94b44e..a2699c2 100644 --- a/tests/backend-error-surface.test.js +++ b/tests/backend-error-surface.test.js @@ -190,4 +190,59 @@ describe('POST /v1/responses backend plain-object error', () => { expect(sdkMocks.sessionCreate.mock.calls.length).toBe(6); }); }); + + describe('prompt-layer throw + genuine auth (chat non-stream)', () => { + const buildApp = (retries) => createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false, + RETRY_MAX_RETRIES: retries + }).app; + + test('prompt throw with transient signature retries then succeeds', async () => { + const throwErr = new Error('fetch failed: socket hang up'); + throwErr.responseHeaders = { 'retry-after-ms': '10' }; + sdkMocks.sessionPrompt + .mockRejectedValueOnce(throwErr) + .mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'recovered-prompt' }] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + expect(JSON.stringify(res.body)).toContain('recovered-prompt'); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(2); + }); + + test('genuine auth failure surfaces immediately without retry', async () => { + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'ProviderAuthError', data: { message: 'Invalid API key for provider' } } }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + expect(JSON.stringify(res.body)).toContain('Invalid API key'); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); + + test('genuine auth failure with 401 status still skips retry', async () => { + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'ProviderAuthError', data: { message: '401: Invalid API key', status: 401 } } }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + // Without the early-false, status 401 alone would trigger retries. + expect(res.statusCode).toBe(502); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); + }); }); From c16d5f36073e21b9951820399992b6321c6f239d Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 03:49:22 +0800 Subject: [PATCH 3/4] fix(retry): broaden genuine-auth fast-fail, guard responseRes, speed up retry tests --- CHANGELOG.md | 2 +- src/proxy.js | 4 ++-- tests/app.test.js | 4 ++-- tests/backend-error-surface.test.js | 17 +++++++++++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ddb8b..79bae44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Anthropic Messages API**:新增 `POST /v1/messages`(`max_tokens` 必填,支持 `system`/`tools(input_schema)`/`tool_choice{auto,any,tool,none}`/`thinking→reasoning`/`image`;`tool_use.id` 原样往返;非流式回 `message` 对象,流式回 `message_start/content_block_*/message_delta/message_stop` 无 `[DONE]`;认证同时支持 `x-api-key`;CORS 放行 `x-api-key/anthropic-version`)。新增 `src/converters/anthropic.js` 纯函数转换层与 `tests/messages-anthropic.test.js`(9 例)。附带修复 `EXTERNAL_TOOL_PREFIX` 缺 import 的 latent `ReferenceError`。 -- **官方对齐重试策略**:移植上游 `session/retry.ts`(`2s×2ⁿ⁻¹` 指数退避 +25% jitter;优先 `retry-after-ms`/`retry-after`,header 等待 clamp 30s;`5xx` 强制、`isRetryable`、`ContextOverflow` 永不)。重试次数 `n` 经 `OPENCODE_PROXY_RETRY_MAX_RETRIES` 控制(默认 3,总尝试 1+n,上限对齐官方 5)。chat 双路由与 messages 非串流改用新退避;responses 非串流新增同结构重试迴圈(仅无产出且 transient 时;responses/messages 串流保持单次尝试)。三处非串流的 prompt/poll 抛错同样走 transient 重试路由(传输层 throw 不再直接逃逸);真鉴权失败(invalid api key/unauthorized/authentication failed)立即透出不再烧退避。新增 `src/retry/policy.js` 与 `tests/retry-policy.test.js`(11 例)及接线测试 7 例。 +- **官方对齐重试策略**:移植上游 `session/retry.ts`(`2s×2ⁿ⁻¹` 指数退避 +25% jitter;优先 `retry-after-ms`/`retry-after`,header 等待 clamp 30s;`5xx` 强制、`isRetryable`、`ContextOverflow` 永不)。重试次数 `n` 经 `OPENCODE_PROXY_RETRY_MAX_RETRIES` 控制(默认 3,总尝试 1+n,上限对齐官方 5)。chat 双路由与 messages 非串流改用新退避;responses 非串流新增同结构重试迴圈(仅无产出且 transient 时;responses/messages 串流保持单次尝试)。三处非串流的 prompt/poll 抛错同样走 transient 重试路由(传输层 throw 不再直接逃逸);真鉴权失败(invalid api key/unauthorized/authentication failed)立即透出不再烧退避。新增 `src/retry/policy.js` 与 `tests/retry-policy.test.js`(11 例)及接线/回归测试 14 例。 ### Fixed diff --git a/src/proxy.js b/src/proxy.js index 5a01799..7a6904f 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -69,7 +69,7 @@ function isTransientUpstreamError(error) { // ("Insufficient balance"/CreditsError) never carries these strings, so // it still retries. Placed after isRetryable so a provider-explicit // retryable flag keeps winning (upstream-faithful). - if (/invalid api key|unauthorized|authentication failed/i.test(message)) return false; + if (/invalid[_\s]?api[_\s]?key|unauthorized|authentication (failed|error)|api key (expired|invalid|incorrect)/i.test(message)) return false; const transientSignatures = [ /insufficient balance/i, @@ -3059,7 +3059,7 @@ export function createApp(config) { : []; } - if (!content && !reasoning && responseRes.data && promptBasedToolCalls.length === 0) { + if (!content && !reasoning && responseRes?.data && promptBasedToolCalls.length === 0) { const data = responseRes.data; content = typeof data === 'string' ? data : data?.message || JSON.stringify(data); } diff --git a/tests/app.test.js b/tests/app.test.js index 5d91a9c..53e031a 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -2178,7 +2178,7 @@ describe('Proxy OpenAI API', () => { properties: { info: { sessionID: sessionId, - error: { name: 'CreditsError', data: { message: '401: {"message":"Insufficient balance. Manage your billing here: https://example.ai","type":"CreditsError","param":"","code":null}' } } + error: { name: 'CreditsError', data: { message: '401: {"message":"Insufficient balance. Manage your billing here: https://example.ai","type":"CreditsError","param":"","code":null}', responseHeaders: { 'retry-after-ms': '10' } } } } } }]; @@ -2214,7 +2214,7 @@ describe('Proxy OpenAI API', () => { .mockResolvedValueOnce([{ info: { role: 'assistant', - error: { name: 'CreditsError', data: { message: '401: Insufficient balance' } } + error: { name: 'CreditsError', data: { message: '401: Insufficient balance', responseHeaders: { 'retry-after-ms': '10' } } } }, parts: [] }]) diff --git a/tests/backend-error-surface.test.js b/tests/backend-error-surface.test.js index a2699c2..7b3c8d0 100644 --- a/tests/backend-error-surface.test.js +++ b/tests/backend-error-surface.test.js @@ -244,5 +244,22 @@ describe('POST /v1/responses backend plain-object error', () => { expect(res.statusCode).toBe(502); expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); }); + + test.each([ + ['Authentication error', '401: Authentication error'], + ['expired key', 'API key expired'], + ['underscore code', 'invalid_api_key'] + ])('auth variant skips retry: %s', async (_label, msg) => { + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'ProviderAuthError', data: { message: msg } } }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); }); }); From c9837fd06ec809ddeb90aa12e4eead6903cc509b Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 03:55:19 +0800 Subject: [PATCH 4/4] fix(retry): restore 504 timeout mapping, add client-gone guards and session-rotation guard --- src/proxy.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/proxy.js b/src/proxy.js index 7a6904f..3baf841 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -2154,6 +2154,12 @@ export function createApp(config) { let reasoning = ''; let error = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + // The route watchdog may have already answered (502) and deleted + // the session; stop feeding the backend once the client is gone. + if (res.writableEnded || res.destroyed) { + logDebug('Client gone, stop retrying', { sessionId, attempt }); + return; + } if (attempt > 1) { // Retry on a fresh session: the failed attempt left an errored // assistant message in the old one, and re-prompting the same @@ -2203,6 +2209,10 @@ export function createApp(config) { break; } if (error && !content && !reasoning) { + // Prompt/poll timeout throws must propagate to the route catch + // so transformUpstreamError maps them to 504/timeout, exactly + // like the pre-try/catch behavior. + if (/^Request timeout after/.test(error.message || '')) throw error; return res.status(502).json({ error: { message: error.data?.message || error.message || 'OpenCode provider error', @@ -2996,6 +3006,11 @@ export function createApp(config) { let polledFilled = false; let lastResponsesAttemptError = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + // Stop once the client is gone (route watchdog may have answered already). + if (res.writableEnded || res.destroyed) { + logDebug('Client gone, stop retrying', { sessionId, attempt }); + return; + } if (attempt > 1) { // Same fresh-session rotation as chat: the failed attempt left an // errored assistant message behind, and re-prompting it would @@ -3311,10 +3326,16 @@ export function createApp(config) { let reasoning = ''; let error = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + // Stop once the client is gone (route watchdog may have answered already). + if (res.writableEnded || res.destroyed) { + logDebug('Client gone, stop retrying', { sessionId, attempt }); + return; + } if (attempt > 1) { try { await client.session.delete({ path: { id: sessionId } }); } catch {} const r = await client.session.create(); sessionId = r.data?.id; + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); promptParams.path.id = sessionId; requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); await sleep(computeRetryDelay(attempt - 1, error));