immortal-js 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/LICENSE +21 -0
- package/README.md +577 -0
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONTRIBUTING.md +41 -0
- package/docs/api/chaos.md +179 -0
- package/docs/api/dashboard.md +109 -0
- package/docs/api/isolation.md +191 -0
- package/docs/api/lifecycle.md +187 -0
- package/docs/api/monitoring.md +313 -0
- package/docs/api/plugins.md +217 -0
- package/docs/api/recovery.md +267 -0
- package/docs/api/safe-zone.md +236 -0
- package/docs/api/supervision.md +285 -0
- package/docs/guides/configuration.md +168 -0
- package/docs/guides/express.md +171 -0
- package/docs/guides/fastify.md +188 -0
- package/docs/guides/koa.md +102 -0
- package/docs/guides/nestjs.md +182 -0
- package/docs/guides/testing.md +91 -0
- package/examples/express-basic/index.ts +462 -0
- package/examples/express-basic/package.json +21 -0
- package/examples/express-basic/tsconfig.json +24 -0
- package/examples/fastify-microservice/index.ts +342 -0
- package/examples/fastify-microservice/package.json +22 -0
- package/examples/fastify-microservice/tsconfig.json +24 -0
- package/examples/invoice-service/data/invoices.db +0 -0
- package/examples/invoice-service/data/invoices.db-shm +0 -0
- package/examples/invoice-service/data/invoices.db-wal +0 -0
- package/examples/invoice-service/package.json +25 -0
- package/examples/invoice-service/public/index.html +5025 -0
- package/examples/invoice-service/src/db.ts +608 -0
- package/examples/invoice-service/src/pdf.ts +358 -0
- package/examples/invoice-service/src/server.ts +527 -0
- package/examples/invoice-service/src/store.ts +159 -0
- package/examples/invoice-service/src/types.ts +193 -0
- package/examples/invoice-service/tsconfig.json +23 -0
- package/examples/nestjs-enterprise/app.module.ts +561 -0
- package/examples/nestjs-enterprise/main.ts +67 -0
- package/examples/nestjs-enterprise/package.json +26 -0
- package/examples/nestjs-enterprise/tsconfig.json +27 -0
- package/immortal-js-1.0.0.tgz +0 -0
- package/package.json +33 -0
- package/packages/adapter-express/package.json +34 -0
- package/packages/adapter-express/src/index.ts +349 -0
- package/packages/adapter-express/tsconfig.json +14 -0
- package/packages/adapter-fastify/package.json +56 -0
- package/packages/adapter-fastify/src/plugin.ts +226 -0
- package/packages/adapter-fastify/tsconfig.json +14 -0
- package/packages/adapter-koa/package.json +55 -0
- package/packages/adapter-koa/src/index.ts +207 -0
- package/packages/adapter-koa/tsconfig.json +14 -0
- package/packages/adapter-nestjs/package.json +61 -0
- package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
- package/packages/adapter-nestjs/src/index.ts +14 -0
- package/packages/adapter-nestjs/tsconfig.json +16 -0
- package/packages/core/package.json +56 -0
- package/packages/core/src/chaos/ChaosEngine.ts +249 -0
- package/packages/core/src/config/defaults.ts +200 -0
- package/packages/core/src/config/schema.ts +199 -0
- package/packages/core/src/event-bus.ts +168 -0
- package/packages/core/src/index.ts +164 -0
- package/packages/core/src/isolation/BulkheadPool.ts +279 -0
- package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
- package/packages/core/src/isolation/index.ts +8 -0
- package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
- package/packages/core/src/logger.ts +104 -0
- package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
- package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
- package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
- package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
- package/packages/core/src/monitoring/index.ts +10 -0
- package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
- package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
- package/packages/core/src/recovery/FallbackCache.ts +328 -0
- package/packages/core/src/recovery/RetryEngine.ts +225 -0
- package/packages/core/src/recovery/Timeout.ts +97 -0
- package/packages/core/src/recovery/index.ts +11 -0
- package/packages/core/src/runtime.ts +242 -0
- package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
- package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
- package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
- package/packages/core/src/safe-zone/index.ts +23 -0
- package/packages/core/src/supervision/ClusterManager.ts +243 -0
- package/packages/core/src/supervision/RestartStrategy.ts +68 -0
- package/packages/core/src/supervision/Supervisor.ts +311 -0
- package/packages/core/src/supervision/index.ts +11 -0
- package/packages/core/src/types.ts +470 -0
- package/packages/core/test/bulkhead.test.ts +310 -0
- package/packages/core/test/circuit-breaker.test.ts +153 -0
- package/packages/core/test/memory-guard.test.ts +213 -0
- package/packages/core/test/retry.test.ts +110 -0
- package/packages/core/test/safe-zone.test.ts +271 -0
- package/packages/core/test/supervisor.test.ts +310 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/dashboard/package.json +56 -0
- package/packages/dashboard/server/DashboardServer.ts +454 -0
- package/packages/dashboard/tsconfig.json +14 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file BuiltinPlugins.ts
|
|
3
|
+
* @description Built-in Immortal.js plugins
|
|
4
|
+
*
|
|
5
|
+
* These plugins demonstrate the Plugin API and provide ready-to-use integrations:
|
|
6
|
+
* 1. ConsoleLogPlugin — structured console logging of all events
|
|
7
|
+
* 2. AnomalyDetectionPlugin — detects unusual patterns and alerts
|
|
8
|
+
* 3. RequestTracingPlugin — adds trace IDs to all requests
|
|
9
|
+
* 4. SlackAlertPlugin — posts critical alerts to a Slack webhook
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ImmortalPlugin, PluginContext, SystemMetrics, ImmortalEvent } from "../types.js";
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// 1. CONSOLE LOG PLUGIN
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export const ConsoleLogPlugin: ImmortalPlugin = {
|
|
19
|
+
name: "console-log",
|
|
20
|
+
version: "1.0.0",
|
|
21
|
+
description: "Logs all Immortal.js events to console with structured formatting",
|
|
22
|
+
|
|
23
|
+
onInit: (ctx: PluginContext) => {
|
|
24
|
+
ctx.logger.info("[ConsoleLogPlugin] Initialized — all events will be logged");
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
onError: (error: unknown, context) => {
|
|
28
|
+
console.error("[IMMORTAL ERROR]", {
|
|
29
|
+
requestId: context.requestId,
|
|
30
|
+
route: context.route,
|
|
31
|
+
error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
onMetrics: (metrics: SystemMetrics) => {
|
|
36
|
+
if (metrics.eventLoopLag.p99 > 200) {
|
|
37
|
+
console.warn("[IMMORTAL METRICS] High event loop lag:", {
|
|
38
|
+
p99: `${metrics.eventLoopLag.p99.toFixed(1)}ms`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (metrics.memory.heapUsedMb > 500) {
|
|
42
|
+
console.warn("[IMMORTAL METRICS] High heap usage:", {
|
|
43
|
+
heap: `${metrics.memory.heapUsedMb.toFixed(1)}MB`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
// 2. ANOMALY DETECTION PLUGIN
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface AnomalyDetectionOptions {
|
|
54
|
+
/** Error rate threshold to trigger anomaly (default: 0.1 = 10%) */
|
|
55
|
+
errorRateThreshold?: number;
|
|
56
|
+
/** Request rate drop % that triggers anomaly (default: 50%) */
|
|
57
|
+
requestRateDropPercent?: number;
|
|
58
|
+
/** Event loop lag spike threshold in ms (default: 300) */
|
|
59
|
+
eventLoopLagSpikeMs?: number;
|
|
60
|
+
/** Custom alert handler */
|
|
61
|
+
onAnomaly?: (type: string, details: Record<string, unknown>) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createAnomalyDetectionPlugin(
|
|
65
|
+
options: AnomalyDetectionOptions = {}
|
|
66
|
+
): ImmortalPlugin {
|
|
67
|
+
const errorRateThreshold = options.errorRateThreshold ?? 0.1;
|
|
68
|
+
const lagSpike = options.eventLoopLagSpikeMs ?? 300;
|
|
69
|
+
let previousRequestRate = 0;
|
|
70
|
+
let initialized = false;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name: "anomaly-detection",
|
|
74
|
+
version: "1.0.0",
|
|
75
|
+
description: "Detects unusual patterns in system metrics and fires alerts",
|
|
76
|
+
|
|
77
|
+
onInit: (ctx: PluginContext) => {
|
|
78
|
+
ctx.logger.info("[AnomalyDetectionPlugin] Watching for anomalies", {
|
|
79
|
+
errorRateThreshold: `${errorRateThreshold * 100}%`,
|
|
80
|
+
lagSpikeThreshold: `${lagSpike}ms`,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
onMetrics: (metrics: SystemMetrics) => {
|
|
85
|
+
const anomalies: Array<{ type: string; details: Record<string, unknown> }> = [];
|
|
86
|
+
|
|
87
|
+
// Anomaly 1: Error rate spike
|
|
88
|
+
if (metrics.requests.errorRate > errorRateThreshold) {
|
|
89
|
+
anomalies.push({
|
|
90
|
+
type: "error-rate-spike",
|
|
91
|
+
details: {
|
|
92
|
+
errorRate: `${(metrics.requests.errorRate * 100).toFixed(2)}%`,
|
|
93
|
+
threshold: `${errorRateThreshold * 100}%`,
|
|
94
|
+
totalRequests: metrics.requests.total,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Anomaly 2: Event loop lag spike
|
|
100
|
+
if (metrics.eventLoopLag.p99 > lagSpike) {
|
|
101
|
+
anomalies.push({
|
|
102
|
+
type: "event-loop-lag-spike",
|
|
103
|
+
details: {
|
|
104
|
+
p99: `${metrics.eventLoopLag.p99.toFixed(1)}ms`,
|
|
105
|
+
threshold: `${lagSpike}ms`,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Anomaly 3: Request rate drop (only after initialized)
|
|
111
|
+
if (initialized && previousRequestRate > 0) {
|
|
112
|
+
const dropPercent =
|
|
113
|
+
((previousRequestRate - metrics.requests.perSecond) / previousRequestRate) * 100;
|
|
114
|
+
if (dropPercent > (options.requestRateDropPercent ?? 50)) {
|
|
115
|
+
anomalies.push({
|
|
116
|
+
type: "request-rate-drop",
|
|
117
|
+
details: {
|
|
118
|
+
previous: `${previousRequestRate.toFixed(1)} req/s`,
|
|
119
|
+
current: `${metrics.requests.perSecond.toFixed(1)} req/s`,
|
|
120
|
+
dropPercent: `${dropPercent.toFixed(1)}%`,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
previousRequestRate = metrics.requests.perSecond;
|
|
127
|
+
initialized = true;
|
|
128
|
+
|
|
129
|
+
for (const anomaly of anomalies) {
|
|
130
|
+
console.error(`[IMMORTAL ANOMALY] ${anomaly.type}`, anomaly.details);
|
|
131
|
+
options.onAnomaly?.(anomaly.type, anomaly.details);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// 3. REQUEST TRACING PLUGIN
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export const RequestTracingPlugin: ImmortalPlugin = {
|
|
142
|
+
name: "request-tracing",
|
|
143
|
+
version: "1.0.0",
|
|
144
|
+
description: "Adds distributed trace IDs to requests for APM correlation",
|
|
145
|
+
|
|
146
|
+
onInit: (ctx: PluginContext) => {
|
|
147
|
+
ctx.logger.info("[RequestTracingPlugin] Request tracing enabled");
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
onRequest: (context) => {
|
|
151
|
+
// Trace ID is already set by AsyncBoundary (requestId)
|
|
152
|
+
// This plugin can extend it with parent trace correlation
|
|
153
|
+
const traceId = context.requestId;
|
|
154
|
+
const spanId = Math.random().toString(36).substring(2, 18);
|
|
155
|
+
|
|
156
|
+
context.custom = {
|
|
157
|
+
...context.custom,
|
|
158
|
+
traceId,
|
|
159
|
+
spanId,
|
|
160
|
+
traceFlags: "01", // sampled
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
onError: (error: unknown, context) => {
|
|
165
|
+
// Log with trace context for APM correlation
|
|
166
|
+
const traceContext = context.custom ?? {};
|
|
167
|
+
console.error("[TRACE]", {
|
|
168
|
+
traceId: traceContext["traceId"],
|
|
169
|
+
spanId: traceContext["spanId"],
|
|
170
|
+
error: (error as Error).message,
|
|
171
|
+
route: context.route,
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// 4. SLACK ALERT PLUGIN
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export interface SlackAlertOptions {
|
|
181
|
+
webhookUrl: string;
|
|
182
|
+
/** Only send alerts for these event types */
|
|
183
|
+
eventTypes?: string[];
|
|
184
|
+
/** Minimum severity to alert on */
|
|
185
|
+
minSeverity?: "warn" | "error" | "critical";
|
|
186
|
+
/** Application name in alert messages */
|
|
187
|
+
appName?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createSlackAlertPlugin(options: SlackAlertOptions): ImmortalPlugin {
|
|
191
|
+
const criticalEvents = [
|
|
192
|
+
"worker:escalated",
|
|
193
|
+
"memory:proactive-restart",
|
|
194
|
+
"memory:critical",
|
|
195
|
+
"eventloop:lag-critical",
|
|
196
|
+
"circuit:opened",
|
|
197
|
+
"error:escalated",
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const targetEvents = options.eventTypes ?? criticalEvents;
|
|
201
|
+
let lastAlertTime = 0;
|
|
202
|
+
const alertThrottleMs = 60_000; // Don't spam — max 1 alert/minute per event type
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
name: "slack-alerts",
|
|
206
|
+
version: "1.0.0",
|
|
207
|
+
description: "Posts critical Immortal.js events to a Slack webhook",
|
|
208
|
+
|
|
209
|
+
onInit: (ctx: PluginContext) => {
|
|
210
|
+
ctx.logger.info("[SlackAlertPlugin] Slack alerts configured", {
|
|
211
|
+
webhookUrl: options.webhookUrl.substring(0, 30) + "...",
|
|
212
|
+
events: targetEvents,
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
onMetrics: async (metrics: SystemMetrics) => {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
if (now - lastAlertTime < alertThrottleMs) return;
|
|
219
|
+
|
|
220
|
+
// Critical metrics that warrant a Slack alert
|
|
221
|
+
const alerts: string[] = [];
|
|
222
|
+
|
|
223
|
+
if (metrics.eventLoopLag.p99 > 1000) {
|
|
224
|
+
alerts.push(`🔴 Event loop critically lagging: *${metrics.eventLoopLag.p99.toFixed(0)}ms p99*`);
|
|
225
|
+
}
|
|
226
|
+
if (metrics.memory.heapUsedMb > 1400) {
|
|
227
|
+
alerts.push(`🔴 Memory critical: *${metrics.memory.heapUsedMb.toFixed(0)}MB* heap`);
|
|
228
|
+
}
|
|
229
|
+
if (metrics.requests.errorRate > 0.3) {
|
|
230
|
+
alerts.push(`🔴 Error rate spike: *${(metrics.requests.errorRate * 100).toFixed(1)}%*`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (alerts.length === 0) return;
|
|
234
|
+
|
|
235
|
+
lastAlertTime = now;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await fetch(options.webhookUrl, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "Content-Type": "application/json" },
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
text: `*${options.appName ?? "Immortal.js"} Critical Alert*\n${alerts.join("\n")}`,
|
|
243
|
+
attachments: [{
|
|
244
|
+
color: "danger",
|
|
245
|
+
fields: [
|
|
246
|
+
{ title: "Environment", value: process.env["NODE_ENV"] ?? "unknown", short: true },
|
|
247
|
+
{ title: "PID", value: String(process.pid), short: true },
|
|
248
|
+
{ title: "Time", value: new Date().toISOString(), short: false },
|
|
249
|
+
],
|
|
250
|
+
}],
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
} catch {
|
|
254
|
+
// Don't let Slack failures affect the application
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
// PLUGIN REGISTRY
|
|
262
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
export const BUILTIN_PLUGINS = {
|
|
265
|
+
ConsoleLog: ConsoleLogPlugin,
|
|
266
|
+
RequestTracing: RequestTracingPlugin,
|
|
267
|
+
createAnomalyDetection: createAnomalyDetectionPlugin,
|
|
268
|
+
createSlackAlert: createSlackAlertPlugin,
|
|
269
|
+
} as const;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CircuitBreaker.ts
|
|
3
|
+
* @description Production-grade Circuit Breaker with Sliding Window metrics
|
|
4
|
+
*
|
|
5
|
+
* State Machine:
|
|
6
|
+
* CLOSED → OPEN: When failure rate in sliding window exceeds threshold
|
|
7
|
+
* OPEN → HALF_OPEN: After cooldown period (one probe request allowed)
|
|
8
|
+
* HALF_OPEN → CLOSED: If probe succeeds (reset failure count)
|
|
9
|
+
* HALF_OPEN → OPEN: If probe fails (restart cooldown)
|
|
10
|
+
*
|
|
11
|
+
* Key improvement over basic implementations:
|
|
12
|
+
* - Sliding window (not cumulative counters) for accurate recent failure rate
|
|
13
|
+
* - Minimum request threshold before circuit can open (prevents premature opening)
|
|
14
|
+
* - Named circuits (one per external service)
|
|
15
|
+
* - Full telemetry via event bus
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CircuitBreakerConfig, CircuitBreakerStatus, FallbackGenerator } from "../types.js";
|
|
19
|
+
import { CircuitOpenError } from "../types.js";
|
|
20
|
+
import { DEFAULT_CIRCUIT_BREAKER } from "../config/defaults.js";
|
|
21
|
+
import { getEventBus } from "../event-bus.js";
|
|
22
|
+
import { getLogger } from "../logger.js";
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// SLIDING WINDOW
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
type RequestOutcome = "success" | "failure";
|
|
29
|
+
|
|
30
|
+
class SlidingWindow {
|
|
31
|
+
private readonly size: number;
|
|
32
|
+
private window: RequestOutcome[] = [];
|
|
33
|
+
private failures = 0;
|
|
34
|
+
private successes = 0;
|
|
35
|
+
|
|
36
|
+
constructor(size: number) {
|
|
37
|
+
this.size = size;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
record(outcome: RequestOutcome): void {
|
|
41
|
+
if (this.window.length >= this.size) {
|
|
42
|
+
const evicted = this.window.shift()!;
|
|
43
|
+
if (evicted === "failure") this.failures--;
|
|
44
|
+
else this.successes--;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.window.push(outcome);
|
|
48
|
+
if (outcome === "failure") this.failures++;
|
|
49
|
+
else this.successes++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getFailureRate(): number {
|
|
53
|
+
if (this.window.length === 0) return 0;
|
|
54
|
+
return (this.failures / this.window.length) * 100;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getTotalRequests(): number {
|
|
58
|
+
return this.window.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getFailures(): number {
|
|
62
|
+
return this.failures;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getSuccesses(): number {
|
|
66
|
+
return this.successes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
reset(): void {
|
|
70
|
+
this.window = [];
|
|
71
|
+
this.failures = 0;
|
|
72
|
+
this.successes = 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
// CIRCUIT BREAKER CLASS
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
81
|
+
|
|
82
|
+
export class CircuitBreaker {
|
|
83
|
+
private state: CircuitState = "CLOSED";
|
|
84
|
+
private readonly window: SlidingWindow;
|
|
85
|
+
private lastStateChange: number = Date.now();
|
|
86
|
+
private lastFailureTime: number = 0;
|
|
87
|
+
private halfOpenProbeActive = false;
|
|
88
|
+
private readonly config: Required<CircuitBreakerConfig>;
|
|
89
|
+
|
|
90
|
+
constructor(
|
|
91
|
+
public readonly name: string,
|
|
92
|
+
config: CircuitBreakerConfig = {}
|
|
93
|
+
) {
|
|
94
|
+
this.config = { ...DEFAULT_CIRCUIT_BREAKER, ...config };
|
|
95
|
+
this.window = new SlidingWindow(this.config.slidingWindowSize);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Execute a function through the circuit breaker.
|
|
100
|
+
* If circuit is OPEN, throws CircuitOpenError immediately (fail-fast).
|
|
101
|
+
* If circuit is HALF_OPEN, allows one probe request.
|
|
102
|
+
*
|
|
103
|
+
* @param fn - The operation to attempt
|
|
104
|
+
* @param fallback - Optional fallback value generator (if provided, never throws CircuitOpenError)
|
|
105
|
+
*/
|
|
106
|
+
async execute<T>(
|
|
107
|
+
fn: () => Promise<T>,
|
|
108
|
+
fallback?: () => T | Promise<T>
|
|
109
|
+
): Promise<T> {
|
|
110
|
+
const bus = getEventBus();
|
|
111
|
+
|
|
112
|
+
// Check if we should transition from OPEN to HALF_OPEN
|
|
113
|
+
if (this.state === "OPEN") {
|
|
114
|
+
const timeSinceLastFailure = Date.now() - this.lastFailureTime;
|
|
115
|
+
|
|
116
|
+
if (timeSinceLastFailure >= this.config.cooldownMs) {
|
|
117
|
+
this.transitionTo("HALF_OPEN");
|
|
118
|
+
} else {
|
|
119
|
+
// Still open — fail fast
|
|
120
|
+
bus.emit("circuit:rejected", {
|
|
121
|
+
service: this.name,
|
|
122
|
+
data: {
|
|
123
|
+
state: this.state,
|
|
124
|
+
cooldownRemainingMs: this.config.cooldownMs - timeSinceLastFailure,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (fallback) {
|
|
129
|
+
return await Promise.resolve(fallback());
|
|
130
|
+
}
|
|
131
|
+
throw new CircuitOpenError(this.name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// HALF_OPEN: only allow one probe at a time
|
|
136
|
+
if (this.state === "HALF_OPEN" && this.halfOpenProbeActive) {
|
|
137
|
+
if (fallback) {
|
|
138
|
+
return await Promise.resolve(fallback());
|
|
139
|
+
}
|
|
140
|
+
throw new CircuitOpenError(this.name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.state === "HALF_OPEN") {
|
|
144
|
+
this.halfOpenProbeActive = true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// CLOSED or HALF_OPEN probe: attempt the operation
|
|
148
|
+
try {
|
|
149
|
+
const result = await fn();
|
|
150
|
+
this.onSuccess();
|
|
151
|
+
return result;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
this.onFailure();
|
|
154
|
+
|
|
155
|
+
if (fallback && this.state === "OPEN") {
|
|
156
|
+
getLogger().debug(`Circuit '${this.name}' opened — using fallback`);
|
|
157
|
+
return await Promise.resolve(fallback());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw err;
|
|
161
|
+
} finally {
|
|
162
|
+
this.halfOpenProbeActive = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get current circuit status for monitoring
|
|
168
|
+
*/
|
|
169
|
+
getStatus(): CircuitBreakerStatus {
|
|
170
|
+
return {
|
|
171
|
+
name: this.name,
|
|
172
|
+
state: this.state,
|
|
173
|
+
failures: this.window.getFailures(),
|
|
174
|
+
successes: this.window.getSuccesses(),
|
|
175
|
+
totalRequests: this.window.getTotalRequests(),
|
|
176
|
+
failureRate: this.window.getFailureRate(),
|
|
177
|
+
lastFailureTime: this.lastFailureTime,
|
|
178
|
+
lastStateChange: this.lastStateChange,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Force the circuit to a specific state (for testing/admin operations)
|
|
184
|
+
*/
|
|
185
|
+
forceState(state: CircuitState): void {
|
|
186
|
+
getLogger().warn(`Circuit '${this.name}' state forced to ${state}`);
|
|
187
|
+
this.transitionTo(state);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Reset circuit to initial CLOSED state
|
|
192
|
+
*/
|
|
193
|
+
reset(): void {
|
|
194
|
+
this.window.reset();
|
|
195
|
+
this.transitionTo("CLOSED");
|
|
196
|
+
this.lastFailureTime = 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getCurrentState(): CircuitState {
|
|
200
|
+
return this.state;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
204
|
+
// PRIVATE STATE MACHINE
|
|
205
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
private onSuccess(): void {
|
|
208
|
+
this.window.record("success");
|
|
209
|
+
|
|
210
|
+
if (this.state === "HALF_OPEN") {
|
|
211
|
+
// Probe succeeded — close the circuit
|
|
212
|
+
this.window.reset();
|
|
213
|
+
this.transitionTo("CLOSED");
|
|
214
|
+
getLogger().info(`Circuit '${this.name}' recovered — transitioning HALF_OPEN → CLOSED`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private onFailure(): void {
|
|
219
|
+
this.window.record("failure");
|
|
220
|
+
this.lastFailureTime = Date.now();
|
|
221
|
+
|
|
222
|
+
if (this.state === "HALF_OPEN") {
|
|
223
|
+
// Probe failed — reopen circuit
|
|
224
|
+
this.transitionTo("OPEN");
|
|
225
|
+
getLogger().warn(`Circuit '${this.name}' probe failed — returning to OPEN`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (this.state === "CLOSED") {
|
|
230
|
+
const totalReqs = this.window.getTotalRequests();
|
|
231
|
+
const failureRate = this.window.getFailureRate();
|
|
232
|
+
|
|
233
|
+
const shouldOpen =
|
|
234
|
+
totalReqs >= this.config.minimumRequests &&
|
|
235
|
+
(failureRate >= this.config.failureRateThreshold ||
|
|
236
|
+
this.window.getFailures() >= this.config.threshold);
|
|
237
|
+
|
|
238
|
+
if (shouldOpen) {
|
|
239
|
+
this.transitionTo("OPEN");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private transitionTo(newState: CircuitState): void {
|
|
245
|
+
if (this.state === newState) return;
|
|
246
|
+
|
|
247
|
+
const bus = getEventBus();
|
|
248
|
+
const previousState = this.state;
|
|
249
|
+
this.state = newState;
|
|
250
|
+
this.lastStateChange = Date.now();
|
|
251
|
+
|
|
252
|
+
const status = this.getStatus();
|
|
253
|
+
|
|
254
|
+
switch (newState) {
|
|
255
|
+
case "OPEN":
|
|
256
|
+
bus.emit("circuit:opened", { service: this.name, data: { previousState, ...status } });
|
|
257
|
+
getLogger().warn(`Circuit '${this.name}' OPENED`, {
|
|
258
|
+
failureRate: `${status.failureRate.toFixed(1)}%`,
|
|
259
|
+
failures: status.failures,
|
|
260
|
+
totalRequests: status.totalRequests,
|
|
261
|
+
});
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case "HALF_OPEN":
|
|
265
|
+
bus.emit("circuit:half-open", { service: this.name, data: { previousState, ...status } });
|
|
266
|
+
getLogger().info(`Circuit '${this.name}' → HALF_OPEN (probing...)`);
|
|
267
|
+
break;
|
|
268
|
+
|
|
269
|
+
case "CLOSED":
|
|
270
|
+
bus.emit("circuit:closed", { service: this.name, data: { previousState, ...status } });
|
|
271
|
+
getLogger().info(`Circuit '${this.name}' CLOSED (recovered)`);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
278
|
+
// CIRCUIT BREAKER REGISTRY
|
|
279
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Registry of named circuit breakers — one per external service.
|
|
283
|
+
* Enables getting/monitoring all circuits in one place.
|
|
284
|
+
*/
|
|
285
|
+
export class CircuitBreakerRegistry {
|
|
286
|
+
private circuits = new Map<string, CircuitBreaker>();
|
|
287
|
+
private readonly defaultConfig: CircuitBreakerConfig;
|
|
288
|
+
|
|
289
|
+
constructor(defaultConfig: CircuitBreakerConfig = {}) {
|
|
290
|
+
this.defaultConfig = defaultConfig;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get or create a circuit breaker for a named service
|
|
295
|
+
*/
|
|
296
|
+
get(name: string, config?: CircuitBreakerConfig): CircuitBreaker {
|
|
297
|
+
if (!this.circuits.has(name)) {
|
|
298
|
+
this.circuits.set(
|
|
299
|
+
name,
|
|
300
|
+
new CircuitBreaker(name, { ...this.defaultConfig, ...config })
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return this.circuits.get(name)!;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Get statuses of all registered circuits
|
|
308
|
+
*/
|
|
309
|
+
getAllStatuses(): Record<string, CircuitBreakerStatus> {
|
|
310
|
+
const result: Record<string, CircuitBreakerStatus> = {};
|
|
311
|
+
for (const [name, circuit] of this.circuits) {
|
|
312
|
+
result[name] = circuit.getStatus();
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Reset all circuits (for testing)
|
|
319
|
+
*/
|
|
320
|
+
resetAll(): void {
|
|
321
|
+
for (const circuit of this.circuits.values()) {
|
|
322
|
+
circuit.reset();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get list of currently open circuits
|
|
328
|
+
*/
|
|
329
|
+
getOpenCircuits(): string[] {
|
|
330
|
+
return Array.from(this.circuits.entries())
|
|
331
|
+
.filter(([, c]) => c.getCurrentState() === "OPEN")
|
|
332
|
+
.map(([name]) => name);
|
|
333
|
+
}
|
|
334
|
+
}
|