@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,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
+ }