From 6a55effd3bf961d90ff4c30316477edc4e1a9567 Mon Sep 17 00:00:00 2001 From: nodca <96775880+nodca@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:29:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20weighted=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=A4=B1=E8=B4=A5=E9=80=80=E9=81=BF=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=E5=AF=BC=E8=87=B4=E6=9C=8D=E5=8A=A1=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=20(#354)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cap token router cooldown backoff * docs: explain token router cooldown cap --- src/server/services/tokenRouter.cache.test.ts | 56 +++++++++++++++++++ src/server/services/tokenRouter.ts | 8 ++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/server/services/tokenRouter.cache.test.ts b/src/server/services/tokenRouter.cache.test.ts index 3d50bcd..1731181 100644 --- a/src/server/services/tokenRouter.cache.test.ts +++ b/src/server/services/tokenRouter.cache.test.ts @@ -691,4 +691,60 @@ describe('TokenRouter runtime cache', () => { expect(current?.cooldownLevel).toBe(0); expect(current?.cooldownUntil).toBeNull(); }); + + it('caps weighted cooldowns before Date overflow for heavily failed channels', async () => { + const site = await db.insert(schema.sites).values({ + name: 'weighted-cooldown-cap-site', + url: 'https://weighted-cooldown-cap.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'weighted-cooldown-cap-user', + accessToken: 'weighted-cooldown-cap-access-token', + apiToken: 'weighted-cooldown-cap-api-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'weighted-cooldown-cap-token', + token: 'sk-weighted-cooldown-cap-token', + enabled: true, + isDefault: true, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.4', + routingStrategy: 'weighted', + enabled: true, + }).returning().get(); + + const channel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: token.id, + priority: 0, + weight: 10, + enabled: true, + failCount: 57, + }).returning().get(); + + // Weighted routes previously let Fibonacci backoff grow beyond the Date range. + const router = new TokenRouter(); + + const startedAt = Date.now(); + await router.recordFailure(channel.id); + const current = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + const cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt; + + expect(current?.failCount).toBe(58); + expect(Number.isFinite(Date.parse(String(current?.cooldownUntil || '')))).toBe(true); + expect(cooldownMs).toBeGreaterThanOrEqual((30 * 24 * 60 * 60 - 5) * 1000); + expect(cooldownMs).toBeLessThanOrEqual((30 * 24 * 60 * 60 + 5) * 1000); + }); }); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 7166982..bc35ade 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -72,6 +72,8 @@ type SiteRuntimeHealthState = { const FAILURE_BACKOFF_BASE_SEC = 15; const SHORT_WINDOW_LIMIT_COOLDOWN_MS = 5 * 60 * 1000; +// Keep weighted-route backoff within the JavaScript Date range when fail counts grow large. +const MAX_FAILURE_BACKOFF_SEC = 30 * 24 * 60 * 60; const MIN_EFFECTIVE_UNIT_COST = 1e-6; const ROUND_ROBIN_FAILURE_THRESHOLD = 3; const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; @@ -192,9 +194,13 @@ function fibonacciNumber(index: number): number { return current; } +/** + * Weighted-route failures use a Fibonacci backoff, but the resulting cooldown must stay + * representable as a JavaScript Date for downstream `toISOString()` calls. + */ function resolveFailureBackoffSec(failCount?: number | null): number { const normalizedFailCount = Math.max(1, Math.trunc(failCount ?? 0)); - return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount); + return Math.min(FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount), MAX_FAILURE_BACKOFF_SEC); } function resolveRoundRobinCooldownSec(level: number): number {