Heaven K
fix: add 30s cooldown on 400 errors to prevent retry loop
001455d
import type { AIProvider } from '../services/aiService.js';
import type { ParsedTransaction } from '@icc/shared';
import pino from 'pino';
const logger = pino({ level: 'info' });
interface ProviderSlot {
provider: AIProvider;
cooldownUntil: number; // timestamp ms
dailyRequests: number;
dailyLimit: number;
disabled: boolean;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export class AIProviderPool {
private slots: ProviderSlot[];
private currentIndex = 0;
constructor(providers: { provider: AIProvider; dailyLimit: number }[]) {
this.slots = providers.map(({ provider, dailyLimit }) => ({
provider,
cooldownUntil: 0,
dailyRequests: 0,
dailyLimit,
disabled: false,
}));
}
getCurrentSlotId(): string {
return this.slots[this.currentIndex]?.provider.name ?? 'none';
}
getStatus() {
return this.slots.map((slot) => ({
name: slot.provider.name,
available: !slot.disabled && Date.now() >= slot.cooldownUntil && slot.dailyRequests < slot.dailyLimit,
dailyRequests: slot.dailyRequests,
dailyLimit: slot.dailyLimit,
cooldownRemaining: Math.max(0, slot.cooldownUntil - Date.now()),
}));
}
private findAvailableSlot(): ProviderSlot | null {
const now = Date.now();
// Always start from slot 0 (primary provider) to enforce priority ordering
for (let i = 0; i < this.slots.length; i++) {
const slot = this.slots[i];
if (!slot.disabled && now >= slot.cooldownUntil && slot.dailyRequests < slot.dailyLimit) {
this.currentIndex = i;
return slot;
}
}
return null;
}
async parse(emailBody: string): Promise<ParsedTransaction> {
const maxRetries = this.slots.length * 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const slot = this.findAvailableSlot();
if (!slot) {
// All slots exhausted — find the shortest cooldown and wait
const now = Date.now();
const soonest = this.slots
.filter((s) => !s.disabled && s.dailyRequests < s.dailyLimit)
.reduce((min, s) => Math.min(min, s.cooldownUntil), Infinity);
if (soonest === Infinity) {
throw new Error('All AI providers exhausted for the day');
}
const waitMs = Math.max(soonest - now, 1000);
logger.warn(`All providers rate-limited. Waiting ${Math.round(waitMs / 1000)}s...`);
await sleep(waitMs);
continue;
}
try {
logger.info(`Parsing with ${slot.provider.name} (attempt ${attempt + 1}/${maxRetries})`);
const result = await slot.provider.parse(emailBody);
slot.dailyRequests++;
return result;
} catch (error: any) {
if (error.status === 429 || error.status === 413) {
// Rate limited or request too large — set cooldown and try next
const retryAfter = parseInt(error.headers?.['retry-after'] || '30');
slot.cooldownUntil = Date.now() + retryAfter * 1000;
logger.info(
`${error.status === 413 ? 'Request too large' : 'Rate limited'} on ${slot.provider.name}, cooldown ${retryAfter}s. Switching...`
);
this.currentIndex = (this.currentIndex + 1) % this.slots.length;
continue;
}
if (error.status === 400) {
// 400 errors: invalid model, json_validate_failed, bad request
const bodyStr = error.body ?? error.message ?? '';
const isInvalidModel = bodyStr.includes('invalid_model') || bodyStr.includes('Invalid model');
if (isInvalidModel) {
slot.disabled = true;
logger.warn(`Invalid model on ${slot.provider.name}, permanently disabled`);
} else {
// Cooldown to prevent retry loop (findAvailableSlot starts from 0)
slot.cooldownUntil = Date.now() + 30_000;
logger.warn(`400 error on ${slot.provider.name}, 30s cooldown. Trying next...`);
}
this.currentIndex = (this.currentIndex + 1) % this.slots.length;
continue;
}
if (error.status === 403) {
// 403: model blocked at org level or forbidden — permanently disable
slot.disabled = true;
logger.warn(`Forbidden (403) on ${slot.provider.name}, permanently disabled. Trying next...`);
this.currentIndex = (this.currentIndex + 1) % this.slots.length;
continue;
}
if (error.status === 404) {
// 404: model does not exist on this provider — permanently disable the slot
slot.disabled = true;
logger.warn(`Model not found (404) on ${slot.provider.name}, permanently disabled. Trying next...`);
this.currentIndex = (this.currentIndex + 1) % this.slots.length;
continue;
}
if (error.status >= 500) {
// Server error — 1 min cooldown
slot.cooldownUntil = Date.now() + 60_000;
logger.warn(`Server error on ${slot.provider.name}, 60s cooldown`);
this.currentIndex = (this.currentIndex + 1) % this.slots.length;
continue;
}
// Any other error (JSON parse, network, etc.) — cooldown and try next
slot.cooldownUntil = Date.now() + 10_000;
logger.warn(`Unexpected error on ${slot.provider.name}: ${error.message || error.status}. Trying next...`);
this.currentIndex = (this.currentIndex + 1) % this.slots.length;
continue;
}
}
throw new Error('Max retries exceeded across all providers');
}
/** Reset daily counters (call at midnight UTC) */
resetDaily() {
for (const slot of this.slots) {
slot.dailyRequests = 0;
slot.disabled = false;
slot.cooldownUntil = 0;
}
}
}