@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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +604 -0
  3. package/dist/adapters/zod.d.ts +14 -0
  4. package/dist/adapters/zod.d.ts.map +1 -0
  5. package/dist/adapters/zod.js +14 -0
  6. package/dist/adapters/zod.js.map +1 -0
  7. package/dist/core/backoff.d.ts +9 -0
  8. package/dist/core/backoff.d.ts.map +1 -0
  9. package/dist/core/backoff.js +23 -0
  10. package/dist/core/backoff.js.map +1 -0
  11. package/dist/core/client.d.ts +98 -0
  12. package/dist/core/client.d.ts.map +1 -0
  13. package/dist/core/client.js +781 -0
  14. package/dist/core/client.js.map +1 -0
  15. package/dist/core/errors.d.ts +87 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +140 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/index.d.ts +15 -0
  20. package/dist/core/index.d.ts.map +1 -0
  21. package/dist/core/index.js +10 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/core/listeners.d.ts +14 -0
  24. package/dist/core/listeners.d.ts.map +1 -0
  25. package/dist/core/listeners.js +27 -0
  26. package/dist/core/listeners.js.map +1 -0
  27. package/dist/core/metrics.d.ts +33 -0
  28. package/dist/core/metrics.d.ts.map +1 -0
  29. package/dist/core/metrics.js +24 -0
  30. package/dist/core/metrics.js.map +1 -0
  31. package/dist/core/nanoid.d.ts +2 -0
  32. package/dist/core/nanoid.d.ts.map +1 -0
  33. package/dist/core/nanoid.js +11 -0
  34. package/dist/core/nanoid.js.map +1 -0
  35. package/dist/core/redact.d.ts +12 -0
  36. package/dist/core/redact.d.ts.map +1 -0
  37. package/dist/core/redact.js +42 -0
  38. package/dist/core/redact.js.map +1 -0
  39. package/dist/core/types.d.ts +261 -0
  40. package/dist/core/types.d.ts.map +1 -0
  41. package/dist/core/types.js +41 -0
  42. package/dist/core/types.js.map +1 -0
  43. package/dist/core/validate.d.ts +19 -0
  44. package/dist/core/validate.d.ts.map +1 -0
  45. package/dist/core/validate.js +135 -0
  46. package/dist/core/validate.js.map +1 -0
  47. package/dist/middleware/index.d.ts +26 -0
  48. package/dist/middleware/index.d.ts.map +1 -0
  49. package/dist/middleware/index.js +55 -0
  50. package/dist/middleware/index.js.map +1 -0
  51. package/dist/queue/bulkhead.d.ts +63 -0
  52. package/dist/queue/bulkhead.d.ts.map +1 -0
  53. package/dist/queue/bulkhead.js +192 -0
  54. package/dist/queue/bulkhead.js.map +1 -0
  55. package/dist/queue/circuit-breaker.d.ts +81 -0
  56. package/dist/queue/circuit-breaker.d.ts.map +1 -0
  57. package/dist/queue/circuit-breaker.js +283 -0
  58. package/dist/queue/circuit-breaker.js.map +1 -0
  59. package/dist/queue/executor.d.ts +67 -0
  60. package/dist/queue/executor.d.ts.map +1 -0
  61. package/dist/queue/executor.js +273 -0
  62. package/dist/queue/executor.js.map +1 -0
  63. package/dist/queue/policy.d.ts +26 -0
  64. package/dist/queue/policy.d.ts.map +1 -0
  65. package/dist/queue/policy.js +37 -0
  66. package/dist/queue/policy.js.map +1 -0
  67. package/dist/queue/retry.d.ts +58 -0
  68. package/dist/queue/retry.d.ts.map +1 -0
  69. package/dist/queue/retry.js +259 -0
  70. package/dist/queue/retry.js.map +1 -0
  71. package/dist/queue/semaphore.d.ts +32 -0
  72. package/dist/queue/semaphore.d.ts.map +1 -0
  73. package/dist/queue/semaphore.js +83 -0
  74. package/dist/queue/semaphore.js.map +1 -0
  75. package/dist/ticket/ticket.d.ts +77 -0
  76. package/dist/ticket/ticket.d.ts.map +1 -0
  77. package/dist/ticket/ticket.js +186 -0
  78. package/dist/ticket/ticket.js.map +1 -0
  79. package/package.json +85 -0
  80. package/src/adapters/zod.ts +16 -0
  81. package/src/core/backoff.ts +26 -0
  82. package/src/core/client.ts +1048 -0
  83. package/src/core/errors.ts +194 -0
  84. package/src/core/index.ts +56 -0
  85. package/src/core/listeners.ts +28 -0
  86. package/src/core/metrics.ts +42 -0
  87. package/src/core/nanoid.ts +11 -0
  88. package/src/core/redact.ts +46 -0
  89. package/src/core/types.ts +306 -0
  90. package/src/core/validate.ts +163 -0
  91. package/src/middleware/index.ts +63 -0
  92. package/src/queue/bulkhead.ts +243 -0
  93. package/src/queue/circuit-breaker.ts +373 -0
  94. package/src/queue/executor.ts +355 -0
  95. package/src/queue/policy.ts +49 -0
  96. package/src/queue/retry.ts +380 -0
  97. package/src/queue/semaphore.ts +91 -0
  98. package/src/ticket/ticket.ts +246 -0
@@ -0,0 +1,163 @@
1
+ import { ConfigurationError } from "./errors.ts";
2
+ import type {
3
+ CircuitBreakerConfig,
4
+ ClientConfig,
5
+ PartitionConfig,
6
+ RequestOptions,
7
+ RetryConfig,
8
+ TimeoutConfig,
9
+ } from "./types.ts";
10
+
11
+ // Every numeric check is written so that NaN fails it: `NaN <= 0` is false,
12
+ // so a bare `x <= 0` guard would let NaN through (and `attemptMs: NaN` would
13
+ // silently mean "no timeout"). `!(x > 0)` rejects it.
14
+
15
+ function isPositiveInteger(value: number): boolean {
16
+ return Number.isInteger(value) && value >= 1;
17
+ }
18
+
19
+ /** Realm-safe ReadableStream detection — instanceof fails across realms
20
+ * (vm contexts, other copies of node:stream/web). No non-stream BodyInit
21
+ * member has getReader, so duck-typing is safe here. */
22
+ export function isReadableStream(body: unknown): body is ReadableStream {
23
+ return body != null && typeof (body as { getReader?: unknown }).getReader === "function";
24
+ }
25
+
26
+ /** A raw ReadableStream body cannot be replayed across retries, so it must be
27
+ * supplied via a factory. Throws ConfigurationError otherwise. */
28
+ export function validateRequestBody(body: BodyInit | (() => BodyInit) | undefined): void {
29
+ if (isReadableStream(body)) {
30
+ throw new ConfigurationError("body must be supplied as a factory (() => BodyInit) when it is a ReadableStream");
31
+ }
32
+ }
33
+
34
+ export function validateConfig(config: ClientConfig): void {
35
+ if (config.baseUrl !== undefined && !URL.canParse(config.baseUrl)) {
36
+ throw new ConfigurationError("baseUrl must be an absolute URL");
37
+ }
38
+
39
+ if (config.concurrency !== undefined && !isPositiveInteger(config.concurrency)) {
40
+ throw new ConfigurationError("concurrency must be a positive integer");
41
+ }
42
+
43
+ // 0 is valid globally: no waiting, overflow rejects with QueueFullError.
44
+ if (config.maxQueueSize !== undefined && !(Number.isInteger(config.maxQueueSize) && config.maxQueueSize >= 0)) {
45
+ throw new ConfigurationError("maxQueueSize must be a non-negative integer");
46
+ }
47
+
48
+ if (!config.timeout || config.timeout.attemptMs === undefined) {
49
+ throw new ConfigurationError(
50
+ "timeout.attemptMs is required — pass a positive number, or Infinity to explicitly opt out of a per-attempt timeout",
51
+ );
52
+ }
53
+ validateTimeoutConfig(config.timeout, "timeout");
54
+ validateRetryConfig(config.retry, "retry");
55
+ validateCircuitBreakerConfig(config.circuitBreaker, "circuitBreaker");
56
+ validatePartitions(config.partitions);
57
+ }
58
+
59
+ /** Validates a single request's own `timeout`/`retry` options — the raw
60
+ * values passed to `client.get()`/`.post()`/etc., before they're merged onto
61
+ * partition/client defaults. Reuses the same rules as `validateConfig`, so
62
+ * an invalid request-level value (e.g. `timeout: { attemptMs: -5 }`) is
63
+ * rejected the same way an invalid client config is, just surfaced as a
64
+ * ticket `ConfigurationError` instead of a throw from `create()`. Omitted
65
+ * fields stay valid (inherit the client/partition default), and `Infinity`
66
+ * stays a legal explicit `attemptMs`/`totalMs`. */
67
+ export function validateRequestOptions(options: Pick<RequestOptions, "timeout" | "retry">): void {
68
+ validateTimeoutConfig(options.timeout, "request.timeout");
69
+ validateRetryConfig(options.retry, "request.retry");
70
+ }
71
+
72
+ function validateTimeoutConfig(timeout: TimeoutConfig | undefined, prefix: string): void {
73
+ if (!timeout) return;
74
+
75
+ if (timeout.attemptMs !== undefined && !(timeout.attemptMs > 0)) {
76
+ throw new ConfigurationError(`${prefix}.attemptMs must be positive`);
77
+ }
78
+
79
+ if (timeout.totalMs !== undefined && !(timeout.totalMs > 0)) {
80
+ throw new ConfigurationError(`${prefix}.totalMs must be positive`);
81
+ }
82
+ }
83
+
84
+ function validateRetryConfig(retry: RetryConfig | undefined, prefix: string): void {
85
+ if (!retry) return;
86
+
87
+ if (retry.maxRetries !== undefined && !(Number.isInteger(retry.maxRetries) && retry.maxRetries >= 0)) {
88
+ throw new ConfigurationError(`${prefix}.maxRetries must be a non-negative integer`);
89
+ }
90
+
91
+ if (retry.retryOnStatus !== undefined) {
92
+ for (const status of retry.retryOnStatus) {
93
+ if (!Number.isInteger(status) || status < 400 || status > 599) {
94
+ throw new ConfigurationError(
95
+ `${prefix}.retryOnStatus must contain only integer HTTP error status codes (400-599)`,
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ if (retry.backoff && typeof retry.backoff === "object") {
102
+ const { baseDelayMs, maxDelayMs } = retry.backoff;
103
+ if (baseDelayMs !== undefined && !(baseDelayMs >= 0)) {
104
+ throw new ConfigurationError(`${prefix}.backoff.baseDelayMs must be non-negative`);
105
+ }
106
+ if (maxDelayMs !== undefined && !(maxDelayMs >= 0)) {
107
+ throw new ConfigurationError(`${prefix}.backoff.maxDelayMs must be non-negative`);
108
+ }
109
+ if (baseDelayMs !== undefined && maxDelayMs !== undefined && baseDelayMs > maxDelayMs) {
110
+ throw new ConfigurationError(`${prefix}.backoff.baseDelayMs must not exceed maxDelayMs`);
111
+ }
112
+ }
113
+ }
114
+
115
+ function validatePartitions(partitions: Record<string, PartitionConfig> | undefined): void {
116
+ if (!partitions) return;
117
+ for (const [name, config] of Object.entries(partitions)) {
118
+ if (config.concurrency !== undefined && !isPositiveInteger(config.concurrency)) {
119
+ throw new ConfigurationError(`partitions.${name}.concurrency must be a positive integer`);
120
+ }
121
+ if (config.maxQueueSize !== undefined && !isPositiveInteger(config.maxQueueSize)) {
122
+ throw new ConfigurationError(`partitions.${name}.maxQueueSize must be a positive integer`);
123
+ }
124
+ validateRetryConfig(config.retry, `partitions.${name}.retry`);
125
+ validateTimeoutConfig(config.timeout, `partitions.${name}.timeout`);
126
+ validateCircuitBreakerConfig(config.circuitBreaker, `partitions.${name}.circuitBreaker`);
127
+ }
128
+ }
129
+
130
+ function validateCircuitBreakerConfig(breaker: CircuitBreakerConfig | undefined, prefix: string): void {
131
+ if (!breaker) return;
132
+
133
+ if (breaker.failureThreshold !== undefined && !isPositiveInteger(breaker.failureThreshold)) {
134
+ throw new ConfigurationError(`${prefix}.failureThreshold must be a positive integer`);
135
+ }
136
+ // Finite: the open -> half-open transition is a timer, and an unbounded
137
+ // reset would keep the circuit open forever.
138
+ if (
139
+ breaker.resetTimeoutMs !== undefined &&
140
+ !(Number.isFinite(breaker.resetTimeoutMs) && breaker.resetTimeoutMs > 0)
141
+ ) {
142
+ throw new ConfigurationError(`${prefix}.resetTimeoutMs must be a positive finite number`);
143
+ }
144
+ // 0 would admit no half-open trial, so the circuit could never close again.
145
+ if (breaker.halfOpenMaxAttempts !== undefined && !isPositiveInteger(breaker.halfOpenMaxAttempts)) {
146
+ throw new ConfigurationError(`${prefix}.halfOpenMaxAttempts must be a positive integer`);
147
+ }
148
+ if (breaker.isFailure !== undefined && typeof breaker.isFailure !== "function") {
149
+ throw new ConfigurationError(`${prefix}.isFailure must be a function`);
150
+ }
151
+
152
+ const { window } = breaker;
153
+ if (window === undefined) return;
154
+ if (!(Number.isFinite(window.sizeMs) && window.sizeMs > 0)) {
155
+ throw new ConfigurationError(`${prefix}.window.sizeMs must be a positive finite number`);
156
+ }
157
+ if (!(window.failureRatePercent > 0 && window.failureRatePercent <= 100)) {
158
+ throw new ConfigurationError(`${prefix}.window.failureRatePercent must be greater than 0 and at most 100`);
159
+ }
160
+ if (!isPositiveInteger(window.minimumRequests)) {
161
+ throw new ConfigurationError(`${prefix}.window.minimumRequests must be a positive integer`);
162
+ }
163
+ }
@@ -0,0 +1,63 @@
1
+ import { redactUrl } from "../core/redact.ts";
2
+
3
+ export type { MiddlewareFn, NextFn, RequestContext } from "../queue/executor.ts";
4
+
5
+ /**
6
+ * Adds a base set of headers to every request. Only sets a header the
7
+ * request doesn't already have — comparison is case-insensitive, so a
8
+ * request-level `authorization` header wins over a default `Authorization`.
9
+ *
10
+ * @example
11
+ * client.use(defaultHeaders({ 'X-Api-Key': 'secret' }));
12
+ */
13
+ export function defaultHeaders(headers: HeadersInit): import("../queue/executor.ts").MiddlewareFn {
14
+ return async (ctx, next) => {
15
+ for (const [key, value] of new Headers(headers)) {
16
+ if (!ctx.headers.has(key)) {
17
+ ctx.headers.set(key, value);
18
+ }
19
+ }
20
+ return next(ctx);
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Logs request timing to the console (or a provided logger). Logged URLs are
26
+ * redacted like the client's own (`redactUrl`): query values and userinfo
27
+ * credentials are replaced with `[redacted]`. Middleware can't see the
28
+ * client's `redactQuery` setting, so pass `redactQuery: false` here too to
29
+ * log raw URLs.
30
+ *
31
+ * @example
32
+ * client.use(requestLogger());
33
+ */
34
+ export function requestLogger(options?: {
35
+ log?: (msg: string, meta: Record<string, unknown>) => void;
36
+ /** @default true */
37
+ redactQuery?: boolean;
38
+ }): import("../queue/executor.ts").MiddlewareFn {
39
+ // biome-ignore lint/suspicious/noConsole: console is the intended default sink for this opt-in logger middleware; callers override it via options.log.
40
+ const log = options?.log ?? ((msg, meta) => console.log(msg, meta));
41
+
42
+ const shownUrl = options?.redactQuery === false ? (url: string) => url : redactUrl;
43
+
44
+ return async (ctx, next) => {
45
+ const start = Date.now();
46
+ try {
47
+ const response = await next(ctx);
48
+ log("Request completed", {
49
+ url: shownUrl(ctx.url),
50
+ status: response.status,
51
+ durationMs: Date.now() - start,
52
+ });
53
+ return response;
54
+ } catch (err) {
55
+ log("Request failed", {
56
+ url: shownUrl(ctx.url),
57
+ error: err instanceof Error ? err.message : String(err),
58
+ durationMs: Date.now() - start,
59
+ });
60
+ throw err;
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,243 @@
1
+ import { CancelledError, QueueFullError } from "../core/errors.ts";
2
+ import { DEFAULT_CONCURRENCY, DEFAULT_MAX_QUEUE_SIZE, type PartitionConfig } from "../core/types.ts";
3
+ import type { Semaphore } from "./semaphore.ts";
4
+
5
+ /** Default TTL (ms) before an idle partition registry entry (bulkhead or
6
+ * circuit breaker) is swept from its registry. Shared by both registries
7
+ * so the eviction policy stays in one place. */
8
+ export const DEFAULT_PARTITION_TTL_MS = 60_000;
9
+
10
+ export class Bulkhead {
11
+ public readonly name: string;
12
+ private readonly concurrency: number;
13
+ private readonly maxQueueSize: number;
14
+ private readonly _limitFirstAttempts: boolean;
15
+ private running = 0;
16
+ private readonly _waitQueue: Array<() => void> = [];
17
+
18
+ constructor(name: string, config: PartitionConfig = {}) {
19
+ this.name = name;
20
+ this.concurrency = config.concurrency ?? DEFAULT_CONCURRENCY;
21
+ this.maxQueueSize = config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
22
+ this._limitFirstAttempts = config.limitFirstAttempts ?? false;
23
+ }
24
+
25
+ get queueSize(): number {
26
+ return this._waitQueue.length;
27
+ }
28
+
29
+ get runningCount(): number {
30
+ return this.running;
31
+ }
32
+
33
+ get concurrencyLimit(): number {
34
+ return this.concurrency;
35
+ }
36
+
37
+ get maxQueueSizeLimit(): number {
38
+ return this.maxQueueSize;
39
+ }
40
+
41
+ get limitFirstAttempts(): boolean {
42
+ return this._limitFirstAttempts;
43
+ }
44
+
45
+ /** Acquire a concurrency slot, execute `task`, release the slot.
46
+ * Rejects with `QueueFullError` when the queue is at capacity.
47
+ * `this.running` is decremented *before* the returned Promise resolves,
48
+ * giving consumers an accurate count immediately after `await`.
49
+ * When a global `semaphore` is provided, a permit is acquired after the
50
+ * partition slot and released before it (D4).
51
+ * `onDequeue`, if given, fires once — right before `task()` starts —
52
+ * with the ms elapsed since `run()` was called, covering both the
53
+ * partition wait and the semaphore wait.
54
+ * If `signal` aborts while the caller is still waiting (for a partition
55
+ * slot or a global permit), it leaves the queue and the promise rejects
56
+ * with `CancelledError` without running `task` (B9). Once `task` has
57
+ * started, cancelling it is the task's own job. */
58
+ run<T>(
59
+ task: () => Promise<T>,
60
+ semaphore?: Semaphore,
61
+ onDequeue?: (queuedMs: number) => void,
62
+ signal?: AbortSignal,
63
+ ): Promise<T> {
64
+ const enqueuedAt = Date.now();
65
+ return new Promise<T>((resolve, reject) => {
66
+ if (signal?.aborted) {
67
+ reject(new CancelledError());
68
+ return;
69
+ }
70
+
71
+ const onAbort = () => {
72
+ const idx = this._waitQueue.indexOf(execute);
73
+ if (idx !== -1) this._waitQueue.splice(idx, 1);
74
+ reject(new CancelledError());
75
+ };
76
+
77
+ const execute = () => {
78
+ signal?.removeEventListener("abort", onAbort);
79
+ this.running++;
80
+ // `release`, when given, must run BEFORE _releaseSlot(): releasing
81
+ // the slot synchronously drains the next waiter, which may call
82
+ // semaphore.acquire() immediately — if the permit hasn't actually
83
+ // been freed yet (i.e. this ran after _releaseSlot instead of
84
+ // before), that acquire can spuriously see none available and
85
+ // reject even though one was about to free.
86
+ const runTask = (release?: () => void) => {
87
+ onDequeue?.(Date.now() - enqueuedAt);
88
+ return task().then(
89
+ (result) => {
90
+ release?.();
91
+ this._releaseSlot();
92
+ resolve(result);
93
+ },
94
+ (err) => {
95
+ release?.();
96
+ this._releaseSlot();
97
+ reject(err);
98
+ },
99
+ );
100
+ };
101
+
102
+ if (semaphore) {
103
+ semaphore.acquire(signal).then(
104
+ (release) => {
105
+ // void: outcomes are routed to the outer resolve/reject inside runTask.
106
+ void runTask(release);
107
+ },
108
+ (err) => {
109
+ // The partition slot was granted but the global semaphore
110
+ // rejected (queue full, or cancelled while waiting for a
111
+ // permit) — release the slot we already
112
+ // counted, or it leaks as a permanently phantom-running slot.
113
+ // Report how long this attempt actually waited before being
114
+ // told no; it never reached runTask's own onDequeue call.
115
+ onDequeue?.(Date.now() - enqueuedAt);
116
+ this._releaseSlot();
117
+ reject(err);
118
+ },
119
+ );
120
+ } else {
121
+ // void: outcomes are routed to the outer resolve/reject inside runTask.
122
+ void runTask();
123
+ }
124
+ };
125
+
126
+ if (this.running < this.concurrency) {
127
+ execute();
128
+ } else if (this._waitQueue.length < this.maxQueueSize) {
129
+ signal?.addEventListener("abort", onAbort, { once: true });
130
+ this._waitQueue.push(execute);
131
+ } else {
132
+ reject(new QueueFullError(this.name, this._waitQueue.length, this.maxQueueSize));
133
+ }
134
+ });
135
+ }
136
+
137
+ /** Free a counted slot and hand it to the next waiter, if any. Every path
138
+ * that decrements `running` — task success, task failure, and a rejected
139
+ * semaphore acquire after the slot was already granted — must go through
140
+ * this, or a slot leaks as permanently phantom-running. */
141
+ private _releaseSlot(): void {
142
+ this.running--;
143
+ this._drainWaitQueue();
144
+ }
145
+
146
+ private _drainWaitQueue(): void {
147
+ while (this.running < this.concurrency && this._waitQueue.length > 0) {
148
+ const next = this._waitQueue.shift()!;
149
+ next();
150
+ }
151
+ }
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Bulkhead registry — one bulkhead per partition
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export interface BulkheadSnapshot {
159
+ name: string;
160
+ running: number;
161
+ queued: number;
162
+ concurrency: number;
163
+ maxQueueSize: number;
164
+ }
165
+
166
+ type BulkheadEntry = [Bulkhead, number];
167
+
168
+ export class BulkheadRegistry {
169
+ private readonly bulkheads = new Map<string, BulkheadEntry>();
170
+ private readonly ttlMs: number;
171
+ private readonly globalConfig: PartitionConfig;
172
+ private readonly partitionConfigs: Record<string, PartitionConfig>;
173
+ private readonly sweepInterval: number;
174
+ private readonly semaphore?: Semaphore;
175
+ private callCounter = 0;
176
+
177
+ constructor(
178
+ globalConfig: PartitionConfig = {},
179
+ partitionConfigs: Record<string, PartitionConfig> = {},
180
+ ttlMs: number = DEFAULT_PARTITION_TTL_MS,
181
+ semaphore?: Semaphore,
182
+ ) {
183
+ this.ttlMs = ttlMs;
184
+ this.globalConfig = globalConfig;
185
+ this.partitionConfigs = partitionConfigs;
186
+ this.sweepInterval = 10;
187
+ this.semaphore = semaphore;
188
+ }
189
+
190
+ get(partitionName: string): Bulkhead {
191
+ this.callCounter++;
192
+
193
+ if (!this.bulkheads.has(partitionName)) {
194
+ const partitionConfig = this.partitionConfigs[partitionName] ?? {};
195
+ const merged: PartitionConfig = {
196
+ ...this.globalConfig,
197
+ ...partitionConfig,
198
+ };
199
+ this.bulkheads.set(partitionName, [new Bulkhead(partitionName, merged), Date.now()]);
200
+ }
201
+ const [bh] = this.bulkheads.get(partitionName)!;
202
+ // Update last accessed time
203
+ this.bulkheads.set(partitionName, [bh, Date.now()]);
204
+
205
+ // Sweep stale entries periodically
206
+ if (this.callCounter % this.sweepInterval === 0 || this.bulkheads.size > 100) {
207
+ this.prune();
208
+ }
209
+
210
+ return bh;
211
+ }
212
+
213
+ prune(): void {
214
+ const now = Date.now();
215
+ for (const [key, [, lastAccessed]] of this.bulkheads) {
216
+ if (now - lastAccessed > this.ttlMs) {
217
+ this.bulkheads.delete(key);
218
+ }
219
+ }
220
+ }
221
+
222
+ delete(partitionName: string): void {
223
+ this.bulkheads.delete(partitionName);
224
+ }
225
+
226
+ getAll(): BulkheadSnapshot[] {
227
+ const result: BulkheadSnapshot[] = [];
228
+ for (const [, [bh]] of this.bulkheads) {
229
+ result.push({
230
+ name: bh.name,
231
+ running: bh.runningCount,
232
+ queued: bh.queueSize,
233
+ concurrency: bh.concurrencyLimit,
234
+ maxQueueSize: bh.maxQueueSizeLimit,
235
+ });
236
+ }
237
+ return result;
238
+ }
239
+
240
+ getSemaphore(): Semaphore | undefined {
241
+ return this.semaphore;
242
+ }
243
+ }