@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/README.md +185 -39
- package/dist/index.d.ts +120 -29
- package/dist/index.js +348 -97
- package/dist/index.js.map +1 -1
- package/package.json +14 -5
- package/src/index.ts +453 -112
package/src/index.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Universal ApiKeyManager
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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(
|
|
92
|
-
key
|
|
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
|
|
115
|
-
if (finishReason === 'SAFETY') {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
284
|
-
this.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
k.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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));
|