@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/src/index.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Universal ApiKeyManager v2.0
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
- export interface ApiKeyManagerStats {
62
- total: number;
63
- healthy: number;
64
- cooling: number;
65
- 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
+ }
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
- export class ApiKeyManager {
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
- constructor(initialKeys: string[] | { key: string; weight?: number }[], storage?: any, strategy?: LoadBalancingStrategy) {
131
- // Simple in-memory storage mock if none provided
132
- this.storage = storage || {
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.strategy = strategy || new StandardStrategy();
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, weight]) => ({
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 HTTP status codes
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) return null;
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
- if (k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD') console.log(`[Key Recovered] ...${key.slice(-4)}`);
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));