@artemiskit/sdk 0.3.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/CHANGELOG.md +134 -0
- package/README.md +173 -0
- package/adapters/openai/dist/index.js +5625 -0
- package/dist/index.js +42577 -0
- package/dist/matchers/index.js +224 -0
- package/dist/matchers/jest.js +257 -0
- package/dist/matchers/vitest.js +257 -0
- package/package.json +78 -0
- package/src/__tests__/artemiskit.test.ts +425 -0
- package/src/__tests__/matchers.test.ts +450 -0
- package/src/artemiskit.ts +791 -0
- package/src/guardian/action-validator.ts +585 -0
- package/src/guardian/circuit-breaker.ts +655 -0
- package/src/guardian/guardian.ts +497 -0
- package/src/guardian/guardrails.ts +536 -0
- package/src/guardian/index.ts +142 -0
- package/src/guardian/intent-classifier.ts +378 -0
- package/src/guardian/interceptor.ts +381 -0
- package/src/guardian/policy.ts +446 -0
- package/src/guardian/types.ts +436 -0
- package/src/index.ts +164 -0
- package/src/matchers/core.ts +315 -0
- package/src/matchers/index.ts +26 -0
- package/src/matchers/jest.ts +112 -0
- package/src/matchers/vitest.ts +84 -0
- package/src/types.ts +259 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit Breaker and Metrics
|
|
3
|
+
*
|
|
4
|
+
* Implements the circuit breaker pattern to protect against repeated failures
|
|
5
|
+
* and provides comprehensive metrics tracking for guardian operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
CircuitBreakerConfig,
|
|
10
|
+
CircuitBreakerState,
|
|
11
|
+
CostTracking,
|
|
12
|
+
GuardianMetrics,
|
|
13
|
+
GuardrailType,
|
|
14
|
+
Violation,
|
|
15
|
+
ViolationSeverity,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Circuit Breaker
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Circuit breaker event types
|
|
24
|
+
*/
|
|
25
|
+
export type CircuitBreakerEvent = 'open' | 'close' | 'half-open' | 'violation' | 'success';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Circuit breaker event handler
|
|
29
|
+
*/
|
|
30
|
+
export type CircuitBreakerEventHandler = (
|
|
31
|
+
event: CircuitBreakerEvent,
|
|
32
|
+
data?: Record<string, unknown>
|
|
33
|
+
) => void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Circuit Breaker implementation
|
|
37
|
+
*
|
|
38
|
+
* Protects against cascading failures by opening the circuit
|
|
39
|
+
* when too many violations occur within a time window.
|
|
40
|
+
*/
|
|
41
|
+
export class CircuitBreaker {
|
|
42
|
+
private config: Required<CircuitBreakerConfig>;
|
|
43
|
+
private state: CircuitBreakerState = 'closed';
|
|
44
|
+
private violations: { timestamp: number; severity: ViolationSeverity }[] = [];
|
|
45
|
+
private lastOpenTime = 0;
|
|
46
|
+
private halfOpenSuccesses = 0;
|
|
47
|
+
private eventHandlers: CircuitBreakerEventHandler[] = [];
|
|
48
|
+
|
|
49
|
+
constructor(config: CircuitBreakerConfig) {
|
|
50
|
+
this.config = {
|
|
51
|
+
enabled: config.enabled ?? true,
|
|
52
|
+
threshold: config.threshold ?? 5,
|
|
53
|
+
windowMs: config.windowMs ?? 60000,
|
|
54
|
+
cooldownMs: config.cooldownMs ?? 300000,
|
|
55
|
+
halfOpenRequests: config.halfOpenRequests ?? 3,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if the circuit is open (requests should be blocked)
|
|
61
|
+
*/
|
|
62
|
+
isOpen(): boolean {
|
|
63
|
+
if (!this.config.enabled) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (this.state === 'open') {
|
|
68
|
+
// Check if cooldown has passed
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (now - this.lastOpenTime >= this.config.cooldownMs) {
|
|
71
|
+
this.transition('half-open');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return this.state === 'open';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if the circuit allows requests
|
|
80
|
+
*/
|
|
81
|
+
allowRequest(): boolean {
|
|
82
|
+
if (!this.config.enabled) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (this.state === 'open') {
|
|
87
|
+
// Check if cooldown has passed
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
if (now - this.lastOpenTime >= this.config.cooldownMs) {
|
|
90
|
+
this.transition('half-open');
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Record a violation
|
|
101
|
+
*/
|
|
102
|
+
recordViolation(violation: Violation): void {
|
|
103
|
+
if (!this.config.enabled) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
|
|
109
|
+
// Add to violations list
|
|
110
|
+
this.violations.push({
|
|
111
|
+
timestamp: now,
|
|
112
|
+
severity: violation.severity,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Clean up old violations outside the window
|
|
116
|
+
this.violations = this.violations.filter((v) => now - v.timestamp < this.config.windowMs);
|
|
117
|
+
|
|
118
|
+
this.emit('violation', { violation });
|
|
119
|
+
|
|
120
|
+
// Check if we should open the circuit
|
|
121
|
+
if (this.state === 'closed' && this.violations.length >= this.config.threshold) {
|
|
122
|
+
this.trip();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// In half-open state, a violation trips the circuit again
|
|
126
|
+
if (this.state === 'half-open' && violation.blocked) {
|
|
127
|
+
this.trip();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record a successful request (no violations)
|
|
133
|
+
*/
|
|
134
|
+
recordSuccess(): void {
|
|
135
|
+
if (!this.config.enabled) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.emit('success', {});
|
|
140
|
+
|
|
141
|
+
// In half-open state, track successes
|
|
142
|
+
if (this.state === 'half-open') {
|
|
143
|
+
this.halfOpenSuccesses++;
|
|
144
|
+
if (this.halfOpenSuccesses >= this.config.halfOpenRequests) {
|
|
145
|
+
this.reset();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Trip the circuit breaker (open it)
|
|
152
|
+
*/
|
|
153
|
+
trip(): void {
|
|
154
|
+
this.lastOpenTime = Date.now();
|
|
155
|
+
this.transition('open');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reset the circuit breaker (close it)
|
|
160
|
+
*/
|
|
161
|
+
reset(): void {
|
|
162
|
+
this.violations = [];
|
|
163
|
+
this.halfOpenSuccesses = 0;
|
|
164
|
+
this.transition('closed');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Force the circuit to a specific state
|
|
169
|
+
*/
|
|
170
|
+
forceState(state: CircuitBreakerState): void {
|
|
171
|
+
this.transition(state);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get the current state
|
|
176
|
+
*/
|
|
177
|
+
getState(): CircuitBreakerState {
|
|
178
|
+
return this.state;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get violation count in current window
|
|
183
|
+
*/
|
|
184
|
+
getViolationCount(): number {
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
return this.violations.filter((v) => now - v.timestamp < this.config.windowMs).length;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get time until the circuit breaker resets (if open)
|
|
191
|
+
*/
|
|
192
|
+
getTimeUntilReset(): number {
|
|
193
|
+
if (this.state !== 'open') {
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
const elapsed = Date.now() - this.lastOpenTime;
|
|
197
|
+
return Math.max(0, this.config.cooldownMs - elapsed);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Register an event handler
|
|
202
|
+
*/
|
|
203
|
+
onEvent(handler: CircuitBreakerEventHandler): void {
|
|
204
|
+
this.eventHandlers.push(handler);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Remove an event handler
|
|
209
|
+
*/
|
|
210
|
+
offEvent(handler: CircuitBreakerEventHandler): void {
|
|
211
|
+
this.eventHandlers = this.eventHandlers.filter((h) => h !== handler);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Transition to a new state
|
|
216
|
+
*/
|
|
217
|
+
private transition(newState: CircuitBreakerState): void {
|
|
218
|
+
if (this.state === newState) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const oldState = this.state;
|
|
223
|
+
this.state = newState;
|
|
224
|
+
|
|
225
|
+
if (newState === 'half-open') {
|
|
226
|
+
this.halfOpenSuccesses = 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Map state to event type
|
|
230
|
+
const eventMap: Record<CircuitBreakerState, CircuitBreakerEvent> = {
|
|
231
|
+
open: 'open',
|
|
232
|
+
closed: 'close',
|
|
233
|
+
'half-open': 'half-open',
|
|
234
|
+
};
|
|
235
|
+
this.emit(eventMap[newState], { previousState: oldState });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Emit an event to all handlers
|
|
240
|
+
*/
|
|
241
|
+
private emit(event: CircuitBreakerEvent, data: Record<string, unknown>): void {
|
|
242
|
+
for (const handler of this.eventHandlers) {
|
|
243
|
+
try {
|
|
244
|
+
handler(event, data);
|
|
245
|
+
} catch {
|
|
246
|
+
// Ignore handler errors
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// Metrics Collector
|
|
254
|
+
// =============================================================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Metrics window for time-based aggregation
|
|
258
|
+
*/
|
|
259
|
+
interface MetricsWindow {
|
|
260
|
+
startTime: number;
|
|
261
|
+
requestCount: number;
|
|
262
|
+
blockedCount: number;
|
|
263
|
+
warnedCount: number;
|
|
264
|
+
latencySum: number;
|
|
265
|
+
violationsByType: Record<string, number>;
|
|
266
|
+
violationsBySeverity: Record<string, number>;
|
|
267
|
+
costSum: number;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Metrics collector for guardian operations
|
|
272
|
+
*/
|
|
273
|
+
export class MetricsCollector {
|
|
274
|
+
private currentWindow: MetricsWindow;
|
|
275
|
+
private windowDurationMs: number;
|
|
276
|
+
private history: MetricsWindow[] = [];
|
|
277
|
+
private maxHistoryWindows: number;
|
|
278
|
+
private currency: string;
|
|
279
|
+
|
|
280
|
+
constructor(
|
|
281
|
+
options: {
|
|
282
|
+
windowDurationMs?: number;
|
|
283
|
+
maxHistoryWindows?: number;
|
|
284
|
+
currency?: string;
|
|
285
|
+
} = {}
|
|
286
|
+
) {
|
|
287
|
+
this.windowDurationMs = options.windowDurationMs ?? 60000; // 1 minute
|
|
288
|
+
this.maxHistoryWindows = options.maxHistoryWindows ?? 60; // 1 hour of history
|
|
289
|
+
this.currency = options.currency ?? 'USD';
|
|
290
|
+
this.currentWindow = this.createWindow();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Record a request
|
|
295
|
+
*/
|
|
296
|
+
recordRequest(options: {
|
|
297
|
+
blocked: boolean;
|
|
298
|
+
warned: boolean;
|
|
299
|
+
latencyMs: number;
|
|
300
|
+
violations: Violation[];
|
|
301
|
+
cost?: number;
|
|
302
|
+
}): void {
|
|
303
|
+
this.rotateWindowIfNeeded();
|
|
304
|
+
|
|
305
|
+
this.currentWindow.requestCount++;
|
|
306
|
+
|
|
307
|
+
if (options.blocked) {
|
|
308
|
+
this.currentWindow.blockedCount++;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (options.warned) {
|
|
312
|
+
this.currentWindow.warnedCount++;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
this.currentWindow.latencySum += options.latencyMs;
|
|
316
|
+
|
|
317
|
+
for (const violation of options.violations) {
|
|
318
|
+
const typeKey = violation.type;
|
|
319
|
+
this.currentWindow.violationsByType[typeKey] =
|
|
320
|
+
(this.currentWindow.violationsByType[typeKey] ?? 0) + 1;
|
|
321
|
+
|
|
322
|
+
const severityKey = violation.severity;
|
|
323
|
+
this.currentWindow.violationsBySeverity[severityKey] =
|
|
324
|
+
(this.currentWindow.violationsBySeverity[severityKey] ?? 0) + 1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (options.cost !== undefined) {
|
|
328
|
+
this.currentWindow.costSum += options.cost;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get current metrics
|
|
334
|
+
*/
|
|
335
|
+
getMetrics(circuitBreakerState: CircuitBreakerState = 'closed'): GuardianMetrics {
|
|
336
|
+
this.rotateWindowIfNeeded();
|
|
337
|
+
|
|
338
|
+
// Aggregate all windows
|
|
339
|
+
const allWindows = [...this.history, this.currentWindow];
|
|
340
|
+
const totalRequests = allWindows.reduce((sum, w) => sum + w.requestCount, 0);
|
|
341
|
+
const blockedRequests = allWindows.reduce((sum, w) => sum + w.blockedCount, 0);
|
|
342
|
+
const warnedRequests = allWindows.reduce((sum, w) => sum + w.warnedCount, 0);
|
|
343
|
+
const totalLatency = allWindows.reduce((sum, w) => sum + w.latencySum, 0);
|
|
344
|
+
const totalCost = allWindows.reduce((sum, w) => sum + w.costSum, 0);
|
|
345
|
+
|
|
346
|
+
// Aggregate violations by type
|
|
347
|
+
const violationsByType: Record<GuardrailType, number> = {} as Record<GuardrailType, number>;
|
|
348
|
+
for (const window of allWindows) {
|
|
349
|
+
for (const [type, count] of Object.entries(window.violationsByType)) {
|
|
350
|
+
violationsByType[type as GuardrailType] =
|
|
351
|
+
(violationsByType[type as GuardrailType] ?? 0) + count;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Aggregate violations by severity
|
|
356
|
+
const violationsBySeverity: Record<ViolationSeverity, number> = {
|
|
357
|
+
low: 0,
|
|
358
|
+
medium: 0,
|
|
359
|
+
high: 0,
|
|
360
|
+
critical: 0,
|
|
361
|
+
};
|
|
362
|
+
for (const window of allWindows) {
|
|
363
|
+
for (const [severity, count] of Object.entries(window.violationsBySeverity)) {
|
|
364
|
+
violationsBySeverity[severity as ViolationSeverity] += count;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Calculate RPS
|
|
369
|
+
const totalTimeMs =
|
|
370
|
+
allWindows.length > 0 ? Date.now() - allWindows[0].startTime : this.windowDurationMs;
|
|
371
|
+
const rps = totalTimeMs > 0 ? (totalRequests * 1000) / totalTimeMs : 0;
|
|
372
|
+
|
|
373
|
+
// Calculate cost tracking
|
|
374
|
+
const costTracking: CostTracking | undefined =
|
|
375
|
+
totalCost > 0
|
|
376
|
+
? {
|
|
377
|
+
totalCost,
|
|
378
|
+
costPerMinute: this.currentWindow.costSum,
|
|
379
|
+
costPerHour: this.getHourlyCost(),
|
|
380
|
+
costPerDay: this.getDailyCost(),
|
|
381
|
+
currency: this.currency,
|
|
382
|
+
}
|
|
383
|
+
: undefined;
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
totalRequests,
|
|
387
|
+
blockedRequests,
|
|
388
|
+
warnedRequests,
|
|
389
|
+
allowedRequests: totalRequests - blockedRequests,
|
|
390
|
+
violationsByType,
|
|
391
|
+
violationsBySeverity,
|
|
392
|
+
averageLatencyMs: totalRequests > 0 ? totalLatency / totalRequests : 0,
|
|
393
|
+
circuitBreakerState,
|
|
394
|
+
requestsPerSecond: rps,
|
|
395
|
+
costTracking,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Get metrics for the current window only
|
|
401
|
+
*/
|
|
402
|
+
getCurrentWindowMetrics(): Partial<GuardianMetrics> {
|
|
403
|
+
return {
|
|
404
|
+
totalRequests: this.currentWindow.requestCount,
|
|
405
|
+
blockedRequests: this.currentWindow.blockedCount,
|
|
406
|
+
warnedRequests: this.currentWindow.warnedCount,
|
|
407
|
+
allowedRequests: this.currentWindow.requestCount - this.currentWindow.blockedCount,
|
|
408
|
+
averageLatencyMs:
|
|
409
|
+
this.currentWindow.requestCount > 0
|
|
410
|
+
? this.currentWindow.latencySum / this.currentWindow.requestCount
|
|
411
|
+
: 0,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Reset all metrics
|
|
417
|
+
*/
|
|
418
|
+
reset(): void {
|
|
419
|
+
this.history = [];
|
|
420
|
+
this.currentWindow = this.createWindow();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get hourly cost
|
|
425
|
+
*/
|
|
426
|
+
private getHourlyCost(): number {
|
|
427
|
+
const hourAgo = Date.now() - 3600000;
|
|
428
|
+
const hourlyWindows = this.history.filter((w) => w.startTime >= hourAgo);
|
|
429
|
+
return hourlyWindows.reduce((sum, w) => sum + w.costSum, 0) + this.currentWindow.costSum;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Get daily cost (estimated based on available data)
|
|
434
|
+
*/
|
|
435
|
+
private getDailyCost(): number {
|
|
436
|
+
const allWindows = [...this.history, this.currentWindow];
|
|
437
|
+
const totalCost = allWindows.reduce((sum, w) => sum + w.costSum, 0);
|
|
438
|
+
const totalTimeMs =
|
|
439
|
+
allWindows.length > 0 ? Date.now() - allWindows[0].startTime : this.windowDurationMs;
|
|
440
|
+
|
|
441
|
+
// Extrapolate to 24 hours
|
|
442
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
443
|
+
return totalTimeMs > 0 ? (totalCost * dayMs) / totalTimeMs : 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Create a new metrics window
|
|
448
|
+
*/
|
|
449
|
+
private createWindow(): MetricsWindow {
|
|
450
|
+
return {
|
|
451
|
+
startTime: Date.now(),
|
|
452
|
+
requestCount: 0,
|
|
453
|
+
blockedCount: 0,
|
|
454
|
+
warnedCount: 0,
|
|
455
|
+
latencySum: 0,
|
|
456
|
+
violationsByType: {},
|
|
457
|
+
violationsBySeverity: {},
|
|
458
|
+
costSum: 0,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Rotate to a new window if needed
|
|
464
|
+
*/
|
|
465
|
+
private rotateWindowIfNeeded(): void {
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
if (now - this.currentWindow.startTime >= this.windowDurationMs) {
|
|
468
|
+
this.history.push(this.currentWindow);
|
|
469
|
+
|
|
470
|
+
// Trim history
|
|
471
|
+
while (this.history.length > this.maxHistoryWindows) {
|
|
472
|
+
this.history.shift();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
this.currentWindow = this.createWindow();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// =============================================================================
|
|
481
|
+
// Rate Limiter
|
|
482
|
+
// =============================================================================
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Rate limiter configuration
|
|
486
|
+
*/
|
|
487
|
+
export interface RateLimiterConfig {
|
|
488
|
+
requestsPerMinute?: number;
|
|
489
|
+
requestsPerHour?: number;
|
|
490
|
+
requestsPerDay?: number;
|
|
491
|
+
burstLimit?: number;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Token bucket rate limiter
|
|
496
|
+
*/
|
|
497
|
+
export class RateLimiter {
|
|
498
|
+
private config: RateLimiterConfig;
|
|
499
|
+
private minuteWindow: { count: number; startTime: number };
|
|
500
|
+
private hourWindow: { count: number; startTime: number };
|
|
501
|
+
private dayWindow: { count: number; startTime: number };
|
|
502
|
+
private burstTokens: number;
|
|
503
|
+
private lastBurstRefill: number;
|
|
504
|
+
|
|
505
|
+
constructor(config: RateLimiterConfig) {
|
|
506
|
+
this.config = config;
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
this.minuteWindow = { count: 0, startTime: now };
|
|
509
|
+
this.hourWindow = { count: 0, startTime: now };
|
|
510
|
+
this.dayWindow = { count: 0, startTime: now };
|
|
511
|
+
this.burstTokens = config.burstLimit ?? 10;
|
|
512
|
+
this.lastBurstRefill = now;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Check if a request is allowed
|
|
517
|
+
*/
|
|
518
|
+
allowRequest(): { allowed: boolean; reason?: string; retryAfterMs?: number } {
|
|
519
|
+
const now = Date.now();
|
|
520
|
+
this.refillBurstTokens(now);
|
|
521
|
+
this.rotateWindows(now);
|
|
522
|
+
|
|
523
|
+
// Check burst limit
|
|
524
|
+
if (this.config.burstLimit !== undefined && this.burstTokens <= 0) {
|
|
525
|
+
return {
|
|
526
|
+
allowed: false,
|
|
527
|
+
reason: 'Burst limit exceeded',
|
|
528
|
+
retryAfterMs: 1000, // Wait 1 second
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Check minute limit
|
|
533
|
+
if (
|
|
534
|
+
this.config.requestsPerMinute !== undefined &&
|
|
535
|
+
this.minuteWindow.count >= this.config.requestsPerMinute
|
|
536
|
+
) {
|
|
537
|
+
const retryAfterMs = 60000 - (now - this.minuteWindow.startTime);
|
|
538
|
+
return {
|
|
539
|
+
allowed: false,
|
|
540
|
+
reason: 'Requests per minute limit exceeded',
|
|
541
|
+
retryAfterMs,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Check hour limit
|
|
546
|
+
if (
|
|
547
|
+
this.config.requestsPerHour !== undefined &&
|
|
548
|
+
this.hourWindow.count >= this.config.requestsPerHour
|
|
549
|
+
) {
|
|
550
|
+
const retryAfterMs = 3600000 - (now - this.hourWindow.startTime);
|
|
551
|
+
return {
|
|
552
|
+
allowed: false,
|
|
553
|
+
reason: 'Requests per hour limit exceeded',
|
|
554
|
+
retryAfterMs,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Check day limit
|
|
559
|
+
if (
|
|
560
|
+
this.config.requestsPerDay !== undefined &&
|
|
561
|
+
this.dayWindow.count >= this.config.requestsPerDay
|
|
562
|
+
) {
|
|
563
|
+
const retryAfterMs = 86400000 - (now - this.dayWindow.startTime);
|
|
564
|
+
return {
|
|
565
|
+
allowed: false,
|
|
566
|
+
reason: 'Requests per day limit exceeded',
|
|
567
|
+
retryAfterMs,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Request allowed, consume tokens
|
|
572
|
+
this.minuteWindow.count++;
|
|
573
|
+
this.hourWindow.count++;
|
|
574
|
+
this.dayWindow.count++;
|
|
575
|
+
if (this.config.burstLimit !== undefined) {
|
|
576
|
+
this.burstTokens--;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return { allowed: true };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Get current rate limit status
|
|
584
|
+
*/
|
|
585
|
+
getStatus(): {
|
|
586
|
+
minuteUsed: number;
|
|
587
|
+
minuteLimit?: number;
|
|
588
|
+
hourUsed: number;
|
|
589
|
+
hourLimit?: number;
|
|
590
|
+
dayUsed: number;
|
|
591
|
+
dayLimit?: number;
|
|
592
|
+
burstTokens: number;
|
|
593
|
+
burstLimit?: number;
|
|
594
|
+
} {
|
|
595
|
+
return {
|
|
596
|
+
minuteUsed: this.minuteWindow.count,
|
|
597
|
+
minuteLimit: this.config.requestsPerMinute,
|
|
598
|
+
hourUsed: this.hourWindow.count,
|
|
599
|
+
hourLimit: this.config.requestsPerHour,
|
|
600
|
+
dayUsed: this.dayWindow.count,
|
|
601
|
+
dayLimit: this.config.requestsPerDay,
|
|
602
|
+
burstTokens: this.burstTokens,
|
|
603
|
+
burstLimit: this.config.burstLimit,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Reset all rate limits
|
|
609
|
+
*/
|
|
610
|
+
reset(): void {
|
|
611
|
+
const now = Date.now();
|
|
612
|
+
this.minuteWindow = { count: 0, startTime: now };
|
|
613
|
+
this.hourWindow = { count: 0, startTime: now };
|
|
614
|
+
this.dayWindow = { count: 0, startTime: now };
|
|
615
|
+
this.burstTokens = this.config.burstLimit ?? 10;
|
|
616
|
+
this.lastBurstRefill = now;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Refill burst tokens
|
|
621
|
+
*/
|
|
622
|
+
private refillBurstTokens(now: number): void {
|
|
623
|
+
if (this.config.burstLimit === undefined) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const elapsed = now - this.lastBurstRefill;
|
|
628
|
+
const tokensToAdd = Math.floor(elapsed / 1000); // 1 token per second
|
|
629
|
+
|
|
630
|
+
if (tokensToAdd > 0) {
|
|
631
|
+
this.burstTokens = Math.min(this.config.burstLimit, this.burstTokens + tokensToAdd);
|
|
632
|
+
this.lastBurstRefill = now;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Rotate time windows
|
|
638
|
+
*/
|
|
639
|
+
private rotateWindows(now: number): void {
|
|
640
|
+
// Rotate minute window
|
|
641
|
+
if (now - this.minuteWindow.startTime >= 60000) {
|
|
642
|
+
this.minuteWindow = { count: 0, startTime: now };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Rotate hour window
|
|
646
|
+
if (now - this.hourWindow.startTime >= 3600000) {
|
|
647
|
+
this.hourWindow = { count: 0, startTime: now };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Rotate day window
|
|
651
|
+
if (now - this.dayWindow.startTime >= 86400000) {
|
|
652
|
+
this.dayWindow = { count: 0, startTime: now };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|