@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.
@@ -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
+ }