@splashcodex/api-key-manager 1.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/src/index.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Universal ApiKeyManager v2.0
3
- * Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff
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
@@ -15,6 +21,13 @@ export interface KeyState {
15
21
  totalRequests: number;
16
22
  halfOpenTestTime: number | null;
17
23
  customCooldown: number | null; // From Retry-After header
24
+ // v2.0 Stats
25
+ weight: number; // 0.0 - 1.0 (Default 1.0)
26
+ averageLatency: number; // Rolling average latency in ms
27
+ totalLatency: number; // Sum of all latency checks (for calculating average)
28
+ latencySamples: number; // Number of samples
29
+ // v3.0 Fields
30
+ provider: string; // Provider tag (e.g. 'openai', 'gemini')
18
31
  }
19
32
 
20
33
  export type ErrorType =
@@ -24,6 +37,7 @@ export type ErrorType =
24
37
  | 'BAD_REQUEST' // 400 - Do not retry, fix request
25
38
  | 'SAFETY' // finishReason: SAFETY - Not a key issue
26
39
  | 'RECITATION' // finishReason: RECITATION - Not a key issue
40
+ | 'TIMEOUT' // Request timed out
27
41
  | 'UNKNOWN'; // Catch-all
28
42
 
29
43
  export interface ErrorClassification {
@@ -34,6 +48,45 @@ export interface ErrorClassification {
34
48
  markKeyDead: boolean;
35
49
  }
36
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
+
37
90
  const CONFIG = {
38
91
  MAX_CONSECUTIVE_FAILURES: 5,
39
92
  COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
@@ -53,43 +106,156 @@ const ERROR_PATTERNS = {
53
106
  isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
54
107
  };
55
108
 
56
- export interface ApiKeyManagerStats {
57
- total: number;
58
- healthy: number;
59
- cooling: number;
60
- dead: number;
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
+ }
123
+ }
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
+
134
+ /**
135
+ * Strategy Interface for selecting the next key
136
+ */
137
+ export interface LoadBalancingStrategy {
138
+ next(candidates: KeyState[]): KeyState | null;
139
+ }
140
+
141
+ /**
142
+ * Standard Strategy: Least Failed > Least Recently Used
143
+ */
144
+ export class StandardStrategy implements LoadBalancingStrategy {
145
+ next(candidates: KeyState[]): KeyState | null {
146
+ candidates.sort((a, b) => {
147
+ if (a.failCount !== b.failCount) return a.failCount - b.failCount;
148
+ return a.lastUsed - b.lastUsed;
149
+ });
150
+ return candidates[0] || null;
151
+ }
61
152
  }
62
153
 
63
- export class ApiKeyManager {
154
+ /**
155
+ * Weighted Strategy: Probabilistic selection based on weight
156
+ * Higher weight = Higher chance of selection
157
+ */
158
+ export class WeightedStrategy implements LoadBalancingStrategy {
159
+ next(candidates: KeyState[]): KeyState | null {
160
+ if (candidates.length === 0) return null;
161
+
162
+ const totalWeight = candidates.reduce((sum, k) => sum + k.weight, 0);
163
+ let random = Math.random() * totalWeight;
164
+
165
+ for (const key of candidates) {
166
+ random -= key.weight;
167
+ if (random <= 0) return key;
168
+ }
169
+
170
+ return candidates[0]; // Fallback
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Latency Strategy: Pick lowest average latency
176
+ */
177
+ export class LatencyStrategy implements LoadBalancingStrategy {
178
+ next(candidates: KeyState[]): KeyState | null {
179
+ if (candidates.length === 0) return null;
180
+ candidates.sort((a, b) => a.averageLatency - b.averageLatency);
181
+ return candidates[0];
182
+ }
183
+ }
184
+
185
+ // ─── Main Class ──────────────────────────────────────────────────────────────
186
+
187
+ export class ApiKeyManager extends EventEmitter {
64
188
  private keys: KeyState[] = [];
65
189
  private storageKey = 'api_rotation_state_v2';
66
- // Simplified Storage interface for Node.js environment
67
190
  private storage: any;
191
+ private strategy: LoadBalancingStrategy;
192
+ private fallbackFn?: () => any;
193
+
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
+ }
68
230
 
69
- constructor(initialKeys: string[], storage?: any) {
70
- // Simple in-memory storage mock if none provided (for testing/Node)
71
- this.storage = storage || {
231
+ this.storage = options.storage || {
72
232
  getItem: () => null,
73
233
  setItem: () => { },
74
234
  };
235
+ this.strategy = options.strategy || new StandardStrategy();
236
+ this.fallbackFn = options.fallbackFn;
237
+ this.maxConcurrency = options.concurrency || Infinity;
238
+
239
+ // Normalize input to objects
240
+ let inputKeys: { key: string; weight?: number; provider?: string }[] = [];
241
+ if (initialKeys.length > 0 && typeof initialKeys[0] === 'string') {
242
+ inputKeys = (initialKeys as string[]).flatMap(k => k.split(',').map(s => ({ key: s.trim(), weight: 1.0, provider: 'default' })));
243
+ } else {
244
+ inputKeys = initialKeys as { key: string; weight?: number; provider?: string }[];
245
+ }
75
246
 
76
- // 1. Sanitize & Deduplicate Keys "Even Better"
77
- const uniqueKeys = new Set<string>();
78
- initialKeys.forEach(rawKey => {
79
- // Handle comma-separated keys if they appear in a single string
80
- const parts = rawKey.split(',').map(s => s.trim()).filter(s => s.length > 0);
81
- parts.forEach(p => uniqueKeys.add(p));
247
+ // Deduplicate
248
+ const uniqueMap = new Map<string, { weight: number; provider: string }>();
249
+ inputKeys.forEach(k => {
250
+ if (k.key.length > 0) uniqueMap.set(k.key, { weight: k.weight ?? 1.0, provider: k.provider ?? 'default' });
82
251
  });
83
252
 
84
- // 2. Warn about duplicates/empty
85
- const sanitizedCount = uniqueKeys.size;
86
- const rawLength = initialKeys.length; // Might be misleading if split happened, but roughly
87
- if (sanitizedCount < rawLength) {
88
- console.warn(`[ApiKeyManager] Optimized key pool: Removed ${rawLength - sanitizedCount} duplicate/empty keys.`);
253
+ if (uniqueMap.size < inputKeys.length) {
254
+ console.warn(`[ApiKeyManager] Removed ${inputKeys.length - uniqueMap.size} duplicate/empty keys.`);
89
255
  }
90
256
 
91
- this.keys = Array.from(uniqueKeys).map(k => ({
92
- key: k,
257
+ this.keys = Array.from(uniqueMap.entries()).map(([key, meta]) => ({
258
+ key,
93
259
  failCount: 0,
94
260
  failedAt: null,
95
261
  isQuotaError: false,
@@ -99,11 +265,18 @@ export class ApiKeyManager {
99
265
  totalRequests: 0,
100
266
  halfOpenTestTime: null,
101
267
  customCooldown: null,
268
+ weight: meta.weight,
269
+ averageLatency: 0,
270
+ totalLatency: 0,
271
+ latencySamples: 0,
272
+ provider: meta.provider,
102
273
  }));
103
274
 
104
275
  this.loadState();
105
276
  }
106
277
 
278
+ // ─── Error Classification ────────────────────────────────────────────────
279
+
107
280
  /**
108
281
  * CLASSIFIES an error to determine handling strategy
109
282
  */
@@ -111,15 +284,16 @@ export class ApiKeyManager {
111
284
  const status = error?.status || error?.response?.status;
112
285
  const message = error?.message || error?.error?.message || String(error);
113
286
 
114
- // 1. Check finishReason first (for 200 responses with content issues)
115
- if (finishReason === 'SAFETY') {
116
- return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
117
- }
118
- if (finishReason === 'RECITATION') {
119
- return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
287
+ // 1. Check finishReason first
288
+ if (finishReason === 'SAFETY') return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
289
+ if (finishReason === 'RECITATION') return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
290
+
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 };
120
294
  }
121
295
 
122
- // 2. Check HTTP status codes
296
+ // 3. Check HTTP status codes
123
297
  if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
124
298
  return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
125
299
  }
@@ -143,9 +317,6 @@ export class ApiKeyManager {
143
317
  return { type: 'UNKNOWN', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
144
318
  }
145
319
 
146
- /**
147
- * Parses Retry-After header from error response
148
- */
149
320
  private parseRetryAfter(error: any): number | null {
150
321
  const retryAfter = error?.response?.headers?.['retry-after'] ||
151
322
  error?.headers?.['retry-after'] ||
@@ -153,43 +324,32 @@ export class ApiKeyManager {
153
324
 
154
325
  if (!retryAfter) return null;
155
326
 
156
- // If it's a number (seconds)
157
327
  const seconds = parseInt(retryAfter, 10);
158
328
  if (!isNaN(seconds)) return seconds * 1000;
159
329
 
160
- // If it's a date string
161
330
  const date = Date.parse(retryAfter);
162
331
  if (!isNaN(date)) return Math.max(0, date - Date.now());
163
332
 
164
333
  return null;
165
334
  }
166
335
 
167
- /**
168
- * HEALTH CHECK
169
- * Determines if a key is usable based on Circuit Breaker logic
170
- */
336
+ // ─── Cooldown ────────────────────────────────────────────────────────────
337
+
171
338
  private isOnCooldown(k: KeyState): boolean {
172
- // Dead keys are NEVER usable
173
339
  if (k.circuitState === 'DEAD') return true;
174
-
175
340
  const now = Date.now();
176
341
 
177
342
  if (k.circuitState === 'OPEN') {
178
- // Check if ready for HALF_OPEN test
179
343
  if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
180
344
  k.circuitState = 'HALF_OPEN';
345
+ this.emit('circuitHalfOpen', k.key);
181
346
  return false;
182
347
  }
183
348
  return true;
184
349
  }
185
350
 
186
- // Additional safeguard for custom cooldowns
187
- if (k.failedAt && k.customCooldown) {
188
- if (now - k.failedAt < k.customCooldown) return true;
189
- }
190
-
191
- // Standard cooldown check
192
351
  if (k.failedAt) {
352
+ if (k.customCooldown && now - k.failedAt < k.customCooldown) return true;
193
353
  const cooldown = k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT;
194
354
  if (now - k.failedAt < cooldown) return true;
195
355
  }
@@ -197,53 +357,70 @@ export class ApiKeyManager {
197
357
  return false;
198
358
  }
199
359
 
200
- /**
201
- * CORE ROTATION LOGIC
202
- * Returns the best available key
203
- */
360
+ // ─── Key Selection ───────────────────────────────────────────────────────
361
+
204
362
  public getKey(): string | null {
205
363
  // 1. Filter out dead and cooling down keys
206
- const candidates = this.keys.filter(k =>
207
- k.circuitState !== 'DEAD' && !this.isOnCooldown(k)
208
- );
364
+ const candidates = this.keys.filter(k => k.circuitState !== 'DEAD' && !this.isOnCooldown(k));
209
365
 
210
366
  if (candidates.length === 0) {
211
367
  // FALLBACK: Return oldest failed key (excluding DEAD)
212
368
  const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
213
- if (nonDead.length === 0) return null; // All keys are dead!
214
-
369
+ if (nonDead.length === 0) {
370
+ this.emit('allKeysExhausted');
371
+ return null;
372
+ }
215
373
  return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
216
374
  }
217
375
 
218
- // 2. Sort candidates: Pristine > Fewest Failures > Least Recently Used
219
- candidates.sort((a, b) => {
220
- if (a.failCount !== b.failCount) return a.failCount - b.failCount;
221
- return a.lastUsed - b.lastUsed;
222
- });
223
-
224
- const selected = candidates[0];
225
- selected.lastUsed = Date.now();
226
- this.saveState();
376
+ // 2. Delegate to Strategy
377
+ const selected = this.strategy.next(candidates);
227
378
 
228
- return selected.key;
379
+ if (selected) {
380
+ selected.lastUsed = Date.now();
381
+ this.saveState();
382
+ return selected.key;
383
+ }
384
+ return null;
229
385
  }
230
386
 
231
387
  /**
232
- * Get count of healthy (non-DEAD) keys
388
+ * Get a key filtered by provider tag
233
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
+
234
406
  public getKeyCount(): number {
235
407
  return this.keys.filter(k => k.circuitState !== 'DEAD').length;
236
408
  }
237
409
 
410
+ // ─── Mark Success / Failed ───────────────────────────────────────────────
411
+
238
412
  /**
239
- * FEEDBACK LOOP: Success
413
+ * Mark success AND update latency stats
414
+ * @param durationMs Duration of the request in milliseconds
240
415
  */
241
- public markSuccess(key: string) {
416
+ public markSuccess(key: string, durationMs?: number) {
242
417
  const k = this.keys.find(x => x.key === key);
243
418
  if (!k) return;
244
419
 
245
- if (k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD') {
420
+ const wasRecovering = k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD';
421
+ if (wasRecovering) {
246
422
  console.log(`[Key Recovered] ...${key.slice(-4)}`);
423
+ this.emit('keyRecovered', key);
247
424
  }
248
425
 
249
426
  k.circuitState = 'CLOSED';
@@ -254,21 +431,18 @@ export class ApiKeyManager {
254
431
  k.successCount++;
255
432
  k.totalRequests++;
256
433
 
434
+ if (durationMs !== undefined) {
435
+ k.totalLatency += durationMs;
436
+ k.latencySamples++;
437
+ k.averageLatency = k.totalLatency / k.latencySamples;
438
+ }
439
+
257
440
  this.saveState();
258
441
  }
259
442
 
260
- /**
261
- * FEEDBACK LOOP: Failure
262
- * Enhanced with error classification
263
- */
264
443
  public markFailed(key: string, classification: ErrorClassification) {
265
444
  const k = this.keys.find(x => x.key === key);
266
- if (!k) return;
267
-
268
- // Don't modify DEAD keys
269
- if (k.circuitState === 'DEAD') return;
270
-
271
- // If this error shouldn't mark the key as failed, skip
445
+ if (!k || k.circuitState === 'DEAD') return;
272
446
  if (!classification.markKeyFailed) return;
273
447
 
274
448
  k.failedAt = Date.now();
@@ -277,29 +451,25 @@ export class ApiKeyManager {
277
451
  k.isQuotaError = classification.type === 'QUOTA';
278
452
  k.customCooldown = classification.cooldownMs || null;
279
453
 
280
- // Permanent death for auth errors
281
454
  if (classification.markKeyDead) {
282
455
  k.circuitState = 'DEAD';
283
- console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed from rotation`);
284
- this.saveState();
285
- return;
286
- }
287
-
288
- // State Transitions
289
- if (k.circuitState === 'HALF_OPEN') {
290
- k.circuitState = 'OPEN';
291
- k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
292
- } else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
293
- k.circuitState = 'OPEN';
294
- k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
456
+ console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed`);
457
+ this.emit('keyDead', key);
458
+ } else {
459
+ // State Transitions
460
+ if (k.circuitState === 'HALF_OPEN') {
461
+ k.circuitState = 'OPEN';
462
+ k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
463
+ this.emit('circuitOpen', key);
464
+ } else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
465
+ k.circuitState = 'OPEN';
466
+ k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
467
+ this.emit('circuitOpen', key);
468
+ }
295
469
  }
296
-
297
470
  this.saveState();
298
471
  }
299
472
 
300
- /**
301
- * Legacy markFailed for backward compatibility
302
- */
303
473
  public markFailedLegacy(key: string, isQuota: boolean = false) {
304
474
  this.markFailed(key, {
305
475
  type: isQuota ? 'QUOTA' : 'TRANSIENT',
@@ -310,9 +480,8 @@ export class ApiKeyManager {
310
480
  });
311
481
  }
312
482
 
313
- /**
314
- * Calculate backoff delay with jitter
315
- */
483
+ // ─── Backoff ─────────────────────────────────────────────────────────────
484
+
316
485
  public calculateBackoff(attempt: number): number {
317
486
  const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
318
487
  const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
@@ -320,13 +489,8 @@ export class ApiKeyManager {
320
489
  return capped + jitter;
321
490
  }
322
491
 
492
+ // ─── Stats ───────────────────────────────────────────────────────────────
323
493
 
324
-
325
- // ... inside class ...
326
-
327
- /**
328
- * Get health statistics
329
- */
330
494
  public getStats(): ApiKeyManagerStats {
331
495
  const total = this.keys.length;
332
496
  const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
@@ -335,11 +499,183 @@ export class ApiKeyManager {
335
499
  return { total, healthy, cooling, dead };
336
500
  }
337
501
 
338
- // Helper for testing
339
- public _getKeys(): KeyState[] {
340
- return this.keys;
502
+ public _getKeys(): KeyState[] { return this.keys; }
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;
341
633
  }
342
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
+
343
679
  private saveState() {
344
680
  if (!this.storage) return;
345
681
  const state = this.keys.reduce((acc, k) => ({
@@ -353,6 +689,11 @@ export class ApiKeyManager {
353
689
  successCount: k.successCount,
354
690
  totalRequests: k.totalRequests,
355
691
  customCooldown: k.customCooldown,
692
+ weight: k.weight,
693
+ averageLatency: k.averageLatency,
694
+ totalLatency: k.totalLatency,
695
+ latencySamples: k.latencySamples,
696
+ provider: k.provider
356
697
  }
357
698
  }), {});
358
699
  this.storage.setItem(this.storageKey, JSON.stringify(state));