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 {