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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file FallbackCache.ts
|
|
3
|
+
* @description LRU + TTL cache for emergency fallback responses
|
|
4
|
+
*
|
|
5
|
+
* When a circuit is open or a service is unavailable, this cache returns
|
|
6
|
+
* the last known-good response instead of a cold error.
|
|
7
|
+
*
|
|
8
|
+
* Key design decisions:
|
|
9
|
+
* 1. Always marks cached responses with X-Immortal-Fallback: true header
|
|
10
|
+
* 2. TTL prevents stale data from being served indefinitely
|
|
11
|
+
* 3. LRU eviction prevents unbounded memory growth
|
|
12
|
+
* 4. Per-key TTL allows different expiry times per route/service
|
|
13
|
+
* 5. Cache is NOT a source of truth — it's a safety net
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getEventBus } from "../event-bus.js";
|
|
17
|
+
import { getLogger } from "../logger.js";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// TYPES
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface CacheEntry<T> {
|
|
24
|
+
value: T;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
hits: number;
|
|
28
|
+
key: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CacheStats {
|
|
32
|
+
size: number;
|
|
33
|
+
maxSize: number;
|
|
34
|
+
hits: number;
|
|
35
|
+
misses: number;
|
|
36
|
+
hitRate: number;
|
|
37
|
+
evictions: number;
|
|
38
|
+
totalEntries: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// LRU CACHE IMPLEMENTATION
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export class FallbackCache<T = unknown> {
|
|
46
|
+
private readonly cache = new Map<string, CacheEntry<T>>();
|
|
47
|
+
private readonly maxSize: number;
|
|
48
|
+
private readonly defaultTtlMs: number;
|
|
49
|
+
private hits = 0;
|
|
50
|
+
private misses = 0;
|
|
51
|
+
private evictions = 0;
|
|
52
|
+
|
|
53
|
+
constructor(options: { maxSize?: number; defaultTtlMs?: number } = {}) {
|
|
54
|
+
this.maxSize = options.maxSize ?? 500;
|
|
55
|
+
this.defaultTtlMs = options.defaultTtlMs ?? 5 * 60 * 1000; // 5 minutes default
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Store a value in the cache.
|
|
60
|
+
* @param key - Cache key (typically route path + params hash)
|
|
61
|
+
* @param value - Value to cache
|
|
62
|
+
* @param ttlMs - Time-to-live in ms (overrides default)
|
|
63
|
+
*/
|
|
64
|
+
set(key: string, value: T, ttlMs?: number): void {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const ttl = ttlMs ?? this.defaultTtlMs;
|
|
67
|
+
|
|
68
|
+
// If key exists, update in place (preserves LRU order by re-inserting)
|
|
69
|
+
if (this.cache.has(key)) {
|
|
70
|
+
this.cache.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Evict LRU entry if at capacity
|
|
74
|
+
if (this.cache.size >= this.maxSize) {
|
|
75
|
+
const lruKey = this.cache.keys().next().value;
|
|
76
|
+
if (lruKey !== undefined) {
|
|
77
|
+
this.cache.delete(lruKey);
|
|
78
|
+
this.evictions++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Insert at end (most recently used position in Map)
|
|
83
|
+
this.cache.set(key, {
|
|
84
|
+
value,
|
|
85
|
+
createdAt: now,
|
|
86
|
+
expiresAt: now + ttl,
|
|
87
|
+
hits: 0,
|
|
88
|
+
key,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Retrieve a value from the cache.
|
|
94
|
+
* Returns undefined if not found or expired.
|
|
95
|
+
* Moves hit entry to MRU position (LRU promotion).
|
|
96
|
+
*/
|
|
97
|
+
get(key: string): T | undefined {
|
|
98
|
+
const entry = this.cache.get(key);
|
|
99
|
+
|
|
100
|
+
if (!entry) {
|
|
101
|
+
this.misses++;
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check TTL
|
|
106
|
+
if (Date.now() > entry.expiresAt) {
|
|
107
|
+
this.cache.delete(key);
|
|
108
|
+
this.misses++;
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// LRU promotion: move to end
|
|
113
|
+
this.cache.delete(key);
|
|
114
|
+
entry.hits++;
|
|
115
|
+
this.cache.set(key, entry);
|
|
116
|
+
this.hits++;
|
|
117
|
+
|
|
118
|
+
return entry.value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if a key exists and is not expired
|
|
123
|
+
*/
|
|
124
|
+
has(key: string): boolean {
|
|
125
|
+
const entry = this.cache.get(key);
|
|
126
|
+
if (!entry) return false;
|
|
127
|
+
if (Date.now() > entry.expiresAt) {
|
|
128
|
+
this.cache.delete(key);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Invalidate a specific cache entry
|
|
136
|
+
*/
|
|
137
|
+
invalidate(key: string): boolean {
|
|
138
|
+
return this.cache.delete(key);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Invalidate all entries matching a prefix pattern
|
|
143
|
+
*/
|
|
144
|
+
invalidatePattern(prefix: string): number {
|
|
145
|
+
let count = 0;
|
|
146
|
+
for (const key of this.cache.keys()) {
|
|
147
|
+
if (key.startsWith(prefix)) {
|
|
148
|
+
this.cache.delete(key);
|
|
149
|
+
count++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return count;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get cache statistics
|
|
157
|
+
*/
|
|
158
|
+
getStats(): CacheStats {
|
|
159
|
+
const total = this.hits + this.misses;
|
|
160
|
+
return {
|
|
161
|
+
size: this.cache.size,
|
|
162
|
+
maxSize: this.maxSize,
|
|
163
|
+
hits: this.hits,
|
|
164
|
+
misses: this.misses,
|
|
165
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
166
|
+
evictions: this.evictions,
|
|
167
|
+
totalEntries: this.cache.size,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Remove all expired entries (run periodically to prevent memory leaks)
|
|
173
|
+
*/
|
|
174
|
+
purgeExpired(): number {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
let purged = 0;
|
|
177
|
+
for (const [key, entry] of this.cache) {
|
|
178
|
+
if (now > entry.expiresAt) {
|
|
179
|
+
this.cache.delete(key);
|
|
180
|
+
purged++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return purged;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clear all entries
|
|
188
|
+
*/
|
|
189
|
+
clear(): void {
|
|
190
|
+
this.cache.clear();
|
|
191
|
+
this.hits = 0;
|
|
192
|
+
this.misses = 0;
|
|
193
|
+
this.evictions = 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get the number of cached entries
|
|
198
|
+
*/
|
|
199
|
+
get size(): number {
|
|
200
|
+
return this.cache.size;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
// ROUTE FALLBACK CACHE — HTTP-aware wrapper
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export interface CachedResponse {
|
|
209
|
+
body: unknown;
|
|
210
|
+
statusCode: number;
|
|
211
|
+
headers: Record<string, string>;
|
|
212
|
+
cachedAt: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class RouteFallbackCache {
|
|
216
|
+
private readonly cache: FallbackCache<CachedResponse>;
|
|
217
|
+
private readonly purgeIntervalMs: number;
|
|
218
|
+
private purgeInterval?: ReturnType<typeof setInterval>;
|
|
219
|
+
|
|
220
|
+
constructor(options: {
|
|
221
|
+
maxSize?: number;
|
|
222
|
+
defaultTtlMs?: number;
|
|
223
|
+
purgeIntervalMs?: number;
|
|
224
|
+
} = {}) {
|
|
225
|
+
this.cache = new FallbackCache({
|
|
226
|
+
maxSize: options.maxSize ?? 200,
|
|
227
|
+
defaultTtlMs: options.defaultTtlMs ?? 5 * 60 * 1000,
|
|
228
|
+
});
|
|
229
|
+
this.purgeIntervalMs = options.purgeIntervalMs ?? 60_000;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Start automatic cache purging
|
|
234
|
+
*/
|
|
235
|
+
start(): void {
|
|
236
|
+
this.purgeInterval = setInterval(() => {
|
|
237
|
+
const purged = this.cache.purgeExpired();
|
|
238
|
+
if (purged > 0) {
|
|
239
|
+
getLogger().debug(`RouteFallbackCache: purged ${purged} expired entries`);
|
|
240
|
+
}
|
|
241
|
+
}, this.purgeIntervalMs).unref();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Stop automatic purging
|
|
246
|
+
*/
|
|
247
|
+
stop(): void {
|
|
248
|
+
if (this.purgeInterval) {
|
|
249
|
+
clearInterval(this.purgeInterval);
|
|
250
|
+
delete this.purgeInterval;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Cache a successful HTTP response
|
|
256
|
+
*/
|
|
257
|
+
cacheResponse(
|
|
258
|
+
route: string,
|
|
259
|
+
method: string,
|
|
260
|
+
response: { body: unknown; statusCode?: number; headers?: Record<string, string> },
|
|
261
|
+
ttlMs?: number
|
|
262
|
+
): void {
|
|
263
|
+
const key = this.buildKey(route, method);
|
|
264
|
+
this.cache.set(key, {
|
|
265
|
+
body: response.body,
|
|
266
|
+
statusCode: response.statusCode ?? 200,
|
|
267
|
+
headers: {
|
|
268
|
+
...response.headers,
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
},
|
|
271
|
+
cachedAt: Date.now(),
|
|
272
|
+
}, ttlMs);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get a cached fallback response for a route
|
|
277
|
+
* Returns undefined if no cache entry or expired
|
|
278
|
+
*/
|
|
279
|
+
getFallback(
|
|
280
|
+
route: string,
|
|
281
|
+
method: string
|
|
282
|
+
): (CachedResponse & { isFallback: true; ageMs: number }) | undefined {
|
|
283
|
+
const key = this.buildKey(route, method);
|
|
284
|
+
const entry = this.cache.get(key);
|
|
285
|
+
|
|
286
|
+
if (!entry) return undefined;
|
|
287
|
+
|
|
288
|
+
getEventBus().emit("fallback:cache-hit", {
|
|
289
|
+
route,
|
|
290
|
+
data: { method, ageMs: Date.now() - entry.cachedAt, key },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
...entry,
|
|
295
|
+
isFallback: true,
|
|
296
|
+
ageMs: Date.now() - entry.cachedAt,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a cache middleware for automatic request/response caching
|
|
302
|
+
*/
|
|
303
|
+
createCacheMiddleware(options: {
|
|
304
|
+
ttlMs?: number;
|
|
305
|
+
shouldCache?: (route: string, method: string) => boolean;
|
|
306
|
+
} = {}): {
|
|
307
|
+
cacheResponse: (route: string, method: string, body: unknown, status?: number) => void;
|
|
308
|
+
getFallback: (route: string, method: string) => (CachedResponse & { isFallback: true; ageMs: number }) | undefined;
|
|
309
|
+
} {
|
|
310
|
+
return {
|
|
311
|
+
cacheResponse: (route, method, body, status = 200) => {
|
|
312
|
+
const shouldCache = options.shouldCache?.(route, method) ?? true;
|
|
313
|
+
if (shouldCache && status < 400) {
|
|
314
|
+
this.cacheResponse(route, method, { body, statusCode: status }, options.ttlMs);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
getFallback: (route, method) => this.getFallback(route, method),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getStats(): CacheStats {
|
|
322
|
+
return this.cache.getStats();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private buildKey(route: string, method: string): string {
|
|
326
|
+
return `${method.toUpperCase()}:${route}`;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file RetryEngine.ts
|
|
3
|
+
* @description Retry engine with Exponential Backoff + Jitter + Retry Budget
|
|
4
|
+
*
|
|
5
|
+
* Key engineering decisions:
|
|
6
|
+
* 1. Exponential backoff with full jitter (not equal jitter) to prevent thundering herd
|
|
7
|
+
* 2. Retry budget prevents global retry storms — max N retries/second across ALL operations
|
|
8
|
+
* 3. isRetryable() classification prevents retrying non-retryable errors (e.g. 400 Bad Request)
|
|
9
|
+
* 4. RetryExhaustedError wraps the last error for full context preservation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RetryConfig } from "../types.js";
|
|
13
|
+
import { RetryExhaustedError } from "../types.js";
|
|
14
|
+
import { DEFAULT_RETRY } from "../config/defaults.js";
|
|
15
|
+
import { getEventBus } from "../event-bus.js";
|
|
16
|
+
import { getLogger } from "../logger.js";
|
|
17
|
+
import { AsyncBoundary } from "../safe-zone/AsyncBoundary.js";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// RETRY BUDGET (Global rate limiter for retries)
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
class RetryBudget {
|
|
24
|
+
private tokens: number;
|
|
25
|
+
private readonly maxTokens: number;
|
|
26
|
+
private lastRefill: number;
|
|
27
|
+
|
|
28
|
+
constructor(maxPerSecond: number) {
|
|
29
|
+
this.maxTokens = maxPerSecond;
|
|
30
|
+
this.tokens = maxPerSecond;
|
|
31
|
+
this.lastRefill = Date.now();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
tryConsume(): boolean {
|
|
35
|
+
this.refill();
|
|
36
|
+
if (this.tokens >= 1) {
|
|
37
|
+
this.tokens -= 1;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private refill(): void {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const elapsed = (now - this.lastRefill) / 1000;
|
|
46
|
+
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.maxTokens);
|
|
47
|
+
this.lastRefill = now;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getAvailable(): number {
|
|
51
|
+
this.refill();
|
|
52
|
+
return Math.floor(this.tokens);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Global budget shared across all RetryEngine instances
|
|
57
|
+
let _globalBudget: RetryBudget | null = null;
|
|
58
|
+
|
|
59
|
+
function getGlobalBudget(maxPerSecond: number): RetryBudget {
|
|
60
|
+
if (!_globalBudget) {
|
|
61
|
+
_globalBudget = new RetryBudget(maxPerSecond);
|
|
62
|
+
}
|
|
63
|
+
return _globalBudget;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// RETRY ENGINE CLASS
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export class RetryEngine {
|
|
71
|
+
private readonly config: Required<RetryConfig>;
|
|
72
|
+
|
|
73
|
+
constructor(config: RetryConfig = {}) {
|
|
74
|
+
this.config = { ...DEFAULT_RETRY, ...config };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Execute a function with retry logic.
|
|
79
|
+
*
|
|
80
|
+
* @param fn - The async function to execute
|
|
81
|
+
* @param operationName - Name for logging/telemetry
|
|
82
|
+
* @returns Result of fn on success
|
|
83
|
+
* @throws RetryExhaustedError if all attempts fail
|
|
84
|
+
*/
|
|
85
|
+
async execute<T>(fn: () => Promise<T>, operationName = "anonymous"): Promise<T> {
|
|
86
|
+
const bus = getEventBus();
|
|
87
|
+
const logger = getLogger();
|
|
88
|
+
const budget = getGlobalBudget(this.config.retryBudget);
|
|
89
|
+
const requestId = AsyncBoundary.getRequestId();
|
|
90
|
+
|
|
91
|
+
let lastError: unknown;
|
|
92
|
+
let attempt = 0;
|
|
93
|
+
|
|
94
|
+
while (true) {
|
|
95
|
+
try {
|
|
96
|
+
const result = await fn();
|
|
97
|
+
|
|
98
|
+
if (attempt > 0) {
|
|
99
|
+
bus.emit("retry:success", {
|
|
100
|
+
requestId,
|
|
101
|
+
data: { operationName, successAfterAttempts: attempt + 1 },
|
|
102
|
+
});
|
|
103
|
+
logger.info(`Retry succeeded after ${attempt + 1} attempts`, { operationName });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
lastError = err;
|
|
109
|
+
attempt++;
|
|
110
|
+
|
|
111
|
+
// Check if retryable
|
|
112
|
+
if (!this.config.isRetryable(err)) {
|
|
113
|
+
logger.debug(`Error not retryable — failing immediately`, {
|
|
114
|
+
operationName,
|
|
115
|
+
attempt,
|
|
116
|
+
error: (err as Error).message,
|
|
117
|
+
});
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check attempt limit
|
|
122
|
+
if (attempt >= this.config.maxAttempts) {
|
|
123
|
+
bus.emit("retry:exhausted", {
|
|
124
|
+
requestId,
|
|
125
|
+
data: { operationName, attempts: attempt },
|
|
126
|
+
error: { name: "RetryExhausted", message: `Failed after ${attempt} attempts` },
|
|
127
|
+
});
|
|
128
|
+
throw new RetryExhaustedError(attempt, lastError);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check global retry budget
|
|
132
|
+
if (!budget.tryConsume()) {
|
|
133
|
+
logger.warn(`Retry budget exhausted — failing immediately to protect system`, {
|
|
134
|
+
operationName,
|
|
135
|
+
attempt,
|
|
136
|
+
budgetAvailable: budget.getAvailable(),
|
|
137
|
+
});
|
|
138
|
+
throw new RetryExhaustedError(attempt, lastError);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Calculate backoff with full jitter
|
|
142
|
+
const delay = this.calculateDelay(attempt);
|
|
143
|
+
|
|
144
|
+
bus.emit("retry:attempt", {
|
|
145
|
+
requestId,
|
|
146
|
+
data: {
|
|
147
|
+
operationName,
|
|
148
|
+
attempt,
|
|
149
|
+
maxAttempts: this.config.maxAttempts,
|
|
150
|
+
nextRetryMs: delay,
|
|
151
|
+
error: (err as Error).message,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
logger.debug(`Retry attempt ${attempt}/${this.config.maxAttempts} in ${delay}ms`, {
|
|
156
|
+
operationName,
|
|
157
|
+
error: (err as Error).message,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await sleep(delay);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private calculateDelay(attempt: number): number {
|
|
166
|
+
// Exponential backoff: baseDelay * 2^attempt
|
|
167
|
+
const exponential = this.config.baseDelayMs * Math.pow(2, attempt - 1);
|
|
168
|
+
// Cap at maxDelayMs
|
|
169
|
+
const capped = Math.min(exponential, this.config.maxDelayMs);
|
|
170
|
+
// Full jitter: random between 0 and capped
|
|
171
|
+
const jitter = Math.random() * capped * this.config.jitterFactor;
|
|
172
|
+
return Math.floor(capped - jitter + jitter * Math.random());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Reset the global retry budget (for testing) */
|
|
176
|
+
static resetBudget(): void {
|
|
177
|
+
_globalBudget = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// FUNCTIONAL API
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Execute a function with retry logic.
|
|
187
|
+
* Functional alternative to RetryEngine class.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* const data = await withRetry(
|
|
191
|
+
* () => fetchFromDatabase(id),
|
|
192
|
+
* { maxAttempts: 3, baseDelayMs: 100 }
|
|
193
|
+
* );
|
|
194
|
+
*/
|
|
195
|
+
export async function withRetry<T>(
|
|
196
|
+
fn: () => Promise<T>,
|
|
197
|
+
config?: RetryConfig,
|
|
198
|
+
operationName?: string
|
|
199
|
+
): Promise<T> {
|
|
200
|
+
const engine = new RetryEngine(config);
|
|
201
|
+
return engine.execute(fn, operationName);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Utility: exponential backoff delay calculator (exported for use in other modules)
|
|
206
|
+
*/
|
|
207
|
+
export function calculateBackoff(
|
|
208
|
+
attempt: number,
|
|
209
|
+
baseMs: number,
|
|
210
|
+
maxMs: number,
|
|
211
|
+
jitterFactor = 0.3
|
|
212
|
+
): number {
|
|
213
|
+
const exponential = baseMs * Math.pow(2, attempt - 1);
|
|
214
|
+
const capped = Math.min(exponential, maxMs);
|
|
215
|
+
const jitter = Math.random() * capped * jitterFactor;
|
|
216
|
+
return Math.floor(capped - jitter + jitter * Math.random());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
// UTILITIES
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function sleep(ms: number): Promise<void> {
|
|
224
|
+
return new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
225
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Timeout.ts
|
|
3
|
+
* @description AbortController-based operation timeout wrapper
|
|
4
|
+
* Ensures no operation can hang indefinitely, blocking resources
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { TimeoutError } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wrap any async operation with a timeout.
|
|
11
|
+
* Uses AbortController for clean cancellation signal propagation.
|
|
12
|
+
*
|
|
13
|
+
* @param fn - Async function that optionally accepts an AbortSignal
|
|
14
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
15
|
+
* @param operationName - Name for error messages
|
|
16
|
+
* @returns Result of fn or throws TimeoutError
|
|
17
|
+
*/
|
|
18
|
+
export async function withTimeout<T>(
|
|
19
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
20
|
+
timeoutMs: number,
|
|
21
|
+
operationName = "operation"
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const { signal } = controller;
|
|
25
|
+
|
|
26
|
+
const timeoutId = setTimeout(() => {
|
|
27
|
+
controller.abort(new TimeoutError(operationName, timeoutMs));
|
|
28
|
+
}, timeoutMs);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = await Promise.race([
|
|
32
|
+
fn(signal),
|
|
33
|
+
new Promise<never>((_, reject) => {
|
|
34
|
+
signal.addEventListener("abort", () => {
|
|
35
|
+
reject(signal.reason instanceof TimeoutError
|
|
36
|
+
? signal.reason
|
|
37
|
+
: new TimeoutError(operationName, timeoutMs)
|
|
38
|
+
);
|
|
39
|
+
}, { once: true });
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
clearTimeout(timeoutId);
|
|
44
|
+
return result;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
clearTimeout(timeoutId);
|
|
47
|
+
controller.abort(); // Ensure abort propagates even on non-timeout errors
|
|
48
|
+
|
|
49
|
+
if (error instanceof TimeoutError) throw error;
|
|
50
|
+
|
|
51
|
+
// If operation was aborted by timeout but threw a different error, wrap it
|
|
52
|
+
if (signal.aborted) {
|
|
53
|
+
throw new TimeoutError(operationName, timeoutMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create a reusable timeout wrapper for a specific timeout value
|
|
62
|
+
*/
|
|
63
|
+
export function createTimeoutWrapper(timeoutMs: number) {
|
|
64
|
+
return function <T>(
|
|
65
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
66
|
+
operationName?: string
|
|
67
|
+
): Promise<T> {
|
|
68
|
+
return withTimeout(fn, timeoutMs, operationName);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Simple timeout without AbortSignal (for legacy code compatibility)
|
|
74
|
+
* Less clean but works with functions that don't accept AbortSignal
|
|
75
|
+
*/
|
|
76
|
+
export async function withSimpleTimeout<T>(
|
|
77
|
+
fn: () => Promise<T>,
|
|
78
|
+
timeoutMs: number,
|
|
79
|
+
operationName = "operation"
|
|
80
|
+
): Promise<T> {
|
|
81
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
|
|
83
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
84
|
+
timeoutId = setTimeout(() => {
|
|
85
|
+
reject(new TimeoutError(operationName, timeoutMs));
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const result = await Promise.race([fn(), timeoutPromise]);
|
|
91
|
+
clearTimeout(timeoutId);
|
|
92
|
+
return result;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
clearTimeout(timeoutId);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file recovery/index.ts
|
|
3
|
+
* @description Recovery layer public exports
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { withTimeout, withSimpleTimeout, createTimeoutWrapper } from "./Timeout.js";
|
|
7
|
+
export { RetryEngine, withRetry, calculateBackoff } from "./RetryEngine.js";
|
|
8
|
+
export { CircuitBreaker, CircuitBreakerRegistry } from "./CircuitBreaker.js";
|
|
9
|
+
export type { CircuitState } from "./CircuitBreaker.js";
|
|
10
|
+
export { FallbackCache, RouteFallbackCache } from "./FallbackCache.js";
|
|
11
|
+
export type { CacheEntry, CacheStats, CachedResponse } from "./FallbackCache.js";
|