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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +577 -0
  3. package/docs/CHANGELOG.md +21 -0
  4. package/docs/CONTRIBUTING.md +41 -0
  5. package/docs/api/chaos.md +179 -0
  6. package/docs/api/dashboard.md +109 -0
  7. package/docs/api/isolation.md +191 -0
  8. package/docs/api/lifecycle.md +187 -0
  9. package/docs/api/monitoring.md +313 -0
  10. package/docs/api/plugins.md +217 -0
  11. package/docs/api/recovery.md +267 -0
  12. package/docs/api/safe-zone.md +236 -0
  13. package/docs/api/supervision.md +285 -0
  14. package/docs/guides/configuration.md +168 -0
  15. package/docs/guides/express.md +171 -0
  16. package/docs/guides/fastify.md +188 -0
  17. package/docs/guides/koa.md +102 -0
  18. package/docs/guides/nestjs.md +182 -0
  19. package/docs/guides/testing.md +91 -0
  20. package/examples/express-basic/index.ts +462 -0
  21. package/examples/express-basic/package.json +21 -0
  22. package/examples/express-basic/tsconfig.json +24 -0
  23. package/examples/fastify-microservice/index.ts +342 -0
  24. package/examples/fastify-microservice/package.json +22 -0
  25. package/examples/fastify-microservice/tsconfig.json +24 -0
  26. package/examples/invoice-service/data/invoices.db +0 -0
  27. package/examples/invoice-service/data/invoices.db-shm +0 -0
  28. package/examples/invoice-service/data/invoices.db-wal +0 -0
  29. package/examples/invoice-service/package.json +25 -0
  30. package/examples/invoice-service/public/index.html +5025 -0
  31. package/examples/invoice-service/src/db.ts +608 -0
  32. package/examples/invoice-service/src/pdf.ts +358 -0
  33. package/examples/invoice-service/src/server.ts +527 -0
  34. package/examples/invoice-service/src/store.ts +159 -0
  35. package/examples/invoice-service/src/types.ts +193 -0
  36. package/examples/invoice-service/tsconfig.json +23 -0
  37. package/examples/nestjs-enterprise/app.module.ts +561 -0
  38. package/examples/nestjs-enterprise/main.ts +67 -0
  39. package/examples/nestjs-enterprise/package.json +26 -0
  40. package/examples/nestjs-enterprise/tsconfig.json +27 -0
  41. package/immortal-js-1.0.0.tgz +0 -0
  42. package/package.json +33 -0
  43. package/packages/adapter-express/package.json +34 -0
  44. package/packages/adapter-express/src/index.ts +349 -0
  45. package/packages/adapter-express/tsconfig.json +14 -0
  46. package/packages/adapter-fastify/package.json +56 -0
  47. package/packages/adapter-fastify/src/plugin.ts +226 -0
  48. package/packages/adapter-fastify/tsconfig.json +14 -0
  49. package/packages/adapter-koa/package.json +55 -0
  50. package/packages/adapter-koa/src/index.ts +207 -0
  51. package/packages/adapter-koa/tsconfig.json +14 -0
  52. package/packages/adapter-nestjs/package.json +61 -0
  53. package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
  54. package/packages/adapter-nestjs/src/index.ts +14 -0
  55. package/packages/adapter-nestjs/tsconfig.json +16 -0
  56. package/packages/core/package.json +56 -0
  57. package/packages/core/src/chaos/ChaosEngine.ts +249 -0
  58. package/packages/core/src/config/defaults.ts +200 -0
  59. package/packages/core/src/config/schema.ts +199 -0
  60. package/packages/core/src/event-bus.ts +168 -0
  61. package/packages/core/src/index.ts +164 -0
  62. package/packages/core/src/isolation/BulkheadPool.ts +279 -0
  63. package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
  64. package/packages/core/src/isolation/index.ts +8 -0
  65. package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
  66. package/packages/core/src/logger.ts +104 -0
  67. package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
  68. package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
  69. package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
  70. package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
  71. package/packages/core/src/monitoring/index.ts +10 -0
  72. package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
  73. package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
  74. package/packages/core/src/recovery/FallbackCache.ts +328 -0
  75. package/packages/core/src/recovery/RetryEngine.ts +225 -0
  76. package/packages/core/src/recovery/Timeout.ts +97 -0
  77. package/packages/core/src/recovery/index.ts +11 -0
  78. package/packages/core/src/runtime.ts +242 -0
  79. package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
  80. package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
  81. package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
  82. package/packages/core/src/safe-zone/index.ts +23 -0
  83. package/packages/core/src/supervision/ClusterManager.ts +243 -0
  84. package/packages/core/src/supervision/RestartStrategy.ts +68 -0
  85. package/packages/core/src/supervision/Supervisor.ts +311 -0
  86. package/packages/core/src/supervision/index.ts +11 -0
  87. package/packages/core/src/types.ts +470 -0
  88. package/packages/core/test/bulkhead.test.ts +310 -0
  89. package/packages/core/test/circuit-breaker.test.ts +153 -0
  90. package/packages/core/test/memory-guard.test.ts +213 -0
  91. package/packages/core/test/retry.test.ts +110 -0
  92. package/packages/core/test/safe-zone.test.ts +271 -0
  93. package/packages/core/test/supervisor.test.ts +310 -0
  94. package/packages/core/tsconfig.json +13 -0
  95. package/packages/dashboard/package.json +56 -0
  96. package/packages/dashboard/server/DashboardServer.ts +454 -0
  97. package/packages/dashboard/tsconfig.json +14 -0
  98. package/tsconfig.json +25 -0
@@ -0,0 +1,188 @@
1
+ # Guide — Fastify Integration
2
+
3
+ This guide walks you through integrating Immortal.js with a Fastify application.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @immortal/core @immortal/fastify fastify
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Quick start
16
+
17
+ ```typescript
18
+ import Fastify from 'fastify';
19
+ import immortalPlugin from '@immortal/fastify';
20
+
21
+ const app = Fastify({ logger: true });
22
+
23
+ await app.register(immortalPlugin, {
24
+ circuitBreakers: true,
25
+ bulkheads: true,
26
+ metrics: true,
27
+ gracefulShutdown: true,
28
+ immortalConfig: {
29
+ logger: { level: 'info' },
30
+ memoryGuard: { enabled: true, warningThresholdMb: 400 },
31
+ },
32
+ });
33
+
34
+ app.get('/api/products', async (req, reply) => {
35
+ return { products: [] };
36
+ });
37
+
38
+ await app.listen({ port: 3000, host: '0.0.0.0' });
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Manual integration (full control)
44
+
45
+ ```typescript
46
+ import Fastify from 'fastify';
47
+ import {
48
+ createImmortal,
49
+ CircuitBreaker,
50
+ BulkheadPool,
51
+ withRetry,
52
+ withTimeout,
53
+ RouteFallbackCache,
54
+ ChaosEngine,
55
+ asyncBoundary,
56
+ } from '@immortal/core';
57
+
58
+ const immortal = await createImmortal({
59
+ logger: { level: process.env.LOG_LEVEL ?? 'info' },
60
+ healthMonitor: { enabled: true },
61
+ gracefulShutdown: { enabled: true, timeoutMs: 30_000 },
62
+ });
63
+
64
+ const app = Fastify();
65
+
66
+ // Circuit breaker per downstream service
67
+ const dbBreaker = new CircuitBreaker('postgres', { threshold: 5, timeout: 30_000 });
68
+ const cacheBreaker = new CircuitBreaker('redis', { threshold: 3, timeout: 10_000 });
69
+
70
+ // Bulkheads per resource type
71
+ const dbPool = new BulkheadPool('database', { maxConcurrent: 10, maxQueueSize: 50 });
72
+ const apiPool = new BulkheadPool('external-api', { maxConcurrent: 5 });
73
+
74
+ // Fallback cache for stale data
75
+ const fallbackCache = new RouteFallbackCache({ ttlMs: 5 * 60_000 });
76
+
77
+ // Request tracing hook
78
+ app.addHook('onRequest', async (req) => {
79
+ req.immortalRequestId = req.headers['x-request-id'] as string ?? crypto.randomUUID();
80
+ });
81
+
82
+ // Metrics hook
83
+ app.addHook('onResponse', async (req, reply) => {
84
+ // record metrics if needed
85
+ });
86
+
87
+ // ─────────────────────────────────────────────────────────
88
+ // Routes
89
+ // ─────────────────────────────────────────────────────────
90
+
91
+ app.get('/api/products/:id', asyncBoundary(async (req, reply) => {
92
+ const { id } = req.params as { id: string };
93
+ const cacheKey = `GET /products/${id}`;
94
+
95
+ try {
96
+ const product = await dbPool.run(() =>
97
+ dbBreaker.run(() =>
98
+ withRetry(() => db.findProduct(id), { maxAttempts: 2 })
99
+ )
100
+ );
101
+
102
+ fallbackCache.set(cacheKey, product);
103
+ return product;
104
+ } catch (err) {
105
+ const cached = fallbackCache.get(cacheKey);
106
+ if (cached) return { ...cached, stale: true };
107
+ throw err;
108
+ }
109
+ }));
110
+
111
+ app.post('/api/orders', asyncBoundary(async (req, reply) => {
112
+ const body = req.body as CreateOrderDto;
113
+
114
+ const result = await dbPool.run(() =>
115
+ withTimeout(
116
+ () => dbBreaker.run(() => db.createOrder(body)),
117
+ 10_000,
118
+ 'create-order'
119
+ )
120
+ );
121
+
122
+ reply.code(201);
123
+ return result;
124
+ }));
125
+
126
+ // Health endpoint (Kubernetes liveness/readiness)
127
+ app.get('/health', async () => ({
128
+ status: immortal.isHealthy() ? 'ok' : 'degraded',
129
+ timestamp: new Date().toISOString(),
130
+ metrics: immortal.getMetrics(),
131
+ }));
132
+
133
+ // Metrics endpoint
134
+ app.get('/metrics', async () => immortal.getMetrics());
135
+
136
+ // ─────────────────────────────────────────────────────────
137
+ // Graceful shutdown
138
+ // ─────────────────────────────────────────────────────────
139
+
140
+ process.on('SIGTERM', async () => {
141
+ await app.close();
142
+ await immortal.shutdown('SIGTERM');
143
+ process.exit(0);
144
+ });
145
+
146
+ await app.listen({ port: 3000, host: '0.0.0.0' });
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Full example
152
+
153
+ See [`examples/fastify-microservice/index.ts`](../../examples/fastify-microservice/index.ts) for a production-ready example with:
154
+
155
+ - Circuit breakers for every downstream service
156
+ - Bulkheads for database and external API calls
157
+ - Retry with exponential backoff
158
+ - Fallback cache for stale data
159
+ - Chaos engineering endpoints
160
+ - Health and metrics routes
161
+ - Graceful shutdown
162
+
163
+ ---
164
+
165
+ ## Error handling
166
+
167
+ Immortal.js provides structured error types. Map them to HTTP status codes:
168
+
169
+ ```typescript
170
+ import { ImmortalError, BulkheadRejectedError, CircuitOpenError, classifyError } from '@immortal/core';
171
+
172
+ app.setErrorHandler((err, req, reply) => {
173
+ if (err instanceof BulkheadRejectedError) {
174
+ return reply.code(503).send({ error: 'Service overloaded', retryAfter: 1 });
175
+ }
176
+ if (err instanceof CircuitOpenError) {
177
+ return reply.code(503).send({ error: 'Dependency unavailable' });
178
+ }
179
+ if (err instanceof ImmortalError) {
180
+ const code = err.statusCode ?? (err.kind === 'programming' ? 500 : 503);
181
+ return reply.code(code).send({ error: err.message, kind: err.kind });
182
+ }
183
+
184
+ const kind = classifyError(err);
185
+ const code = kind === 'programming' ? 500 : 503;
186
+ return reply.code(code).send({ error: 'Internal error', kind });
187
+ });
188
+ ```
@@ -0,0 +1,102 @@
1
+ # Guide — Koa Integration
2
+
3
+ This guide covers integrating Immortal.js with Koa.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @immortal/core @immortal/koa koa
11
+ npm install -D @types/koa
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Plugin setup
17
+
18
+ ```typescript
19
+ import Koa from 'koa';
20
+ import { immortalMiddleware } from '@immortal/koa';
21
+
22
+ const app = new Koa();
23
+
24
+ app.use(immortalMiddleware({
25
+ circuitBreakers: true,
26
+ bulkheads: true,
27
+ metrics: true,
28
+ immortalConfig: {
29
+ logger: { level: 'info' },
30
+ gracefulShutdown: { enabled: true, timeoutMs: 30_000 },
31
+ },
32
+ }));
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Manual integration
38
+
39
+ ```typescript
40
+ import Koa from 'koa';
41
+ import Router from '@koa/router';
42
+ import {
43
+ createImmortal,
44
+ CircuitBreaker,
45
+ BulkheadPool,
46
+ withRetry,
47
+ asyncBoundary,
48
+ classifyError,
49
+ } from '@immortal/core';
50
+
51
+ const immortal = await createImmortal({ logger: { level: 'info' } });
52
+ const app = new Koa();
53
+ const router = new Router();
54
+
55
+ const dbBreaker = new CircuitBreaker('database', { threshold: 5 });
56
+ const dbPool = new BulkheadPool('database', { maxConcurrent: 10 });
57
+
58
+ // Request tracing
59
+ app.use(async (ctx, next) => {
60
+ const requestId = ctx.get('x-request-id') || crypto.randomUUID();
61
+ ctx.set('x-request-id', requestId);
62
+ ctx.state.requestId = requestId;
63
+ await next();
64
+ });
65
+
66
+ // Error handling
67
+ app.use(async (ctx, next) => {
68
+ try {
69
+ await next();
70
+ } catch (err) {
71
+ const kind = classifyError(err);
72
+ ctx.status = kind === 'programming' ? 500 : 503;
73
+ ctx.body = { error: (err as Error).message, kind };
74
+ }
75
+ });
76
+
77
+ // Route
78
+ router.get('/api/products/:id', async (ctx) => {
79
+ const { id } = ctx.params;
80
+
81
+ ctx.body = await dbPool.run(() =>
82
+ dbBreaker.run(() =>
83
+ withRetry(() => db.findProduct(id), { maxAttempts: 2 })
84
+ )
85
+ );
86
+ });
87
+
88
+ router.get('/health', (ctx) => {
89
+ ctx.body = { status: immortal.isHealthy() ? 'ok' : 'degraded' };
90
+ });
91
+
92
+ app.use(router.routes());
93
+ app.use(router.allowedMethods());
94
+
95
+ const server = app.listen(3000);
96
+
97
+ process.on('SIGTERM', async () => {
98
+ server.close();
99
+ await immortal.shutdown('SIGTERM');
100
+ process.exit(0);
101
+ });
102
+ ```
@@ -0,0 +1,182 @@
1
+ # Guide — NestJS Integration
2
+
3
+ This guide covers integrating Immortal.js into a NestJS application using the `@immortal/nestjs` module and its method decorators.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @immortal/core @immortal/nestjs @nestjs/core @nestjs/common reflect-metadata rxjs
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Module setup
16
+
17
+ Import `ImmortalModule` in your root `AppModule`:
18
+
19
+ ```typescript
20
+ // app.module.ts
21
+ import { Module } from '@nestjs/common';
22
+ import { ImmortalModule } from '@immortal/nestjs';
23
+
24
+ @Module({
25
+ imports: [
26
+ ImmortalModule.forRoot({
27
+ logger: { level: 'info' },
28
+ healthMonitor: { enabled: true },
29
+ memoryGuard: { enabled: true, warningThresholdMb: 400 },
30
+ gracefulShutdown: { enabled: true, timeoutMs: 30_000 },
31
+ }),
32
+ ],
33
+ })
34
+ export class AppModule {}
35
+ ```
36
+
37
+ ### Async configuration
38
+
39
+ ```typescript
40
+ ImmortalModule.forRootAsync({
41
+ useFactory: (configService: ConfigService) => ({
42
+ logger: { level: configService.get('LOG_LEVEL') },
43
+ gracefulShutdown: { enabled: true },
44
+ }),
45
+ inject: [ConfigService],
46
+ });
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Injecting the runtime
52
+
53
+ ```typescript
54
+ import { Injectable } from '@nestjs/common';
55
+ import { InjectImmortal, ImmortalService } from '@immortal/nestjs';
56
+
57
+ @Injectable()
58
+ export class MetricsController {
59
+ constructor(private readonly immortal: ImmortalService) {}
60
+
61
+ getHealth() {
62
+ return {
63
+ status: this.immortal.isHealthy() ? 'ok' : 'degraded',
64
+ metrics: this.immortal.getMetrics(),
65
+ };
66
+ }
67
+ }
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Method decorators
73
+
74
+ ### `@Retry(options?)`
75
+
76
+ Wrap a service method with automatic retry:
77
+
78
+ ```typescript
79
+ import { Retry } from '@immortal/nestjs';
80
+
81
+ @Injectable()
82
+ export class PaymentService {
83
+ @Retry({ maxAttempts: 3, baseDelayMs: 200, backoffFactor: 2 })
84
+ async chargeCustomer(customerId: string, amount: number) {
85
+ return this.paymentApi.charge(customerId, amount);
86
+ }
87
+ }
88
+ ```
89
+
90
+ ### `@Circuit(options?)`
91
+
92
+ Protect a method with a circuit breaker:
93
+
94
+ ```typescript
95
+ import { Circuit } from '@immortal/nestjs';
96
+
97
+ @Injectable()
98
+ export class OrdersService {
99
+ @Circuit({ name: 'orders-db', threshold: 5, timeout: 30_000 })
100
+ async findOrders(customerId: string) {
101
+ return this.db.orders.findByCustomer(customerId);
102
+ }
103
+ }
104
+ ```
105
+
106
+ The circuit breaker is shared across all calls to the same `name`. Use the same name for related methods that hit the same downstream service.
107
+
108
+ ### `@Bulkhead(options?)`
109
+
110
+ Limit concurrency for a method:
111
+
112
+ ```typescript
113
+ import { Bulkhead } from '@immortal/nestjs';
114
+
115
+ @Injectable()
116
+ export class ReportService {
117
+ @Bulkhead({ pool: 'reports', maxConcurrent: 2, maxQueueSize: 5 })
118
+ async generateReport(params: ReportParams) {
119
+ return this.reportEngine.generate(params);
120
+ }
121
+ }
122
+ ```
123
+
124
+ ### Combining decorators
125
+
126
+ Decorators compose — apply multiple for layered resilience:
127
+
128
+ ```typescript
129
+ @Injectable()
130
+ export class InventoryService {
131
+ @Bulkhead({ pool: 'inventory', maxConcurrent: 5 })
132
+ @Circuit({ name: 'inventory-db', threshold: 3 })
133
+ @Retry({ maxAttempts: 2 })
134
+ async getStock(productId: string): Promise<StockLevel> {
135
+ return this.db.inventory.find(productId);
136
+ }
137
+ }
138
+ ```
139
+
140
+ **Execution order** (outermost to innermost):
141
+ 1. `@Bulkhead` — wait for a slot
142
+ 2. `@Circuit` — check if circuit is open
143
+ 3. `@Retry` — retry the actual DB call
144
+
145
+ ---
146
+
147
+ ## Health check endpoint
148
+
149
+ ```typescript
150
+ import { Controller, Get } from '@nestjs/common';
151
+ import { ImmortalService } from '@immortal/nestjs';
152
+
153
+ @Controller()
154
+ export class HealthController {
155
+ constructor(private readonly immortal: ImmortalService) {}
156
+
157
+ @Get('/health')
158
+ health() {
159
+ return {
160
+ status: this.immortal.isHealthy() ? 'ok' : 'degraded',
161
+ timestamp: new Date().toISOString(),
162
+ };
163
+ }
164
+
165
+ @Get('/metrics')
166
+ metrics() {
167
+ return this.immortal.getMetrics();
168
+ }
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Full example
175
+
176
+ See [`examples/nestjs-enterprise/`](../../examples/nestjs-enterprise/) for a complete application with:
177
+
178
+ - `ImmortalModule.forRoot` configuration
179
+ - `OrdersService` with `@Bulkhead`, `@Circuit`, `@Retry`
180
+ - `PaymentsService` with circuit breaker
181
+ - Health and metrics endpoints
182
+ - Graceful shutdown integration
@@ -0,0 +1,91 @@
1
+ # Testing Guide
2
+
3
+ This guide covers testing patterns for applications using Immortal.js.
4
+
5
+ ---
6
+
7
+ ## Running the test suite
8
+
9
+ ```bash
10
+ # All packages
11
+ npm test --workspaces --if-present
12
+
13
+ # Core package only
14
+ cd packages/core && npm test
15
+
16
+ # Watch mode
17
+ cd packages/core && npm test -- --watch
18
+
19
+ # Coverage
20
+ cd packages/core && npm test -- --coverage
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Mocking the event bus
26
+
27
+ In unit tests, mock the event bus to avoid side effects:
28
+
29
+ ```typescript
30
+ import { vi, describe, it, beforeEach } from 'vitest';
31
+
32
+ vi.mock('../src/event-bus.js', () => ({
33
+ getEventBus: () => ({ emit: vi.fn(), on: vi.fn(), onAny: vi.fn() }),
34
+ resetEventBus: vi.fn(),
35
+ }));
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Testing circuit breakers
41
+
42
+ ```typescript
43
+ import { CircuitBreaker } from '@immortal/core';
44
+
45
+ it('opens after threshold failures', async () => {
46
+ const cb = new CircuitBreaker('test', { threshold: 2, timeout: 100 });
47
+
48
+ for (let i = 0; i < 2; i++) {
49
+ await expect(cb.run(() => Promise.reject(new Error('fail')))).rejects.toThrow();
50
+ }
51
+
52
+ expect(cb.getCurrentState()).toBe('open');
53
+ await expect(cb.run(() => Promise.resolve('ok'))).rejects.toThrow(/circuit.*open/i);
54
+ });
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Testing bulkheads
60
+
61
+ ```typescript
62
+ import { BulkheadPool, BulkheadRejectedError } from '@immortal/core';
63
+
64
+ it('rejects when saturated', async () => {
65
+ const pool = new BulkheadPool('test', { maxConcurrent: 1, maxQueueSize: 0 });
66
+
67
+ const hold = pool.run(() => new Promise(resolve => setTimeout(resolve, 100)));
68
+
69
+ await expect(pool.run(() => Promise.resolve())).rejects.toBeInstanceOf(BulkheadRejectedError);
70
+ await hold;
71
+ });
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Testing with fake timers
77
+
78
+ ```typescript
79
+ import { vi } from 'vitest';
80
+
81
+ it('retries with backoff', async () => {
82
+ vi.useFakeTimers();
83
+ const fn = vi.fn().mockRejectedValueOnce(new Error('fail')).mockResolvedValue('ok');
84
+
85
+ const promise = withRetry(fn, { maxAttempts: 2, baseDelayMs: 1_000 });
86
+ await vi.advanceTimersByTimeAsync(1_100);
87
+ expect(await promise).toBe('ok');
88
+
89
+ vi.useRealTimers();
90
+ });
91
+ ```