diff --git a/docs/src/content/docs/en/configuration/routing.md b/docs/src/content/docs/en/configuration/routing.md index a378c56..f21a521 100644 --- a/docs/src/content/docs/en/configuration/routing.md +++ b/docs/src/content/docs/en/configuration/routing.md @@ -162,7 +162,7 @@ Network errors move to the next attempt. Status-code fallback depends on the mod | Retry | `408`, `409`, `429`, `5xx` | | Fallback targets | Any `4xx` or `5xx` | -For `429` rate-limit responses, CCR waits before the next attempt. It honors `Retry-After` when the upstream provides it; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. +Before moving to the next attempt, CCR waits for every fallback-triggering failure, including network errors. It honors a positive `Retry-After` header when the upstream provides one; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. **Fallback targets** also switches on `4xx` because model-not-found, auth, or provider-side rejection errors may only affect the current target. If the fallback model works, the request can still succeed. diff --git a/docs/src/content/docs/zh/configuration/routing.md b/docs/src/content/docs/zh/configuration/routing.md index c00f069..332eda9 100644 --- a/docs/src/content/docs/zh/configuration/routing.md +++ b/docs/src/content/docs/zh/configuration/routing.md @@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成; | 继续重试 | `408`、`409`、`429`、`5xx` | | 失败降级目标 | 任意 `4xx` 或 `5xx` | -对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 +进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 **失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。 diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 13f78a6..99dcaaf 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -5681,7 +5681,7 @@ async function fetchUpstreamWithFallback(input: { }); if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) { - const delayMs = retryDelayAfterStatus(response.status, response, failedAttempts.length); + const delayMs = retryDelayAfterStatus(response.status, response.headers, failedAttempts.length); failedAttempts.push({ credentialChain: attempt.credentialChain, credentialIds: attempt.credentialIds, @@ -5704,10 +5704,13 @@ async function fetchUpstreamWithFallback(input: { }; } catch (error) { const message = formatError(error); + const delayMs = hasNextAttempt && !input.signal?.aborted + ? retryDelayAfterNetworkError(failedAttempts.length) + : 0; failedAttempts.push({ credentialChain: attempt.credentialChain, credentialIds: attempt.credentialIds, - delayMs: 0, + delayMs, error: message, model: attempt.model }); @@ -5719,6 +5722,9 @@ async function fetchUpstreamWithFallback(input: { }); } if (hasNextAttempt) { + if (delayMs > 0) { + await delay(delayMs); + } continue; } throw new UpstreamRequestError(message, { @@ -6170,17 +6176,30 @@ function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode) return false; } -function retryDelayAfterStatus(statusCode: number, response: Response, failedAttemptIndex: number): number { - if (statusCode !== 429) { - return 0; - } - const retryAfterMs = parseRetryAfterHeaderMs(response.headers.get("retry-after")); - if (retryAfterMs !== undefined) { - return clampNumber(retryAfterMs, 0, upstreamRetryAfterMaxMs); +function retryDelayAfterStatus(_statusCode: number, headers: Headers, failedAttemptIndex: number): number { + const retryAfterMs = parseRetryAfterHeaderMs(headers.get("retry-after")); + if (retryAfterMs !== undefined && retryAfterMs > 0) { + return clampNumber(retryAfterMs, 1, upstreamRetryAfterMaxMs); } return exponentialRetryBackoffMs(failedAttemptIndex); } +function retryDelayAfterNetworkError(failedAttemptIndex: number): number { + return exponentialRetryBackoffMs(failedAttemptIndex); +} + +export function fallbackRetryDelayAfterStatusForTest(input: { failedAttemptIndex?: number; retryAfter?: string | null; statusCode: number }): number { + const headers = new Headers(); + if (input.retryAfter !== undefined && input.retryAfter !== null) { + headers.set("retry-after", input.retryAfter); + } + return retryDelayAfterStatus(input.statusCode, headers, input.failedAttemptIndex ?? 0); +} + +export function fallbackRetryDelayAfterNetworkErrorForTest(failedAttemptIndex = 0): number { + return retryDelayAfterNetworkError(failedAttemptIndex); +} + function parseRetryAfterHeaderMs(value: string | null): number | undefined { const trimmed = value?.trim(); if (!trimmed) { diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index 1928fc5..b461f52 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { ClaudeCodeRouterPlugin } from "../../packages/core/src/gateway/claude-code-router-plugin.ts"; -import { prepareGatewayUpstreamAttemptForTest } from "../../packages/core/src/gateway/service.ts"; +import { + fallbackRetryDelayAfterNetworkErrorForTest, + fallbackRetryDelayAfterStatusForTest, + prepareGatewayUpstreamAttemptForTest +} from "../../packages/core/src/gateway/service.ts"; function createRouterPlugin(options = {}) { const agent = options.agent ?? "claude-code"; @@ -42,6 +46,18 @@ function createRouterPlugin(options = {}) { }); } +test("fallback retry delay backs off retryable HTTP statuses", () => { + assert.equal(fallbackRetryDelayAfterStatusForTest({ statusCode: 503 }), 1000); + assert.equal(fallbackRetryDelayAfterStatusForTest({ failedAttemptIndex: 1, statusCode: 408 }), 2000); + assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "3", statusCode: 429 }), 3000); + assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "0", statusCode: 429 }), 1000); +}); + +test("fallback retry delay backs off network errors", () => { + assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(), 1000); + assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(2), 4000); +}); + function createIssue1480UserConfig() { return { APIKEY: "gateway-key",