@splashcodex/api-key-manager 1.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 +81 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +308 -0
- package/dist/index.js.map +1 -0
- package/package.json +31 -0
- package/src/index.ts +374 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @splashcodex/api-key-manager
|
|
2
|
+
|
|
3
|
+
A robust, universal API Key Rotation and Management system designed for high-availability applications using rate-limited APIs (like Google Gemini).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **🔄 Automatic Key Rotation**: Seamlessly switches to the next available key upon exhaustion.
|
|
8
|
+
- **🔌 Circuit Breaker Pattern**: Automatically "opens" the circuit for keys that return 429s or 500s, preventing wasted requests.
|
|
9
|
+
- **💾 Persistence**: Remembers key states (failures, cooldowns) across restarts using local storage.
|
|
10
|
+
- **🧠 Smart Error Classification**: Distinguishes between transient errors (retryable), quota errors (cooldown), and auth errors (dead).
|
|
11
|
+
- **⏱️ Jittered Exponential Backoff**: Prevents thundering herd problems during retries.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @splashcodex/api-key-manager
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### 1. Initialize
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { ApiKeyManager } from '@splashcodex/api-key-manager';
|
|
25
|
+
|
|
26
|
+
// Initialize with a pool of keys
|
|
27
|
+
const apiKeys = [
|
|
28
|
+
"AIzaSy...",
|
|
29
|
+
"AIzaSy...",
|
|
30
|
+
"AIzaSy..."
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const manager = new ApiKeyManager(apiKeys);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Get a Key
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
const key = manager.getKey();
|
|
40
|
+
|
|
41
|
+
if (!key) {
|
|
42
|
+
throw new Error("All API keys are exhausted or cooling down.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Use the key with your API client
|
|
46
|
+
const client = new GoogleGenerativeAI(key);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 3. Report Results (The Feedback Loop)
|
|
50
|
+
|
|
51
|
+
Crucial Step: You must report success or failure back to the manager so it can update the circuit state.
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
try {
|
|
55
|
+
const response = await client.generateContent(prompt);
|
|
56
|
+
|
|
57
|
+
// ✅ REPORT SUCCESS
|
|
58
|
+
manager.markSuccess(key);
|
|
59
|
+
|
|
60
|
+
return response;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// ❌ REPORT FAILURE
|
|
63
|
+
// The manager will automatically classify the error (Quota vs Auth vs Transient)
|
|
64
|
+
const classification = manager.classifyError(error);
|
|
65
|
+
manager.markFailed(key, classification);
|
|
66
|
+
|
|
67
|
+
throw error; // Re-throw or handle accordingly
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Error Classification
|
|
72
|
+
|
|
73
|
+
The `classifyError` method automatically detects:
|
|
74
|
+
- **429 / Quota**: Marks key as 'OPEN' for a cooldown period (default 5 mins).
|
|
75
|
+
- **403 / Auth**: Marks key as 'DEAD' permanently.
|
|
76
|
+
- **500 / Transient**: Marks key as 'OPEN' for a short cooldown (default 1 min).
|
|
77
|
+
- **FinishReason: SAFETY/RECITATION**: specific to Gemini, does not penalize the key.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
ISC
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal ApiKeyManager v2.0
|
|
3
|
+
* Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff
|
|
4
|
+
* Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
|
|
5
|
+
*/
|
|
6
|
+
export interface KeyState {
|
|
7
|
+
key: string;
|
|
8
|
+
failCount: number;
|
|
9
|
+
failedAt: number | null;
|
|
10
|
+
isQuotaError: boolean;
|
|
11
|
+
circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN' | 'DEAD';
|
|
12
|
+
lastUsed: number;
|
|
13
|
+
successCount: number;
|
|
14
|
+
totalRequests: number;
|
|
15
|
+
halfOpenTestTime: number | null;
|
|
16
|
+
customCooldown: number | null;
|
|
17
|
+
}
|
|
18
|
+
export type ErrorType = 'QUOTA' | 'TRANSIENT' | 'AUTH' | 'BAD_REQUEST' | 'SAFETY' | 'RECITATION' | 'UNKNOWN';
|
|
19
|
+
export interface ErrorClassification {
|
|
20
|
+
type: ErrorType;
|
|
21
|
+
retryable: boolean;
|
|
22
|
+
cooldownMs: number;
|
|
23
|
+
markKeyFailed: boolean;
|
|
24
|
+
markKeyDead: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface ApiKeyManagerStats {
|
|
27
|
+
total: number;
|
|
28
|
+
healthy: number;
|
|
29
|
+
cooling: number;
|
|
30
|
+
dead: number;
|
|
31
|
+
}
|
|
32
|
+
export declare class ApiKeyManager {
|
|
33
|
+
private keys;
|
|
34
|
+
private storageKey;
|
|
35
|
+
private storage;
|
|
36
|
+
constructor(initialKeys: string[], storage?: any);
|
|
37
|
+
/**
|
|
38
|
+
* CLASSIFIES an error to determine handling strategy
|
|
39
|
+
*/
|
|
40
|
+
classifyError(error: any, finishReason?: string): ErrorClassification;
|
|
41
|
+
/**
|
|
42
|
+
* Parses Retry-After header from error response
|
|
43
|
+
*/
|
|
44
|
+
private parseRetryAfter;
|
|
45
|
+
/**
|
|
46
|
+
* HEALTH CHECK
|
|
47
|
+
* Determines if a key is usable based on Circuit Breaker logic
|
|
48
|
+
*/
|
|
49
|
+
private isOnCooldown;
|
|
50
|
+
/**
|
|
51
|
+
* CORE ROTATION LOGIC
|
|
52
|
+
* Returns the best available key
|
|
53
|
+
*/
|
|
54
|
+
getKey(): string | null;
|
|
55
|
+
/**
|
|
56
|
+
* Get count of healthy (non-DEAD) keys
|
|
57
|
+
*/
|
|
58
|
+
getKeyCount(): number;
|
|
59
|
+
/**
|
|
60
|
+
* FEEDBACK LOOP: Success
|
|
61
|
+
*/
|
|
62
|
+
markSuccess(key: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* FEEDBACK LOOP: Failure
|
|
65
|
+
* Enhanced with error classification
|
|
66
|
+
*/
|
|
67
|
+
markFailed(key: string, classification: ErrorClassification): void;
|
|
68
|
+
/**
|
|
69
|
+
* Legacy markFailed for backward compatibility
|
|
70
|
+
*/
|
|
71
|
+
markFailedLegacy(key: string, isQuota?: boolean): void;
|
|
72
|
+
/**
|
|
73
|
+
* Calculate backoff delay with jitter
|
|
74
|
+
*/
|
|
75
|
+
calculateBackoff(attempt: number): number;
|
|
76
|
+
/**
|
|
77
|
+
* Get health statistics
|
|
78
|
+
*/
|
|
79
|
+
getStats(): ApiKeyManagerStats;
|
|
80
|
+
_getKeys(): KeyState[];
|
|
81
|
+
private saveState;
|
|
82
|
+
private loadState;
|
|
83
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Universal ApiKeyManager v2.0
|
|
4
|
+
* Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff
|
|
5
|
+
* Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.ApiKeyManager = void 0;
|
|
9
|
+
const CONFIG = {
|
|
10
|
+
MAX_CONSECUTIVE_FAILURES: 5,
|
|
11
|
+
COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
|
|
12
|
+
COOLDOWN_QUOTA: 5 * 60 * 1000, // 5 minutes (default if no Retry-After)
|
|
13
|
+
COOLDOWN_QUOTA_DAILY: 60 * 60 * 1000, // 1 hour for RPD exhaustion
|
|
14
|
+
HALF_OPEN_TEST_DELAY: 60 * 1000, // 1 minute after open
|
|
15
|
+
MAX_BACKOFF: 64 * 1000, // 64 seconds max
|
|
16
|
+
BASE_BACKOFF: 1000, // 1 second base
|
|
17
|
+
};
|
|
18
|
+
// Error classification patterns
|
|
19
|
+
const ERROR_PATTERNS = {
|
|
20
|
+
isQuotaError: /429|quota|exhausted|resource.?exhausted|too.?many.?requests|rate.?limit/i,
|
|
21
|
+
isAuthError: /403|permission.?denied|invalid.?api.?key|unauthorized|unauthenticated/i,
|
|
22
|
+
isSafetyBlock: /safety|blocked|recitation|harmful/i,
|
|
23
|
+
isTransient: /500|502|503|504|internal|unavailable|deadline|timeout|overloaded/i,
|
|
24
|
+
isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
|
|
25
|
+
};
|
|
26
|
+
class ApiKeyManager {
|
|
27
|
+
keys = [];
|
|
28
|
+
storageKey = 'api_rotation_state_v2';
|
|
29
|
+
// Simplified Storage interface for Node.js environment
|
|
30
|
+
storage;
|
|
31
|
+
constructor(initialKeys, storage) {
|
|
32
|
+
// Simple in-memory storage mock if none provided (for testing/Node)
|
|
33
|
+
this.storage = storage || {
|
|
34
|
+
getItem: () => null,
|
|
35
|
+
setItem: () => { },
|
|
36
|
+
};
|
|
37
|
+
// 1. Sanitize & Deduplicate Keys "Even Better"
|
|
38
|
+
const uniqueKeys = new Set();
|
|
39
|
+
initialKeys.forEach(rawKey => {
|
|
40
|
+
// Handle comma-separated keys if they appear in a single string
|
|
41
|
+
const parts = rawKey.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
42
|
+
parts.forEach(p => uniqueKeys.add(p));
|
|
43
|
+
});
|
|
44
|
+
// 2. Warn about duplicates/empty
|
|
45
|
+
const sanitizedCount = uniqueKeys.size;
|
|
46
|
+
const rawLength = initialKeys.length; // Might be misleading if split happened, but roughly
|
|
47
|
+
if (sanitizedCount < rawLength) {
|
|
48
|
+
console.warn(`[ApiKeyManager] Optimized key pool: Removed ${rawLength - sanitizedCount} duplicate/empty keys.`);
|
|
49
|
+
}
|
|
50
|
+
this.keys = Array.from(uniqueKeys).map(k => ({
|
|
51
|
+
key: k,
|
|
52
|
+
failCount: 0,
|
|
53
|
+
failedAt: null,
|
|
54
|
+
isQuotaError: false,
|
|
55
|
+
circuitState: 'CLOSED',
|
|
56
|
+
lastUsed: 0,
|
|
57
|
+
successCount: 0,
|
|
58
|
+
totalRequests: 0,
|
|
59
|
+
halfOpenTestTime: null,
|
|
60
|
+
customCooldown: null,
|
|
61
|
+
}));
|
|
62
|
+
this.loadState();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* CLASSIFIES an error to determine handling strategy
|
|
66
|
+
*/
|
|
67
|
+
classifyError(error, finishReason) {
|
|
68
|
+
const status = error?.status || error?.response?.status;
|
|
69
|
+
const message = error?.message || error?.error?.message || String(error);
|
|
70
|
+
// 1. Check finishReason first (for 200 responses with content issues)
|
|
71
|
+
if (finishReason === 'SAFETY') {
|
|
72
|
+
return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
|
|
73
|
+
}
|
|
74
|
+
if (finishReason === 'RECITATION') {
|
|
75
|
+
return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
|
|
76
|
+
}
|
|
77
|
+
// 2. Check HTTP status codes
|
|
78
|
+
if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
|
|
79
|
+
return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
|
|
80
|
+
}
|
|
81
|
+
if (status === 429 || ERROR_PATTERNS.isQuotaError.test(message)) {
|
|
82
|
+
const retryAfter = this.parseRetryAfter(error);
|
|
83
|
+
return {
|
|
84
|
+
type: 'QUOTA',
|
|
85
|
+
retryable: true,
|
|
86
|
+
cooldownMs: retryAfter || CONFIG.COOLDOWN_QUOTA,
|
|
87
|
+
markKeyFailed: true,
|
|
88
|
+
markKeyDead: false
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (status === 400 || ERROR_PATTERNS.isBadRequest.test(message)) {
|
|
92
|
+
return { type: 'BAD_REQUEST', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
|
|
93
|
+
}
|
|
94
|
+
if (ERROR_PATTERNS.isTransient.test(message) || [500, 502, 503, 504].includes(status)) {
|
|
95
|
+
return { type: 'TRANSIENT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
|
|
96
|
+
}
|
|
97
|
+
return { type: 'UNKNOWN', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parses Retry-After header from error response
|
|
101
|
+
*/
|
|
102
|
+
parseRetryAfter(error) {
|
|
103
|
+
const retryAfter = error?.response?.headers?.['retry-after'] ||
|
|
104
|
+
error?.headers?.['retry-after'] ||
|
|
105
|
+
error?.retryAfter;
|
|
106
|
+
if (!retryAfter)
|
|
107
|
+
return null;
|
|
108
|
+
// If it's a number (seconds)
|
|
109
|
+
const seconds = parseInt(retryAfter, 10);
|
|
110
|
+
if (!isNaN(seconds))
|
|
111
|
+
return seconds * 1000;
|
|
112
|
+
// If it's a date string
|
|
113
|
+
const date = Date.parse(retryAfter);
|
|
114
|
+
if (!isNaN(date))
|
|
115
|
+
return Math.max(0, date - Date.now());
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* HEALTH CHECK
|
|
120
|
+
* Determines if a key is usable based on Circuit Breaker logic
|
|
121
|
+
*/
|
|
122
|
+
isOnCooldown(k) {
|
|
123
|
+
// Dead keys are NEVER usable
|
|
124
|
+
if (k.circuitState === 'DEAD')
|
|
125
|
+
return true;
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
if (k.circuitState === 'OPEN') {
|
|
128
|
+
// Check if ready for HALF_OPEN test
|
|
129
|
+
if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
|
|
130
|
+
k.circuitState = 'HALF_OPEN';
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// Additional safeguard for custom cooldowns
|
|
136
|
+
if (k.failedAt && k.customCooldown) {
|
|
137
|
+
if (now - k.failedAt < k.customCooldown)
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// Standard cooldown check
|
|
141
|
+
if (k.failedAt) {
|
|
142
|
+
const cooldown = k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT;
|
|
143
|
+
if (now - k.failedAt < cooldown)
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* CORE ROTATION LOGIC
|
|
150
|
+
* Returns the best available key
|
|
151
|
+
*/
|
|
152
|
+
getKey() {
|
|
153
|
+
// 1. Filter out dead and cooling down keys
|
|
154
|
+
const candidates = this.keys.filter(k => k.circuitState !== 'DEAD' && !this.isOnCooldown(k));
|
|
155
|
+
if (candidates.length === 0) {
|
|
156
|
+
// FALLBACK: Return oldest failed key (excluding DEAD)
|
|
157
|
+
const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
|
|
158
|
+
if (nonDead.length === 0)
|
|
159
|
+
return null; // All keys are dead!
|
|
160
|
+
return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
|
|
161
|
+
}
|
|
162
|
+
// 2. Sort candidates: Pristine > Fewest Failures > Least Recently Used
|
|
163
|
+
candidates.sort((a, b) => {
|
|
164
|
+
if (a.failCount !== b.failCount)
|
|
165
|
+
return a.failCount - b.failCount;
|
|
166
|
+
return a.lastUsed - b.lastUsed;
|
|
167
|
+
});
|
|
168
|
+
const selected = candidates[0];
|
|
169
|
+
selected.lastUsed = Date.now();
|
|
170
|
+
this.saveState();
|
|
171
|
+
return selected.key;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Get count of healthy (non-DEAD) keys
|
|
175
|
+
*/
|
|
176
|
+
getKeyCount() {
|
|
177
|
+
return this.keys.filter(k => k.circuitState !== 'DEAD').length;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* FEEDBACK LOOP: Success
|
|
181
|
+
*/
|
|
182
|
+
markSuccess(key) {
|
|
183
|
+
const k = this.keys.find(x => x.key === key);
|
|
184
|
+
if (!k)
|
|
185
|
+
return;
|
|
186
|
+
if (k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD') {
|
|
187
|
+
console.log(`[Key Recovered] ...${key.slice(-4)}`);
|
|
188
|
+
}
|
|
189
|
+
k.circuitState = 'CLOSED';
|
|
190
|
+
k.failCount = 0;
|
|
191
|
+
k.failedAt = null;
|
|
192
|
+
k.isQuotaError = false;
|
|
193
|
+
k.customCooldown = null;
|
|
194
|
+
k.successCount++;
|
|
195
|
+
k.totalRequests++;
|
|
196
|
+
this.saveState();
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* FEEDBACK LOOP: Failure
|
|
200
|
+
* Enhanced with error classification
|
|
201
|
+
*/
|
|
202
|
+
markFailed(key, classification) {
|
|
203
|
+
const k = this.keys.find(x => x.key === key);
|
|
204
|
+
if (!k)
|
|
205
|
+
return;
|
|
206
|
+
// Don't modify DEAD keys
|
|
207
|
+
if (k.circuitState === 'DEAD')
|
|
208
|
+
return;
|
|
209
|
+
// If this error shouldn't mark the key as failed, skip
|
|
210
|
+
if (!classification.markKeyFailed)
|
|
211
|
+
return;
|
|
212
|
+
k.failedAt = Date.now();
|
|
213
|
+
k.failCount++;
|
|
214
|
+
k.totalRequests++;
|
|
215
|
+
k.isQuotaError = classification.type === 'QUOTA';
|
|
216
|
+
k.customCooldown = classification.cooldownMs || null;
|
|
217
|
+
// Permanent death for auth errors
|
|
218
|
+
if (classification.markKeyDead) {
|
|
219
|
+
k.circuitState = 'DEAD';
|
|
220
|
+
console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed from rotation`);
|
|
221
|
+
this.saveState();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// State Transitions
|
|
225
|
+
if (k.circuitState === 'HALF_OPEN') {
|
|
226
|
+
k.circuitState = 'OPEN';
|
|
227
|
+
k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
|
|
228
|
+
}
|
|
229
|
+
else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
|
|
230
|
+
k.circuitState = 'OPEN';
|
|
231
|
+
k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
|
|
232
|
+
}
|
|
233
|
+
this.saveState();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Legacy markFailed for backward compatibility
|
|
237
|
+
*/
|
|
238
|
+
markFailedLegacy(key, isQuota = false) {
|
|
239
|
+
this.markFailed(key, {
|
|
240
|
+
type: isQuota ? 'QUOTA' : 'TRANSIENT',
|
|
241
|
+
retryable: true,
|
|
242
|
+
cooldownMs: isQuota ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT,
|
|
243
|
+
markKeyFailed: true,
|
|
244
|
+
markKeyDead: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Calculate backoff delay with jitter
|
|
249
|
+
*/
|
|
250
|
+
calculateBackoff(attempt) {
|
|
251
|
+
const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
|
|
252
|
+
const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
|
|
253
|
+
const jitter = Math.random() * 1000;
|
|
254
|
+
return capped + jitter;
|
|
255
|
+
}
|
|
256
|
+
// ... inside class ...
|
|
257
|
+
/**
|
|
258
|
+
* Get health statistics
|
|
259
|
+
*/
|
|
260
|
+
getStats() {
|
|
261
|
+
const total = this.keys.length;
|
|
262
|
+
const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
|
|
263
|
+
const cooling = this.keys.filter(k => k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN').length;
|
|
264
|
+
const healthy = total - dead - cooling;
|
|
265
|
+
return { total, healthy, cooling, dead };
|
|
266
|
+
}
|
|
267
|
+
// Helper for testing
|
|
268
|
+
_getKeys() {
|
|
269
|
+
return this.keys;
|
|
270
|
+
}
|
|
271
|
+
saveState() {
|
|
272
|
+
if (!this.storage)
|
|
273
|
+
return;
|
|
274
|
+
const state = this.keys.reduce((acc, k) => ({
|
|
275
|
+
...acc,
|
|
276
|
+
[k.key]: {
|
|
277
|
+
failCount: k.failCount,
|
|
278
|
+
failedAt: k.failedAt,
|
|
279
|
+
isQuotaError: k.isQuotaError,
|
|
280
|
+
circuitState: k.circuitState,
|
|
281
|
+
lastUsed: k.lastUsed,
|
|
282
|
+
successCount: k.successCount,
|
|
283
|
+
totalRequests: k.totalRequests,
|
|
284
|
+
customCooldown: k.customCooldown,
|
|
285
|
+
}
|
|
286
|
+
}), {});
|
|
287
|
+
this.storage.setItem(this.storageKey, JSON.stringify(state));
|
|
288
|
+
}
|
|
289
|
+
loadState() {
|
|
290
|
+
if (!this.storage)
|
|
291
|
+
return;
|
|
292
|
+
try {
|
|
293
|
+
const raw = this.storage.getItem(this.storageKey);
|
|
294
|
+
if (!raw)
|
|
295
|
+
return;
|
|
296
|
+
const data = JSON.parse(raw);
|
|
297
|
+
this.keys.forEach(k => {
|
|
298
|
+
if (data[k.key])
|
|
299
|
+
Object.assign(k, data[k.key]);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
console.error("Failed to load key state");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
exports.ApiKeyManager = ApiKeyManager;
|
|
308
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@splashcodex/api-key-manager",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Universal API Key Rotation System for rate-limited APIs",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "ts-node tests/index.test.ts",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"api",
|
|
18
|
+
"key",
|
|
19
|
+
"rotation",
|
|
20
|
+
"gemini",
|
|
21
|
+
"rate-limit",
|
|
22
|
+
"circuit-breaker"
|
|
23
|
+
],
|
|
24
|
+
"author": "Antigravity",
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.3.3",
|
|
28
|
+
"ts-node": "^10.9.2",
|
|
29
|
+
"@types/node": "^20.10.5"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal ApiKeyManager v2.0
|
|
3
|
+
* Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff
|
|
4
|
+
* Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface KeyState {
|
|
8
|
+
key: string;
|
|
9
|
+
failCount: number; // Consecutive failures
|
|
10
|
+
failedAt: number | null; // Timestamp of last failure
|
|
11
|
+
isQuotaError: boolean; // Was last error a 429?
|
|
12
|
+
circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN' | 'DEAD';
|
|
13
|
+
lastUsed: number;
|
|
14
|
+
successCount: number;
|
|
15
|
+
totalRequests: number;
|
|
16
|
+
halfOpenTestTime: number | null;
|
|
17
|
+
customCooldown: number | null; // From Retry-After header
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ErrorType =
|
|
21
|
+
| 'QUOTA' // 429 - Rotate key, respect cooldown
|
|
22
|
+
| 'TRANSIENT' // 500/503/504 - Retry with backoff
|
|
23
|
+
| 'AUTH' // 403 - Key is dead, remove from pool
|
|
24
|
+
| 'BAD_REQUEST' // 400 - Do not retry, fix request
|
|
25
|
+
| 'SAFETY' // finishReason: SAFETY - Not a key issue
|
|
26
|
+
| 'RECITATION' // finishReason: RECITATION - Not a key issue
|
|
27
|
+
| 'UNKNOWN'; // Catch-all
|
|
28
|
+
|
|
29
|
+
export interface ErrorClassification {
|
|
30
|
+
type: ErrorType;
|
|
31
|
+
retryable: boolean;
|
|
32
|
+
cooldownMs: number;
|
|
33
|
+
markKeyFailed: boolean;
|
|
34
|
+
markKeyDead: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CONFIG = {
|
|
38
|
+
MAX_CONSECUTIVE_FAILURES: 5,
|
|
39
|
+
COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
|
|
40
|
+
COOLDOWN_QUOTA: 5 * 60 * 1000, // 5 minutes (default if no Retry-After)
|
|
41
|
+
COOLDOWN_QUOTA_DAILY: 60 * 60 * 1000, // 1 hour for RPD exhaustion
|
|
42
|
+
HALF_OPEN_TEST_DELAY: 60 * 1000, // 1 minute after open
|
|
43
|
+
MAX_BACKOFF: 64 * 1000, // 64 seconds max
|
|
44
|
+
BASE_BACKOFF: 1000, // 1 second base
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Error classification patterns
|
|
48
|
+
const ERROR_PATTERNS = {
|
|
49
|
+
isQuotaError: /429|quota|exhausted|resource.?exhausted|too.?many.?requests|rate.?limit/i,
|
|
50
|
+
isAuthError: /403|permission.?denied|invalid.?api.?key|unauthorized|unauthenticated/i,
|
|
51
|
+
isSafetyBlock: /safety|blocked|recitation|harmful/i,
|
|
52
|
+
isTransient: /500|502|503|504|internal|unavailable|deadline|timeout|overloaded/i,
|
|
53
|
+
isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface ApiKeyManagerStats {
|
|
57
|
+
total: number;
|
|
58
|
+
healthy: number;
|
|
59
|
+
cooling: number;
|
|
60
|
+
dead: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ApiKeyManager {
|
|
64
|
+
private keys: KeyState[] = [];
|
|
65
|
+
private storageKey = 'api_rotation_state_v2';
|
|
66
|
+
// Simplified Storage interface for Node.js environment
|
|
67
|
+
private storage: any;
|
|
68
|
+
|
|
69
|
+
constructor(initialKeys: string[], storage?: any) {
|
|
70
|
+
// Simple in-memory storage mock if none provided (for testing/Node)
|
|
71
|
+
this.storage = storage || {
|
|
72
|
+
getItem: () => null,
|
|
73
|
+
setItem: () => { },
|
|
74
|
+
};
|
|
75
|
+
|
|
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));
|
|
82
|
+
});
|
|
83
|
+
|
|
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.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.keys = Array.from(uniqueKeys).map(k => ({
|
|
92
|
+
key: k,
|
|
93
|
+
failCount: 0,
|
|
94
|
+
failedAt: null,
|
|
95
|
+
isQuotaError: false,
|
|
96
|
+
circuitState: 'CLOSED',
|
|
97
|
+
lastUsed: 0,
|
|
98
|
+
successCount: 0,
|
|
99
|
+
totalRequests: 0,
|
|
100
|
+
halfOpenTestTime: null,
|
|
101
|
+
customCooldown: null,
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
this.loadState();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CLASSIFIES an error to determine handling strategy
|
|
109
|
+
*/
|
|
110
|
+
public classifyError(error: any, finishReason?: string): ErrorClassification {
|
|
111
|
+
const status = error?.status || error?.response?.status;
|
|
112
|
+
const message = error?.message || error?.error?.message || String(error);
|
|
113
|
+
|
|
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 };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2. Check HTTP status codes
|
|
123
|
+
if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
|
|
124
|
+
return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
|
|
125
|
+
}
|
|
126
|
+
if (status === 429 || ERROR_PATTERNS.isQuotaError.test(message)) {
|
|
127
|
+
const retryAfter = this.parseRetryAfter(error);
|
|
128
|
+
return {
|
|
129
|
+
type: 'QUOTA',
|
|
130
|
+
retryable: true,
|
|
131
|
+
cooldownMs: retryAfter || CONFIG.COOLDOWN_QUOTA,
|
|
132
|
+
markKeyFailed: true,
|
|
133
|
+
markKeyDead: false
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (status === 400 || ERROR_PATTERNS.isBadRequest.test(message)) {
|
|
137
|
+
return { type: 'BAD_REQUEST', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
|
|
138
|
+
}
|
|
139
|
+
if (ERROR_PATTERNS.isTransient.test(message) || [500, 502, 503, 504].includes(status)) {
|
|
140
|
+
return { type: 'TRANSIENT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { type: 'UNKNOWN', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parses Retry-After header from error response
|
|
148
|
+
*/
|
|
149
|
+
private parseRetryAfter(error: any): number | null {
|
|
150
|
+
const retryAfter = error?.response?.headers?.['retry-after'] ||
|
|
151
|
+
error?.headers?.['retry-after'] ||
|
|
152
|
+
error?.retryAfter;
|
|
153
|
+
|
|
154
|
+
if (!retryAfter) return null;
|
|
155
|
+
|
|
156
|
+
// If it's a number (seconds)
|
|
157
|
+
const seconds = parseInt(retryAfter, 10);
|
|
158
|
+
if (!isNaN(seconds)) return seconds * 1000;
|
|
159
|
+
|
|
160
|
+
// If it's a date string
|
|
161
|
+
const date = Date.parse(retryAfter);
|
|
162
|
+
if (!isNaN(date)) return Math.max(0, date - Date.now());
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* HEALTH CHECK
|
|
169
|
+
* Determines if a key is usable based on Circuit Breaker logic
|
|
170
|
+
*/
|
|
171
|
+
private isOnCooldown(k: KeyState): boolean {
|
|
172
|
+
// Dead keys are NEVER usable
|
|
173
|
+
if (k.circuitState === 'DEAD') return true;
|
|
174
|
+
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
|
|
177
|
+
if (k.circuitState === 'OPEN') {
|
|
178
|
+
// Check if ready for HALF_OPEN test
|
|
179
|
+
if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
|
|
180
|
+
k.circuitState = 'HALF_OPEN';
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
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
|
+
if (k.failedAt) {
|
|
193
|
+
const cooldown = k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT;
|
|
194
|
+
if (now - k.failedAt < cooldown) return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* CORE ROTATION LOGIC
|
|
202
|
+
* Returns the best available key
|
|
203
|
+
*/
|
|
204
|
+
public getKey(): string | null {
|
|
205
|
+
// 1. Filter out dead and cooling down keys
|
|
206
|
+
const candidates = this.keys.filter(k =>
|
|
207
|
+
k.circuitState !== 'DEAD' && !this.isOnCooldown(k)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (candidates.length === 0) {
|
|
211
|
+
// FALLBACK: Return oldest failed key (excluding DEAD)
|
|
212
|
+
const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
|
|
213
|
+
if (nonDead.length === 0) return null; // All keys are dead!
|
|
214
|
+
|
|
215
|
+
return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
|
|
216
|
+
}
|
|
217
|
+
|
|
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();
|
|
227
|
+
|
|
228
|
+
return selected.key;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get count of healthy (non-DEAD) keys
|
|
233
|
+
*/
|
|
234
|
+
public getKeyCount(): number {
|
|
235
|
+
return this.keys.filter(k => k.circuitState !== 'DEAD').length;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* FEEDBACK LOOP: Success
|
|
240
|
+
*/
|
|
241
|
+
public markSuccess(key: string) {
|
|
242
|
+
const k = this.keys.find(x => x.key === key);
|
|
243
|
+
if (!k) return;
|
|
244
|
+
|
|
245
|
+
if (k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD') {
|
|
246
|
+
console.log(`[Key Recovered] ...${key.slice(-4)}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
k.circuitState = 'CLOSED';
|
|
250
|
+
k.failCount = 0;
|
|
251
|
+
k.failedAt = null;
|
|
252
|
+
k.isQuotaError = false;
|
|
253
|
+
k.customCooldown = null;
|
|
254
|
+
k.successCount++;
|
|
255
|
+
k.totalRequests++;
|
|
256
|
+
|
|
257
|
+
this.saveState();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* FEEDBACK LOOP: Failure
|
|
262
|
+
* Enhanced with error classification
|
|
263
|
+
*/
|
|
264
|
+
public markFailed(key: string, classification: ErrorClassification) {
|
|
265
|
+
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
|
|
272
|
+
if (!classification.markKeyFailed) return;
|
|
273
|
+
|
|
274
|
+
k.failedAt = Date.now();
|
|
275
|
+
k.failCount++;
|
|
276
|
+
k.totalRequests++;
|
|
277
|
+
k.isQuotaError = classification.type === 'QUOTA';
|
|
278
|
+
k.customCooldown = classification.cooldownMs || null;
|
|
279
|
+
|
|
280
|
+
// Permanent death for auth errors
|
|
281
|
+
if (classification.markKeyDead) {
|
|
282
|
+
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);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.saveState();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Legacy markFailed for backward compatibility
|
|
302
|
+
*/
|
|
303
|
+
public markFailedLegacy(key: string, isQuota: boolean = false) {
|
|
304
|
+
this.markFailed(key, {
|
|
305
|
+
type: isQuota ? 'QUOTA' : 'TRANSIENT',
|
|
306
|
+
retryable: true,
|
|
307
|
+
cooldownMs: isQuota ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT,
|
|
308
|
+
markKeyFailed: true,
|
|
309
|
+
markKeyDead: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Calculate backoff delay with jitter
|
|
315
|
+
*/
|
|
316
|
+
public calculateBackoff(attempt: number): number {
|
|
317
|
+
const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
|
|
318
|
+
const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
|
|
319
|
+
const jitter = Math.random() * 1000;
|
|
320
|
+
return capped + jitter;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
// ... inside class ...
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get health statistics
|
|
329
|
+
*/
|
|
330
|
+
public getStats(): ApiKeyManagerStats {
|
|
331
|
+
const total = this.keys.length;
|
|
332
|
+
const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
|
|
333
|
+
const cooling = this.keys.filter(k => k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN').length;
|
|
334
|
+
const healthy = total - dead - cooling;
|
|
335
|
+
return { total, healthy, cooling, dead };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Helper for testing
|
|
339
|
+
public _getKeys(): KeyState[] {
|
|
340
|
+
return this.keys;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private saveState() {
|
|
344
|
+
if (!this.storage) return;
|
|
345
|
+
const state = this.keys.reduce((acc, k) => ({
|
|
346
|
+
...acc,
|
|
347
|
+
[k.key]: {
|
|
348
|
+
failCount: k.failCount,
|
|
349
|
+
failedAt: k.failedAt,
|
|
350
|
+
isQuotaError: k.isQuotaError,
|
|
351
|
+
circuitState: k.circuitState,
|
|
352
|
+
lastUsed: k.lastUsed,
|
|
353
|
+
successCount: k.successCount,
|
|
354
|
+
totalRequests: k.totalRequests,
|
|
355
|
+
customCooldown: k.customCooldown,
|
|
356
|
+
}
|
|
357
|
+
}), {});
|
|
358
|
+
this.storage.setItem(this.storageKey, JSON.stringify(state));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private loadState() {
|
|
362
|
+
if (!this.storage) return;
|
|
363
|
+
try {
|
|
364
|
+
const raw = this.storage.getItem(this.storageKey);
|
|
365
|
+
if (!raw) return;
|
|
366
|
+
const data = JSON.parse(raw);
|
|
367
|
+
this.keys.forEach(k => {
|
|
368
|
+
if (data[k.key]) Object.assign(k, data[k.key]);
|
|
369
|
+
});
|
|
370
|
+
} catch (e) {
|
|
371
|
+
console.error("Failed to load key state");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|