@splashcodex/api-key-manager 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -36
- package/dist/index.d.ts +89 -5
- package/dist/index.js +253 -22
- package/dist/index.js.map +1 -1
- package/package.json +8 -2
- package/src/index.ts +354 -30
package/src/index.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Universal ApiKeyManager
|
|
3
|
-
* Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff, Strategies
|
|
2
|
+
* Universal ApiKeyManager v3.0
|
|
3
|
+
* Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff, Strategies,
|
|
4
|
+
* Event Emitter, Fallback, execute(), Timeout, Auto-Retry, Provider Tags,
|
|
5
|
+
* Health Checks, Bulkhead/Concurrency
|
|
4
6
|
* Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
import { EventEmitter } from 'events';
|
|
10
|
+
|
|
11
|
+
// ─── Interfaces & Types ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
7
13
|
export interface KeyState {
|
|
8
14
|
key: string;
|
|
9
15
|
failCount: number; // Consecutive failures
|
|
@@ -20,6 +26,8 @@ export interface KeyState {
|
|
|
20
26
|
averageLatency: number; // Rolling average latency in ms
|
|
21
27
|
totalLatency: number; // Sum of all latency checks (for calculating average)
|
|
22
28
|
latencySamples: number; // Number of samples
|
|
29
|
+
// v3.0 Fields
|
|
30
|
+
provider: string; // Provider tag (e.g. 'openai', 'gemini')
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
export type ErrorType =
|
|
@@ -29,6 +37,7 @@ export type ErrorType =
|
|
|
29
37
|
| 'BAD_REQUEST' // 400 - Do not retry, fix request
|
|
30
38
|
| 'SAFETY' // finishReason: SAFETY - Not a key issue
|
|
31
39
|
| 'RECITATION' // finishReason: RECITATION - Not a key issue
|
|
40
|
+
| 'TIMEOUT' // Request timed out
|
|
32
41
|
| 'UNKNOWN'; // Catch-all
|
|
33
42
|
|
|
34
43
|
export interface ErrorClassification {
|
|
@@ -39,6 +48,45 @@ export interface ErrorClassification {
|
|
|
39
48
|
markKeyDead: boolean;
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
export interface ApiKeyManagerStats {
|
|
52
|
+
total: number;
|
|
53
|
+
healthy: number;
|
|
54
|
+
cooling: number;
|
|
55
|
+
dead: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ExecuteOptions {
|
|
59
|
+
timeoutMs?: number; // Timeout per attempt in ms
|
|
60
|
+
maxRetries?: number; // Max retry attempts (default: 0 = no retry)
|
|
61
|
+
finishReason?: string; // For Gemini finishReason handling
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ApiKeyManagerOptions {
|
|
65
|
+
storage?: any;
|
|
66
|
+
strategy?: LoadBalancingStrategy;
|
|
67
|
+
fallbackFn?: () => any;
|
|
68
|
+
concurrency?: number; // Max concurrent execute() calls
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Event Types ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export interface ApiKeyManagerEventMap {
|
|
74
|
+
keyDead: (key: string) => void;
|
|
75
|
+
circuitOpen: (key: string) => void;
|
|
76
|
+
circuitHalfOpen: (key: string) => void;
|
|
77
|
+
keyRecovered: (key: string) => void;
|
|
78
|
+
fallback: (reason: string) => void;
|
|
79
|
+
allKeysExhausted: () => void;
|
|
80
|
+
retry: (key: string, attempt: number, delayMs: number) => void;
|
|
81
|
+
healthCheckFailed: (key: string, error: any) => void;
|
|
82
|
+
healthCheckPassed: (key: string) => void;
|
|
83
|
+
executeSuccess: (key: string, durationMs: number) => void;
|
|
84
|
+
executeFailed: (key: string, error: any) => void;
|
|
85
|
+
bulkheadRejected: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
42
90
|
const CONFIG = {
|
|
43
91
|
MAX_CONSECUTIVE_FAILURES: 5,
|
|
44
92
|
COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
|
|
@@ -58,13 +106,31 @@ const ERROR_PATTERNS = {
|
|
|
58
106
|
isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
|
|
59
107
|
};
|
|
60
108
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
// ─── Custom Errors ───────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export class TimeoutError extends Error {
|
|
112
|
+
constructor(ms: number) {
|
|
113
|
+
super(`Request timed out after ${ms}ms`);
|
|
114
|
+
this.name = 'TimeoutError';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class BulkheadRejectionError extends Error {
|
|
119
|
+
constructor() {
|
|
120
|
+
super('Bulkhead capacity exceeded — too many concurrent requests');
|
|
121
|
+
this.name = 'BulkheadRejectionError';
|
|
122
|
+
}
|
|
66
123
|
}
|
|
67
124
|
|
|
125
|
+
export class AllKeysExhaustedError extends Error {
|
|
126
|
+
constructor() {
|
|
127
|
+
super('All API keys exhausted — no healthy keys available');
|
|
128
|
+
this.name = 'AllKeysExhaustedError';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Strategies ──────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
68
134
|
/**
|
|
69
135
|
* Strategy Interface for selecting the next key
|
|
70
136
|
*/
|
|
@@ -77,7 +143,6 @@ export interface LoadBalancingStrategy {
|
|
|
77
143
|
*/
|
|
78
144
|
export class StandardStrategy implements LoadBalancingStrategy {
|
|
79
145
|
next(candidates: KeyState[]): KeyState | null {
|
|
80
|
-
// Sort: Pristine > Fewest Failures > Least Recently Used
|
|
81
146
|
candidates.sort((a, b) => {
|
|
82
147
|
if (a.failCount !== b.failCount) return a.failCount - b.failCount;
|
|
83
148
|
return a.lastUsed - b.lastUsed;
|
|
@@ -112,49 +177,84 @@ export class WeightedStrategy implements LoadBalancingStrategy {
|
|
|
112
177
|
export class LatencyStrategy implements LoadBalancingStrategy {
|
|
113
178
|
next(candidates: KeyState[]): KeyState | null {
|
|
114
179
|
if (candidates.length === 0) return null;
|
|
115
|
-
// Sort by averageLatency (lowest first)
|
|
116
|
-
// If latency is 0 (untried), treat as high priority or neutral?
|
|
117
|
-
// Let's treat 0 as "unknown, give it a shot" -> insert at top or mixed?
|
|
118
|
-
// Simple: Sort ASC. 0 comes first.
|
|
119
180
|
candidates.sort((a, b) => a.averageLatency - b.averageLatency);
|
|
120
181
|
return candidates[0];
|
|
121
182
|
}
|
|
122
183
|
}
|
|
123
184
|
|
|
124
|
-
|
|
185
|
+
// ─── Main Class ──────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export class ApiKeyManager extends EventEmitter {
|
|
125
188
|
private keys: KeyState[] = [];
|
|
126
189
|
private storageKey = 'api_rotation_state_v2';
|
|
127
190
|
private storage: any;
|
|
128
191
|
private strategy: LoadBalancingStrategy;
|
|
192
|
+
private fallbackFn?: () => any;
|
|
129
193
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
194
|
+
// Bulkhead state
|
|
195
|
+
private maxConcurrency: number;
|
|
196
|
+
private activeCalls: number = 0;
|
|
197
|
+
|
|
198
|
+
// Health check state
|
|
199
|
+
private healthCheckFn?: (key: string) => Promise<boolean>;
|
|
200
|
+
private healthCheckInterval?: ReturnType<typeof setInterval>;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Constructor supports both legacy positional args and new options object.
|
|
204
|
+
*
|
|
205
|
+
* @example Legacy (v1/v2 — still works):
|
|
206
|
+
* new ApiKeyManager(['key1', 'key2'], storage, strategy)
|
|
207
|
+
*
|
|
208
|
+
* @example New (v3):
|
|
209
|
+
* new ApiKeyManager(keys, { storage, strategy, fallbackFn, concurrency })
|
|
210
|
+
*/
|
|
211
|
+
constructor(
|
|
212
|
+
initialKeys: string[] | { key: string; weight?: number; provider?: string }[],
|
|
213
|
+
storageOrOptions?: any | ApiKeyManagerOptions,
|
|
214
|
+
strategy?: LoadBalancingStrategy
|
|
215
|
+
) {
|
|
216
|
+
super();
|
|
217
|
+
|
|
218
|
+
// Detect if second arg is options object or legacy storage
|
|
219
|
+
let options: ApiKeyManagerOptions = {};
|
|
220
|
+
if (storageOrOptions && typeof storageOrOptions === 'object' && ('storage' in storageOrOptions || 'strategy' in storageOrOptions || 'fallbackFn' in storageOrOptions || 'concurrency' in storageOrOptions)) {
|
|
221
|
+
// New v3 options object
|
|
222
|
+
options = storageOrOptions as ApiKeyManagerOptions;
|
|
223
|
+
} else {
|
|
224
|
+
// Legacy positional args
|
|
225
|
+
options = {
|
|
226
|
+
storage: storageOrOptions,
|
|
227
|
+
strategy: strategy,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.storage = options.storage || {
|
|
133
232
|
getItem: () => null,
|
|
134
233
|
setItem: () => { },
|
|
135
234
|
};
|
|
136
|
-
|
|
137
|
-
this.
|
|
235
|
+
this.strategy = options.strategy || new StandardStrategy();
|
|
236
|
+
this.fallbackFn = options.fallbackFn;
|
|
237
|
+
this.maxConcurrency = options.concurrency || Infinity;
|
|
138
238
|
|
|
139
239
|
// Normalize input to objects
|
|
140
|
-
let inputKeys: { key: string; weight?: number }[] = [];
|
|
240
|
+
let inputKeys: { key: string; weight?: number; provider?: string }[] = [];
|
|
141
241
|
if (initialKeys.length > 0 && typeof initialKeys[0] === 'string') {
|
|
142
|
-
inputKeys = (initialKeys as string[]).flatMap(k => k.split(',').map(s => ({ key: s.trim(), weight: 1.0 })));
|
|
242
|
+
inputKeys = (initialKeys as string[]).flatMap(k => k.split(',').map(s => ({ key: s.trim(), weight: 1.0, provider: 'default' })));
|
|
143
243
|
} else {
|
|
144
|
-
inputKeys = initialKeys as { key: string; weight?: number }[];
|
|
244
|
+
inputKeys = initialKeys as { key: string; weight?: number; provider?: string }[];
|
|
145
245
|
}
|
|
146
246
|
|
|
147
247
|
// Deduplicate
|
|
148
|
-
const uniqueMap = new Map<string, number>();
|
|
248
|
+
const uniqueMap = new Map<string, { weight: number; provider: string }>();
|
|
149
249
|
inputKeys.forEach(k => {
|
|
150
|
-
if (k.key.length > 0) uniqueMap.set(k.key, k.weight ?? 1.0);
|
|
250
|
+
if (k.key.length > 0) uniqueMap.set(k.key, { weight: k.weight ?? 1.0, provider: k.provider ?? 'default' });
|
|
151
251
|
});
|
|
152
252
|
|
|
153
253
|
if (uniqueMap.size < inputKeys.length) {
|
|
154
254
|
console.warn(`[ApiKeyManager] Removed ${inputKeys.length - uniqueMap.size} duplicate/empty keys.`);
|
|
155
255
|
}
|
|
156
256
|
|
|
157
|
-
this.keys = Array.from(uniqueMap.entries()).map(([key,
|
|
257
|
+
this.keys = Array.from(uniqueMap.entries()).map(([key, meta]) => ({
|
|
158
258
|
key,
|
|
159
259
|
failCount: 0,
|
|
160
260
|
failedAt: null,
|
|
@@ -165,15 +265,18 @@ export class ApiKeyManager {
|
|
|
165
265
|
totalRequests: 0,
|
|
166
266
|
halfOpenTestTime: null,
|
|
167
267
|
customCooldown: null,
|
|
168
|
-
weight: weight,
|
|
268
|
+
weight: meta.weight,
|
|
169
269
|
averageLatency: 0,
|
|
170
270
|
totalLatency: 0,
|
|
171
|
-
latencySamples: 0
|
|
271
|
+
latencySamples: 0,
|
|
272
|
+
provider: meta.provider,
|
|
172
273
|
}));
|
|
173
274
|
|
|
174
275
|
this.loadState();
|
|
175
276
|
}
|
|
176
277
|
|
|
278
|
+
// ─── Error Classification ────────────────────────────────────────────────
|
|
279
|
+
|
|
177
280
|
/**
|
|
178
281
|
* CLASSIFIES an error to determine handling strategy
|
|
179
282
|
*/
|
|
@@ -185,7 +288,12 @@ export class ApiKeyManager {
|
|
|
185
288
|
if (finishReason === 'SAFETY') return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
|
|
186
289
|
if (finishReason === 'RECITATION') return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
|
|
187
290
|
|
|
188
|
-
// 2. Check
|
|
291
|
+
// 2. Check timeout
|
|
292
|
+
if (error instanceof TimeoutError || error?.name === 'TimeoutError') {
|
|
293
|
+
return { type: 'TIMEOUT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 3. Check HTTP status codes
|
|
189
297
|
if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
|
|
190
298
|
return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
|
|
191
299
|
}
|
|
@@ -225,6 +333,8 @@ export class ApiKeyManager {
|
|
|
225
333
|
return null;
|
|
226
334
|
}
|
|
227
335
|
|
|
336
|
+
// ─── Cooldown ────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
228
338
|
private isOnCooldown(k: KeyState): boolean {
|
|
229
339
|
if (k.circuitState === 'DEAD') return true;
|
|
230
340
|
const now = Date.now();
|
|
@@ -232,6 +342,7 @@ export class ApiKeyManager {
|
|
|
232
342
|
if (k.circuitState === 'OPEN') {
|
|
233
343
|
if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
|
|
234
344
|
k.circuitState = 'HALF_OPEN';
|
|
345
|
+
this.emit('circuitHalfOpen', k.key);
|
|
235
346
|
return false;
|
|
236
347
|
}
|
|
237
348
|
return true;
|
|
@@ -246,6 +357,8 @@ export class ApiKeyManager {
|
|
|
246
357
|
return false;
|
|
247
358
|
}
|
|
248
359
|
|
|
360
|
+
// ─── Key Selection ───────────────────────────────────────────────────────
|
|
361
|
+
|
|
249
362
|
public getKey(): string | null {
|
|
250
363
|
// 1. Filter out dead and cooling down keys
|
|
251
364
|
const candidates = this.keys.filter(k => k.circuitState !== 'DEAD' && !this.isOnCooldown(k));
|
|
@@ -253,7 +366,10 @@ export class ApiKeyManager {
|
|
|
253
366
|
if (candidates.length === 0) {
|
|
254
367
|
// FALLBACK: Return oldest failed key (excluding DEAD)
|
|
255
368
|
const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
|
|
256
|
-
if (nonDead.length === 0)
|
|
369
|
+
if (nonDead.length === 0) {
|
|
370
|
+
this.emit('allKeysExhausted');
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
257
373
|
return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
|
|
258
374
|
}
|
|
259
375
|
|
|
@@ -268,10 +384,31 @@ export class ApiKeyManager {
|
|
|
268
384
|
return null;
|
|
269
385
|
}
|
|
270
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Get a key filtered by provider tag
|
|
389
|
+
*/
|
|
390
|
+
public getKeyByProvider(provider: string): string | null {
|
|
391
|
+
const candidates = this.keys.filter(k =>
|
|
392
|
+
k.provider === provider && k.circuitState !== 'DEAD' && !this.isOnCooldown(k)
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
if (candidates.length === 0) return null;
|
|
396
|
+
|
|
397
|
+
const selected = this.strategy.next(candidates);
|
|
398
|
+
if (selected) {
|
|
399
|
+
selected.lastUsed = Date.now();
|
|
400
|
+
this.saveState();
|
|
401
|
+
return selected.key;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
271
406
|
public getKeyCount(): number {
|
|
272
407
|
return this.keys.filter(k => k.circuitState !== 'DEAD').length;
|
|
273
408
|
}
|
|
274
409
|
|
|
410
|
+
// ─── Mark Success / Failed ───────────────────────────────────────────────
|
|
411
|
+
|
|
275
412
|
/**
|
|
276
413
|
* Mark success AND update latency stats
|
|
277
414
|
* @param durationMs Duration of the request in milliseconds
|
|
@@ -280,7 +417,11 @@ export class ApiKeyManager {
|
|
|
280
417
|
const k = this.keys.find(x => x.key === key);
|
|
281
418
|
if (!k) return;
|
|
282
419
|
|
|
283
|
-
|
|
420
|
+
const wasRecovering = k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD';
|
|
421
|
+
if (wasRecovering) {
|
|
422
|
+
console.log(`[Key Recovered] ...${key.slice(-4)}`);
|
|
423
|
+
this.emit('keyRecovered', key);
|
|
424
|
+
}
|
|
284
425
|
|
|
285
426
|
k.circuitState = 'CLOSED';
|
|
286
427
|
k.failCount = 0;
|
|
@@ -313,14 +454,17 @@ export class ApiKeyManager {
|
|
|
313
454
|
if (classification.markKeyDead) {
|
|
314
455
|
k.circuitState = 'DEAD';
|
|
315
456
|
console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed`);
|
|
457
|
+
this.emit('keyDead', key);
|
|
316
458
|
} else {
|
|
317
459
|
// State Transitions
|
|
318
460
|
if (k.circuitState === 'HALF_OPEN') {
|
|
319
461
|
k.circuitState = 'OPEN';
|
|
320
462
|
k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
|
|
463
|
+
this.emit('circuitOpen', key);
|
|
321
464
|
} else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
|
|
322
465
|
k.circuitState = 'OPEN';
|
|
323
466
|
k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
|
|
467
|
+
this.emit('circuitOpen', key);
|
|
324
468
|
}
|
|
325
469
|
}
|
|
326
470
|
this.saveState();
|
|
@@ -336,6 +480,8 @@ export class ApiKeyManager {
|
|
|
336
480
|
});
|
|
337
481
|
}
|
|
338
482
|
|
|
483
|
+
// ─── Backoff ─────────────────────────────────────────────────────────────
|
|
484
|
+
|
|
339
485
|
public calculateBackoff(attempt: number): number {
|
|
340
486
|
const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
|
|
341
487
|
const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
|
|
@@ -343,6 +489,8 @@ export class ApiKeyManager {
|
|
|
343
489
|
return capped + jitter;
|
|
344
490
|
}
|
|
345
491
|
|
|
492
|
+
// ─── Stats ───────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
346
494
|
public getStats(): ApiKeyManagerStats {
|
|
347
495
|
const total = this.keys.length;
|
|
348
496
|
const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
|
|
@@ -353,6 +501,181 @@ export class ApiKeyManager {
|
|
|
353
501
|
|
|
354
502
|
public _getKeys(): KeyState[] { return this.keys; }
|
|
355
503
|
|
|
504
|
+
// ─── execute() Wrapper ───────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Wraps the entire API call lifecycle into a single method.
|
|
508
|
+
*
|
|
509
|
+
* @example
|
|
510
|
+
* const result = await manager.execute(
|
|
511
|
+
* (key) => fetch(`https://api.example.com?key=${key}`),
|
|
512
|
+
* { maxRetries: 3, timeoutMs: 5000 }
|
|
513
|
+
* );
|
|
514
|
+
*/
|
|
515
|
+
public async execute<T>(
|
|
516
|
+
fn: (key: string, signal?: AbortSignal) => Promise<T>,
|
|
517
|
+
options?: ExecuteOptions
|
|
518
|
+
): Promise<T> {
|
|
519
|
+
const maxRetries = options?.maxRetries ?? 0;
|
|
520
|
+
const timeoutMs = options?.timeoutMs;
|
|
521
|
+
const finishReason = options?.finishReason;
|
|
522
|
+
|
|
523
|
+
// Bulkhead check
|
|
524
|
+
if (this.activeCalls >= this.maxConcurrency) {
|
|
525
|
+
this.emit('bulkheadRejected');
|
|
526
|
+
throw new BulkheadRejectionError();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
this.activeCalls++;
|
|
530
|
+
try {
|
|
531
|
+
return await this._executeWithRetry(fn, maxRetries, timeoutMs, finishReason);
|
|
532
|
+
} finally {
|
|
533
|
+
this.activeCalls--;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private async _executeWithRetry<T>(
|
|
538
|
+
fn: (key: string, signal?: AbortSignal) => Promise<T>,
|
|
539
|
+
maxRetries: number,
|
|
540
|
+
timeoutMs?: number,
|
|
541
|
+
finishReason?: string
|
|
542
|
+
): Promise<T> {
|
|
543
|
+
let lastError: any;
|
|
544
|
+
|
|
545
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
546
|
+
const key = this.getKey();
|
|
547
|
+
|
|
548
|
+
if (!key) {
|
|
549
|
+
// All keys exhausted — try fallback
|
|
550
|
+
if (this.fallbackFn) {
|
|
551
|
+
this.emit('fallback', 'all keys exhausted');
|
|
552
|
+
return this.fallbackFn();
|
|
553
|
+
}
|
|
554
|
+
throw new AllKeysExhaustedError();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const start = Date.now();
|
|
559
|
+
let result: T;
|
|
560
|
+
|
|
561
|
+
if (timeoutMs) {
|
|
562
|
+
result = await this._executeWithTimeout(fn, key, timeoutMs);
|
|
563
|
+
} else {
|
|
564
|
+
result = await fn(key);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const duration = Date.now() - start;
|
|
568
|
+
this.markSuccess(key, duration);
|
|
569
|
+
this.emit('executeSuccess', key, duration);
|
|
570
|
+
return result;
|
|
571
|
+
|
|
572
|
+
} catch (error: any) {
|
|
573
|
+
lastError = error;
|
|
574
|
+
const classification = this.classifyError(error, finishReason);
|
|
575
|
+
|
|
576
|
+
this.markFailed(key, classification);
|
|
577
|
+
this.emit('executeFailed', key, error);
|
|
578
|
+
|
|
579
|
+
if (!classification.retryable || attempt >= maxRetries) {
|
|
580
|
+
// Non-retryable or out of retries
|
|
581
|
+
if (this.fallbackFn && attempt >= maxRetries) {
|
|
582
|
+
this.emit('fallback', 'max retries exceeded');
|
|
583
|
+
return this.fallbackFn();
|
|
584
|
+
}
|
|
585
|
+
throw error;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Retry with backoff
|
|
589
|
+
const delay = this.calculateBackoff(attempt);
|
|
590
|
+
this.emit('retry', key, attempt + 1, delay);
|
|
591
|
+
await this._sleep(delay);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Should not reach here, but safety net
|
|
596
|
+
throw lastError;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private async _executeWithTimeout<T>(
|
|
600
|
+
fn: (key: string, signal?: AbortSignal) => Promise<T>,
|
|
601
|
+
key: string,
|
|
602
|
+
timeoutMs: number
|
|
603
|
+
): Promise<T> {
|
|
604
|
+
const controller = new AbortController();
|
|
605
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
const result = await Promise.race([
|
|
609
|
+
fn(key, controller.signal),
|
|
610
|
+
new Promise<never>((_, reject) => {
|
|
611
|
+
controller.signal.addEventListener('abort', () => {
|
|
612
|
+
reject(new TimeoutError(timeoutMs));
|
|
613
|
+
});
|
|
614
|
+
})
|
|
615
|
+
]);
|
|
616
|
+
return result;
|
|
617
|
+
} finally {
|
|
618
|
+
clearTimeout(timer);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private _sleep(ms: number): Promise<void> {
|
|
623
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ─── Health Checks ───────────────────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Set a health check function that tests if a key is operational
|
|
630
|
+
*/
|
|
631
|
+
public setHealthCheck(fn: (key: string) => Promise<boolean>) {
|
|
632
|
+
this.healthCheckFn = fn;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Start periodic health checks
|
|
637
|
+
* @param intervalMs How often to run health checks (default: 60s)
|
|
638
|
+
*/
|
|
639
|
+
public startHealthChecks(intervalMs: number = 60_000) {
|
|
640
|
+
this.stopHealthChecks(); // Clear any existing interval
|
|
641
|
+
this.healthCheckInterval = setInterval(() => this._runHealthChecks(), intervalMs);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Stop periodic health checks
|
|
646
|
+
*/
|
|
647
|
+
public stopHealthChecks() {
|
|
648
|
+
if (this.healthCheckInterval) {
|
|
649
|
+
clearInterval(this.healthCheckInterval);
|
|
650
|
+
this.healthCheckInterval = undefined;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private async _runHealthChecks() {
|
|
655
|
+
if (!this.healthCheckFn) return;
|
|
656
|
+
|
|
657
|
+
// Check non-DEAD keys that are in OPEN or HALF_OPEN state
|
|
658
|
+
const keysToCheck = this.keys.filter(k =>
|
|
659
|
+
k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN'
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
for (const k of keysToCheck) {
|
|
663
|
+
try {
|
|
664
|
+
const healthy = await this.healthCheckFn(k.key);
|
|
665
|
+
if (healthy) {
|
|
666
|
+
this.markSuccess(k.key);
|
|
667
|
+
this.emit('healthCheckPassed', k.key);
|
|
668
|
+
} else {
|
|
669
|
+
this.emit('healthCheckFailed', k.key, new Error('Health check returned false'));
|
|
670
|
+
}
|
|
671
|
+
} catch (error) {
|
|
672
|
+
this.emit('healthCheckFailed', k.key, error);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ─── Persistence ─────────────────────────────────────────────────────────
|
|
678
|
+
|
|
356
679
|
private saveState() {
|
|
357
680
|
if (!this.storage) return;
|
|
358
681
|
const state = this.keys.reduce((acc, k) => ({
|
|
@@ -369,7 +692,8 @@ export class ApiKeyManager {
|
|
|
369
692
|
weight: k.weight,
|
|
370
693
|
averageLatency: k.averageLatency,
|
|
371
694
|
totalLatency: k.totalLatency,
|
|
372
|
-
latencySamples: k.latencySamples
|
|
695
|
+
latencySamples: k.latencySamples,
|
|
696
|
+
provider: k.provider
|
|
373
697
|
}
|
|
374
698
|
}), {});
|
|
375
699
|
this.storage.setItem(this.storageKey, JSON.stringify(state));
|