124 lines
5.0 KiB
JavaScript
124 lines
5.0 KiB
JavaScript
// Regression test for per-model rate-limit buckets.
|
|
//
|
|
// Bug: a model-specific (weekly) 429 — originally claude-fable-5, later claude-sonnet-4-5 —
|
|
// was routed into the account-wide bucket (markAccountRateLimited -> schedulable='false'),
|
|
// taking the whole OAuth account offline so claude-opus-4-8 failed with
|
|
// "No available Claude accounts support the requested model".
|
|
//
|
|
// Fix: every model family (opus/sonnet/haiku/fable) gets its own bucket that never
|
|
// disables account-wide scheduling and never sets the general rate-limit state.
|
|
|
|
const mockStore = new Map()
|
|
|
|
jest.mock('../src/models/redis', () => ({
|
|
getClaudeAccount: jest.fn(async (id) => mockStore.get(id) || {}),
|
|
setClaudeAccount: jest.fn(async (id, data) => {
|
|
mockStore.set(id, { ...data })
|
|
}),
|
|
client: { hdel: jest.fn(async () => 1) }
|
|
}))
|
|
|
|
jest.mock('../src/utils/logger', () => ({
|
|
debug: jest.fn(),
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
success: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/tokenRefreshService', () => ({}))
|
|
jest.mock('../src/utils/tokenRefreshLogger', () => ({}))
|
|
jest.mock('../src/utils/webhookNotifier', () => ({ sendAccountAnomalyNotification: jest.fn() }))
|
|
jest.mock('../src/utils/upstreamErrorHelper', () => ({
|
|
recordErrorHistory: jest.fn(() => ({ catch: jest.fn() })),
|
|
markTempUnavailable: jest.fn(() => ({ catch: jest.fn() })),
|
|
parseRetryAfter: jest.fn(() => null)
|
|
}))
|
|
jest.mock('../src/utils/proxyHelper', () => ({}))
|
|
jest.mock('axios', () => ({}))
|
|
|
|
// The service constructor starts a cache-cleanup setInterval at load; unref it so the
|
|
// timer never keeps Jest alive.
|
|
const _realSetInterval = global.setInterval
|
|
global.setInterval = (fn, ms, ...args) => {
|
|
const timer = _realSetInterval(fn, ms, ...args)
|
|
if (timer && typeof timer.unref === 'function') {
|
|
timer.unref()
|
|
}
|
|
return timer
|
|
}
|
|
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
|
global.setInterval = _realSetInterval
|
|
|
|
const ACCOUNT_ID = 'acct-model-test'
|
|
const DAY = 24 * 60 * 60 * 1000
|
|
const futureTs = () => Math.floor((Date.now() + 3 * DAY) / 1000)
|
|
|
|
const seed = () => {
|
|
mockStore.clear()
|
|
mockStore.set(ACCOUNT_ID, {
|
|
id: ACCOUNT_ID,
|
|
name: 'test-account',
|
|
isActive: 'true',
|
|
status: 'active',
|
|
schedulable: 'true'
|
|
})
|
|
}
|
|
|
|
describe('per-model rate-limit buckets (claudeAccountService)', () => {
|
|
beforeEach(seed)
|
|
|
|
it.each(['opus', 'sonnet', 'haiku', 'fable'])(
|
|
'records a %s limit without flipping the account-wide kill switch',
|
|
async (family) => {
|
|
await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, family, futureTs())
|
|
|
|
const stored = mockStore.get(ACCOUNT_ID)
|
|
expect(stored[`${family}RateLimitedAt`]).toBeTruthy()
|
|
expect(stored[`${family}RateLimitEndAt`]).toBeTruthy()
|
|
// The heart of the bug: a model limit must NOT disable account-wide scheduling,
|
|
// and must NOT write the general rate-limit state that blocks every other model.
|
|
expect(stored.schedulable).not.toBe('false')
|
|
expect(stored.rateLimitStatus).toBeUndefined()
|
|
expect(stored.rateLimitAutoStopped).toBeUndefined()
|
|
expect(stored.rateLimitEndAt).toBeUndefined()
|
|
}
|
|
)
|
|
|
|
it('a sonnet limit leaves opus schedulable and the general bucket untouched', async () => {
|
|
await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, 'sonnet', futureTs())
|
|
|
|
expect(await claudeAccountService.isAccountModelRateLimited(ACCOUNT_ID, 'sonnet')).toBe(true)
|
|
// This is exactly what regressed opus in production.
|
|
expect(await claudeAccountService.isAccountModelRateLimited(ACCOUNT_ID, 'opus')).toBe(false)
|
|
expect(await claudeAccountService.isAccountRateLimited(ACCOUNT_ID)).toBe(false)
|
|
})
|
|
|
|
it('auto-clears an expired model limit', async () => {
|
|
const pastTs = Math.floor((Date.now() - 60 * 60 * 1000) / 1000)
|
|
await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, 'sonnet', pastTs)
|
|
|
|
expect(await claudeAccountService.isAccountModelRateLimited(ACCOUNT_ID, 'sonnet')).toBe(false)
|
|
expect(mockStore.get(ACCOUNT_ID).sonnetRateLimitEndAt).toBeUndefined()
|
|
})
|
|
|
|
it('reports remaining minutes via getAccountModelRateLimitInfo', async () => {
|
|
await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, 'haiku', futureTs())
|
|
const info = await claudeAccountService.getAccountModelRateLimitInfo(ACCOUNT_ID, 'haiku')
|
|
expect(info.isRateLimited).toBe(true)
|
|
expect(info.minutesRemaining).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('keeps the legacy Opus/Fable wrappers writing the same Redis fields', async () => {
|
|
await claudeAccountService.markAccountOpusRateLimited(ACCOUNT_ID, futureTs())
|
|
await claudeAccountService.markAccountFableRateLimited(ACCOUNT_ID, futureTs())
|
|
|
|
const stored = mockStore.get(ACCOUNT_ID)
|
|
expect(stored.opusRateLimitEndAt).toBeTruthy()
|
|
expect(stored.fableRateLimitEndAt).toBeTruthy()
|
|
expect(await claudeAccountService.isAccountOpusRateLimited(ACCOUNT_ID)).toBe(true)
|
|
expect(await claudeAccountService.isAccountFableRateLimited(ACCOUNT_ID)).toBe(true)
|
|
expect(stored.schedulable).not.toBe('false')
|
|
})
|
|
})
|