Retry fallback after network errors and HTTP failures

This commit is contained in:
musistudio 2026-07-09 19:30:31 +08:00
parent 15f67ab8d8
commit 4cf0c2468c
4 changed files with 47 additions and 12 deletions

View file

@ -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.

View file

@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成;
| 继续重试 | `408``409``429``5xx` |
| 失败降级目标 | 任意 `4xx``5xx` |
对于 `429` 限流响应CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
进入下一次尝试前CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
**失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。

View file

@ -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) {

View file

@ -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",