@vereda/http 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 +604 -0
- package/dist/adapters/zod.d.ts +14 -0
- package/dist/adapters/zod.d.ts.map +1 -0
- package/dist/adapters/zod.js +14 -0
- package/dist/adapters/zod.js.map +1 -0
- package/dist/core/backoff.d.ts +9 -0
- package/dist/core/backoff.d.ts.map +1 -0
- package/dist/core/backoff.js +23 -0
- package/dist/core/backoff.js.map +1 -0
- package/dist/core/client.d.ts +98 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +781 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/errors.d.ts +87 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +140 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +10 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/listeners.d.ts +14 -0
- package/dist/core/listeners.d.ts.map +1 -0
- package/dist/core/listeners.js +27 -0
- package/dist/core/listeners.js.map +1 -0
- package/dist/core/metrics.d.ts +33 -0
- package/dist/core/metrics.d.ts.map +1 -0
- package/dist/core/metrics.js +24 -0
- package/dist/core/metrics.js.map +1 -0
- package/dist/core/nanoid.d.ts +2 -0
- package/dist/core/nanoid.d.ts.map +1 -0
- package/dist/core/nanoid.js +11 -0
- package/dist/core/nanoid.js.map +1 -0
- package/dist/core/redact.d.ts +12 -0
- package/dist/core/redact.d.ts.map +1 -0
- package/dist/core/redact.js +42 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/types.d.ts +261 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +41 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validate.d.ts +19 -0
- package/dist/core/validate.d.ts.map +1 -0
- package/dist/core/validate.js +135 -0
- package/dist/core/validate.js.map +1 -0
- package/dist/middleware/index.d.ts +26 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +55 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/queue/bulkhead.d.ts +63 -0
- package/dist/queue/bulkhead.d.ts.map +1 -0
- package/dist/queue/bulkhead.js +192 -0
- package/dist/queue/bulkhead.js.map +1 -0
- package/dist/queue/circuit-breaker.d.ts +81 -0
- package/dist/queue/circuit-breaker.d.ts.map +1 -0
- package/dist/queue/circuit-breaker.js +283 -0
- package/dist/queue/circuit-breaker.js.map +1 -0
- package/dist/queue/executor.d.ts +67 -0
- package/dist/queue/executor.d.ts.map +1 -0
- package/dist/queue/executor.js +273 -0
- package/dist/queue/executor.js.map +1 -0
- package/dist/queue/policy.d.ts +26 -0
- package/dist/queue/policy.d.ts.map +1 -0
- package/dist/queue/policy.js +37 -0
- package/dist/queue/policy.js.map +1 -0
- package/dist/queue/retry.d.ts +58 -0
- package/dist/queue/retry.d.ts.map +1 -0
- package/dist/queue/retry.js +259 -0
- package/dist/queue/retry.js.map +1 -0
- package/dist/queue/semaphore.d.ts +32 -0
- package/dist/queue/semaphore.d.ts.map +1 -0
- package/dist/queue/semaphore.js +83 -0
- package/dist/queue/semaphore.js.map +1 -0
- package/dist/ticket/ticket.d.ts +77 -0
- package/dist/ticket/ticket.d.ts.map +1 -0
- package/dist/ticket/ticket.js +186 -0
- package/dist/ticket/ticket.js.map +1 -0
- package/package.json +85 -0
- package/src/adapters/zod.ts +16 -0
- package/src/core/backoff.ts +26 -0
- package/src/core/client.ts +1048 -0
- package/src/core/errors.ts +194 -0
- package/src/core/index.ts +56 -0
- package/src/core/listeners.ts +28 -0
- package/src/core/metrics.ts +42 -0
- package/src/core/nanoid.ts +11 -0
- package/src/core/redact.ts +46 -0
- package/src/core/types.ts +306 -0
- package/src/core/validate.ts +163 -0
- package/src/middleware/index.ts +63 -0
- package/src/queue/bulkhead.ts +243 -0
- package/src/queue/circuit-breaker.ts +373 -0
- package/src/queue/executor.ts +355 -0
- package/src/queue/policy.ts +49 -0
- package/src/queue/retry.ts +380 -0
- package/src/queue/semaphore.ts +91 -0
- package/src/ticket/ticket.ts +246 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import type { AppError } from "../core/errors.ts";
|
|
2
|
+
import {
|
|
3
|
+
type CircuitBreakerConfig,
|
|
4
|
+
DEFAULT_FAILURE_THRESHOLD,
|
|
5
|
+
DEFAULT_HALF_OPEN_MAX_ATTEMPTS,
|
|
6
|
+
DEFAULT_RESET_TIMEOUT_MS,
|
|
7
|
+
type PartitionConfig,
|
|
8
|
+
} from "../core/types.ts";
|
|
9
|
+
import { DEFAULT_PARTITION_TTL_MS } from "./bulkhead.ts";
|
|
10
|
+
import { RETRIABLE_KINDS } from "./policy.ts";
|
|
11
|
+
|
|
12
|
+
type CircuitState = "closed" | "open" | "half-open";
|
|
13
|
+
|
|
14
|
+
/** Error kinds that mean the attempt never left the process — no request was
|
|
15
|
+
* ever dispatched, so the host's health is untouched. Currently only a body
|
|
16
|
+
* factory (`RequestOptions.body` as a function) that threw before `fetch`
|
|
17
|
+
* was called (`src/queue/executor.ts`). A raw-`ReadableStream` body is
|
|
18
|
+
* rejected even earlier, before the breaker is asked for a permit at all
|
|
19
|
+
* (`client.ts` validates it ahead of `tryAcquire()`), so it never reaches
|
|
20
|
+
* this classification in the first place. */
|
|
21
|
+
const NEVER_SENT_KINDS: ReadonlySet<AppError["kind"]> = new Set(["configuration"]);
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Rolling window — fixed-size time buckets for the failure-percentage mode
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
interface Bucket {
|
|
28
|
+
/** Bucket start time (ms since epoch), used to determine expiry. */
|
|
29
|
+
start: number;
|
|
30
|
+
total: number;
|
|
31
|
+
failures: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_BUCKET_COUNT = 10;
|
|
35
|
+
|
|
36
|
+
class RollingWindow {
|
|
37
|
+
private readonly sizeMs: number;
|
|
38
|
+
private readonly bucketMs: number;
|
|
39
|
+
private buckets: Bucket[] = [];
|
|
40
|
+
|
|
41
|
+
constructor(sizeMs: number, bucketCount: number = DEFAULT_BUCKET_COUNT) {
|
|
42
|
+
this.sizeMs = sizeMs;
|
|
43
|
+
this.bucketMs = Math.max(1, Math.floor(sizeMs / bucketCount));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
recordSuccess(now: number = Date.now()): void {
|
|
47
|
+
const bucket = this.currentBucket(now);
|
|
48
|
+
bucket.total++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
recordFailure(now: number = Date.now()): void {
|
|
52
|
+
const bucket = this.currentBucket(now);
|
|
53
|
+
bucket.total++;
|
|
54
|
+
bucket.failures++;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
totalRequests(now: number = Date.now()): number {
|
|
58
|
+
this.prune(now);
|
|
59
|
+
return this.buckets.reduce((sum, b) => sum + b.total, 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
failureRate(now: number = Date.now()): number {
|
|
63
|
+
this.prune(now);
|
|
64
|
+
const total = this.buckets.reduce((sum, b) => sum + b.total, 0);
|
|
65
|
+
if (total === 0) return 0;
|
|
66
|
+
const failures = this.buckets.reduce((sum, b) => sum + b.failures, 0);
|
|
67
|
+
return (failures / total) * 100;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private currentBucket(now: number): Bucket {
|
|
71
|
+
this.prune(now);
|
|
72
|
+
const bucketStart = Math.floor(now / this.bucketMs) * this.bucketMs;
|
|
73
|
+
const last = this.buckets[this.buckets.length - 1];
|
|
74
|
+
if (last && last.start === bucketStart) {
|
|
75
|
+
return last;
|
|
76
|
+
}
|
|
77
|
+
const bucket: Bucket = { start: bucketStart, total: 0, failures: 0 };
|
|
78
|
+
this.buckets.push(bucket);
|
|
79
|
+
return bucket;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private prune(now: number): void {
|
|
83
|
+
const cutoff = now - this.sizeMs;
|
|
84
|
+
this.buckets = this.buckets.filter((b) => b.start >= cutoff);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// CircuitBreaker — one per partition
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** One admission through the breaker, returned by `tryAcquire()`. Exactly one
|
|
93
|
+
* of its methods takes effect (later calls are no-ops), so a caller can
|
|
94
|
+
* report the outcome and still `release()` unconditionally in a `finally`.
|
|
95
|
+
* In half-open, the permit owns a trial slot; every exit path — success,
|
|
96
|
+
* failure, a non-failure error, cancellation, a veto — must hand it back,
|
|
97
|
+
* or the breaker is wedged half-open with no trial slots left (B1). */
|
|
98
|
+
export interface CircuitPermit {
|
|
99
|
+
/** The attempt succeeded. */
|
|
100
|
+
success(): void;
|
|
101
|
+
/** The attempt failed with `error`. Errors the breaker doesn't classify as
|
|
102
|
+
* failures (e.g. a 404) count as successes — the host answered. An error
|
|
103
|
+
* that shows the attempt never reached the host (e.g. a body factory that
|
|
104
|
+
* threw) is ignored instead — same effect as calling `release()` — since
|
|
105
|
+
* it carries no information about the host's health. */
|
|
106
|
+
failure(error: AppError): void;
|
|
107
|
+
/** The permit was not used to reach the server (cancelled, vetoed, queue
|
|
108
|
+
* full…). Frees a half-open trial slot without recording an outcome. */
|
|
109
|
+
release(): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class CircuitBreaker {
|
|
113
|
+
private readonly partition: string;
|
|
114
|
+
private readonly config: CircuitBreakerConfig;
|
|
115
|
+
private readonly onStateChange?: (partition: string, state: "open" | "closed") => void;
|
|
116
|
+
|
|
117
|
+
private state: CircuitState = "closed";
|
|
118
|
+
private consecutiveFailures = 0;
|
|
119
|
+
private openedAt = 0;
|
|
120
|
+
private halfOpenInFlight = 0;
|
|
121
|
+
/** Bumped on every open -> half-open transition, so a permit can tell
|
|
122
|
+
* whether the trial slot it reserved still belongs to the current
|
|
123
|
+
* half-open episode or to one that has since ended. */
|
|
124
|
+
private halfOpenGeneration = 0;
|
|
125
|
+
private readonly window?: RollingWindow;
|
|
126
|
+
|
|
127
|
+
constructor(
|
|
128
|
+
partition: string,
|
|
129
|
+
config: CircuitBreakerConfig = {},
|
|
130
|
+
onStateChange?: (partition: string, state: "open" | "closed") => void,
|
|
131
|
+
) {
|
|
132
|
+
this.partition = partition;
|
|
133
|
+
this.config = config;
|
|
134
|
+
this.onStateChange = onStateChange;
|
|
135
|
+
if (config.window) {
|
|
136
|
+
this.window = new RollingWindow(config.window.sizeMs);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether a request may proceed against this partition right now.
|
|
141
|
+
* Always true when the breaker is disabled. Handles the open -> half-open
|
|
142
|
+
* transition on `resetTimeoutMs` elapse, and reserves a half-open trial
|
|
143
|
+
* slot atomically so concurrent callers don't over-admit trials. */
|
|
144
|
+
canRequest(): boolean {
|
|
145
|
+
if (!this.config.enabled) return true;
|
|
146
|
+
|
|
147
|
+
if (this.state === "open") {
|
|
148
|
+
const resetTimeoutMs = this.config.resetTimeoutMs ?? DEFAULT_RESET_TIMEOUT_MS;
|
|
149
|
+
if (Date.now() - this.openedAt >= resetTimeoutMs) {
|
|
150
|
+
this.state = "half-open";
|
|
151
|
+
this.halfOpenInFlight = 0;
|
|
152
|
+
this.halfOpenGeneration++;
|
|
153
|
+
} else {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (this.state === "half-open") {
|
|
159
|
+
const maxAttempts = this.config.halfOpenMaxAttempts ?? DEFAULT_HALF_OPEN_MAX_ATTEMPTS;
|
|
160
|
+
if (this.halfOpenInFlight >= maxAttempts) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
// Reserve the slot immediately (side-effecting) so two callers
|
|
164
|
+
// arriving before either trial resolves can't both be admitted.
|
|
165
|
+
this.halfOpenInFlight++;
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// closed
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Admit a request, or return `null` when the circuit rejects it. Prefer
|
|
174
|
+
* this over the raw `canRequest()`/`record*()` pair: the returned permit
|
|
175
|
+
* guarantees a reserved half-open trial slot is always given back. */
|
|
176
|
+
tryAcquire(): CircuitPermit | null {
|
|
177
|
+
if (!this.canRequest()) return null;
|
|
178
|
+
|
|
179
|
+
// null = admitted while closed (or disabled) — no trial slot held.
|
|
180
|
+
const trialGeneration = this.config.enabled && this.state === "half-open" ? this.halfOpenGeneration : null;
|
|
181
|
+
const isCurrentTrial = () => trialGeneration !== null && trialGeneration === this.halfOpenGeneration;
|
|
182
|
+
// A request admitted while closed that completes during a later
|
|
183
|
+
// half-open episode must not decide that episode: only its own trials do.
|
|
184
|
+
const mayRecord = () => this.state !== "half-open" || isCurrentTrial();
|
|
185
|
+
const freeTrialSlot = () => {
|
|
186
|
+
if (this.state === "half-open" && isCurrentTrial()) {
|
|
187
|
+
this.halfOpenInFlight = Math.max(0, this.halfOpenInFlight - 1);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
let settled = false;
|
|
192
|
+
const settle = (fn: () => void) => {
|
|
193
|
+
if (settled) return;
|
|
194
|
+
settled = true;
|
|
195
|
+
fn();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
success: () =>
|
|
200
|
+
settle(() => {
|
|
201
|
+
if (mayRecord()) this.recordSuccess();
|
|
202
|
+
}),
|
|
203
|
+
failure: (error) =>
|
|
204
|
+
settle(() => {
|
|
205
|
+
if (!mayRecord()) return;
|
|
206
|
+
// Never-sent outcomes are ignored outright — not even isFailure is
|
|
207
|
+
// consulted, since there is no host-health signal to classify.
|
|
208
|
+
if (NEVER_SENT_KINDS.has(error.kind)) {
|
|
209
|
+
freeTrialSlot();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Classify once here and record the already-classified failure
|
|
213
|
+
// directly — `recordFailure()` would consult isFailure a second time.
|
|
214
|
+
if (this.isFailure(error)) {
|
|
215
|
+
this.recordClassifiedFailure();
|
|
216
|
+
} else {
|
|
217
|
+
this.recordNeutral();
|
|
218
|
+
freeTrialSlot();
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
release: () => settle(freeTrialSlot),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Record a successful attempt outcome. No-op when disabled. */
|
|
226
|
+
recordSuccess(): void {
|
|
227
|
+
if (!this.config.enabled) return;
|
|
228
|
+
|
|
229
|
+
if (this.state === "half-open") {
|
|
230
|
+
this.halfOpenInFlight = Math.max(0, this.halfOpenInFlight - 1);
|
|
231
|
+
this.state = "closed";
|
|
232
|
+
this.resetCounters();
|
|
233
|
+
this.onStateChange?.(this.partition, "closed");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (this.state === "closed") {
|
|
238
|
+
this.consecutiveFailures = 0;
|
|
239
|
+
this.window?.recordSuccess();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Record a failed attempt outcome. No-op when disabled or when the error
|
|
244
|
+
* is not classified as a failure. */
|
|
245
|
+
recordFailure(error: AppError): void {
|
|
246
|
+
if (!this.config.enabled) return;
|
|
247
|
+
if (!this.isFailure(error)) return;
|
|
248
|
+
this.recordClassifiedFailure();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Record a failure the caller has already classified via `isFailure`, so a
|
|
252
|
+
* user-supplied classifier runs exactly once per outcome. No-op when disabled. */
|
|
253
|
+
private recordClassifiedFailure(): void {
|
|
254
|
+
if (!this.config.enabled) return;
|
|
255
|
+
|
|
256
|
+
if (this.state === "half-open") {
|
|
257
|
+
this.halfOpenInFlight = Math.max(0, this.halfOpenInFlight - 1);
|
|
258
|
+
this.state = "open";
|
|
259
|
+
this.openedAt = Date.now();
|
|
260
|
+
this.onStateChange?.(this.partition, "open");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (this.state === "closed") {
|
|
265
|
+
this.consecutiveFailures++;
|
|
266
|
+
this.window?.recordFailure();
|
|
267
|
+
if (this.evaluateTripCondition()) {
|
|
268
|
+
this.state = "open";
|
|
269
|
+
this.openedAt = Date.now();
|
|
270
|
+
this.onStateChange?.(this.partition, "open");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Not consulted for a `NEVER_SENT_KINDS` error (`tryAcquire()` short-circuits
|
|
276
|
+
* before calling this) — such an error never reached the host, so there is
|
|
277
|
+
* nothing for a caller-supplied classifier to legitimately weigh in on. */
|
|
278
|
+
private isFailure(error: AppError): boolean {
|
|
279
|
+
return this.config.isFailure ? this.config.isFailure(error) : RETRIABLE_KINDS.has(error.kind);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** An attempt reached the server but ended in an error the breaker does
|
|
283
|
+
* not count as a failure — e.g. a 404, or a 200 whose body failed `parse`.
|
|
284
|
+
* The breaker measures availability, and the host answered, so this is
|
|
285
|
+
* recorded as a success in every state (the Resilience4j default for
|
|
286
|
+
* unrecorded errors): closed resets the consecutive-failure run and counts
|
|
287
|
+
* as a non-failure in the rolling window; a half-open trial closes the
|
|
288
|
+
* circuit. Classify it as a failure via `isFailure` to change that. */
|
|
289
|
+
private recordNeutral(): void {
|
|
290
|
+
this.recordSuccess();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private resetCounters(): void {
|
|
294
|
+
this.consecutiveFailures = 0;
|
|
295
|
+
this.halfOpenInFlight = 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private evaluateTripCondition(): boolean {
|
|
299
|
+
const windowConfig = this.config.window;
|
|
300
|
+
if (this.window && windowConfig) {
|
|
301
|
+
return (
|
|
302
|
+
this.window.totalRequests() >= windowConfig.minimumRequests &&
|
|
303
|
+
this.window.failureRate() > windowConfig.failureRatePercent
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return this.consecutiveFailures >= (this.config.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// CircuitBreakerRegistry — one breaker per partition, mirrors BulkheadRegistry
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
type CircuitBreakerEntry = [CircuitBreaker, number];
|
|
315
|
+
|
|
316
|
+
export class CircuitBreakerRegistry {
|
|
317
|
+
private readonly breakers = new Map<string, CircuitBreakerEntry>();
|
|
318
|
+
private readonly ttlMs: number;
|
|
319
|
+
private readonly globalConfig: CircuitBreakerConfig;
|
|
320
|
+
private readonly partitionConfigs: Record<string, PartitionConfig>;
|
|
321
|
+
private readonly sweepInterval: number;
|
|
322
|
+
private readonly onStateChange?: (partition: string, state: "open" | "closed") => void;
|
|
323
|
+
private callCounter = 0;
|
|
324
|
+
|
|
325
|
+
constructor(
|
|
326
|
+
globalConfig: CircuitBreakerConfig = {},
|
|
327
|
+
partitionConfigs: Record<string, PartitionConfig> = {},
|
|
328
|
+
ttlMs: number = DEFAULT_PARTITION_TTL_MS,
|
|
329
|
+
onStateChange?: (partition: string, state: "open" | "closed") => void,
|
|
330
|
+
) {
|
|
331
|
+
this.ttlMs = ttlMs;
|
|
332
|
+
this.globalConfig = globalConfig;
|
|
333
|
+
this.partitionConfigs = partitionConfigs;
|
|
334
|
+
this.sweepInterval = 10;
|
|
335
|
+
this.onStateChange = onStateChange;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
get(partitionName: string): CircuitBreaker {
|
|
339
|
+
this.callCounter++;
|
|
340
|
+
|
|
341
|
+
if (!this.breakers.has(partitionName)) {
|
|
342
|
+
const partitionConfig = this.partitionConfigs[partitionName]?.circuitBreaker ?? {};
|
|
343
|
+
const merged: CircuitBreakerConfig = {
|
|
344
|
+
...this.globalConfig,
|
|
345
|
+
...partitionConfig,
|
|
346
|
+
};
|
|
347
|
+
this.breakers.set(partitionName, [new CircuitBreaker(partitionName, merged, this.onStateChange), Date.now()]);
|
|
348
|
+
}
|
|
349
|
+
const [cb] = this.breakers.get(partitionName)!;
|
|
350
|
+
// Update last accessed time
|
|
351
|
+
this.breakers.set(partitionName, [cb, Date.now()]);
|
|
352
|
+
|
|
353
|
+
// Sweep stale entries periodically
|
|
354
|
+
if (this.callCounter % this.sweepInterval === 0 || this.breakers.size > 100) {
|
|
355
|
+
this.prune();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return cb;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
prune(): void {
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
for (const [key, [, lastAccessed]] of this.breakers) {
|
|
364
|
+
if (now - lastAccessed > this.ttlMs) {
|
|
365
|
+
this.breakers.delete(key);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
delete(partitionName: string): void {
|
|
371
|
+
this.breakers.delete(partitionName);
|
|
372
|
+
}
|
|
373
|
+
}
|