{"_id":"@dcsv-io/d2-resilience","name":"@dcsv-io/d2-resilience","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.2":{"name":"@dcsv-io/d2-resilience","version":"0.1.2","type":"module","main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"dependencies":{"@dcsv-io/d2-logging":"0.1.2","@dcsv-io/d2-result":"0.1.2","@dcsv-io/d2-utilities":"0.1.2"},"devDependencies":{"@vitest/coverage-v8":"4.0.18","typescript":"5.9.3","vitest":"4.0.18"},"scripts":{"build":"tsc -b","test":"vitest run","test:coverage":"vitest run --coverage","type-check:test":"tsc -p tsconfig.test.json"},"_id":"@dcsv-io/d2-resilience@0.1.2","description":"<!-- Copyright (c) DCSV. Licensed under the Apache License, Version 2.0. -->","_integrity":"sha512-l4W7GC7qB0O3LkFblYt7brYTBPNZQbeJwMsUQwVVVENieQkf4m1a/ZY6QJSeG6Bmb9+6g9mAHaVZWrsLZUipPg==","_resolved":"/home/runner/work/D2-Public/D2-Public/bundle/npm/dcsv-io-d2-resilience-0.1.2.tgz","_from":"file:bundle/npm/dcsv-io-d2-resilience-0.1.2.tgz","_nodeVersion":"24.18.0","_npmVersion":"11.16.0","dist":{"integrity":"sha512-l4W7GC7qB0O3LkFblYt7brYTBPNZQbeJwMsUQwVVVENieQkf4m1a/ZY6QJSeG6Bmb9+6g9mAHaVZWrsLZUipPg==","shasum":"da16d9a28baa684d7c2c693f17018b0f4a6585fe","tarball":"https://registry.npmjs.org/@dcsv-io/d2-resilience/-/d2-resilience-0.1.2.tgz","fileCount":71,"unpackedSize":134445,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIAgYilx2vwiKwTGg++mlDYSMa/PeRFweGhgnVI64U/GlAiEAuPPHySii19mjA1CNYnQSvH12EzHA/sFRd/3/7SGN8pI="}]},"_npmUser":{"name":"dcsv-tristan","email":"tristan@dcsv.io"},"directories":{},"maintainers":[{"name":"dcsv-tristan","email":"tristan@dcsv.io"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/d2-resilience_0.1.2_1784485133396_0.5779598561850432"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-19T18:18:53.293Z","0.1.2":"2026-07-19T18:18:53.539Z","modified":"2026-07-19T18:18:53.771Z"},"maintainers":[{"name":"dcsv-tristan","email":"tristan@dcsv.io"}],"description":"<!-- Copyright (c) DCSV. Licensed under the Apache License, Version 2.0. -->","readme":"<!--\nCopyright (c) DCSV. Licensed under the Apache License, Version 2.0.\n-->\n\n# @dcsv-io/d2-resilience\n\nRetry / circuit breaker / singleflight / timeout / rate-limiter / composable\npipeline. Mirrors `DcsvIo.D2.Resilience` (.NET).\n\n## Install\n\n```bash\npnpm add @dcsv-io/d2-resilience\n```\n\n## Public API\n\n| Export                                                                                         | Purpose                                                                                                                        |\n| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `RetryHelper.retryAsync(op, opts?, signal?, rng?)`                                             | Generic retry with backoff + jitter; cancellation never retried; conservative default classifier.                              |\n| `RetryHelper.retryD2ResultAsync(op, opts?, signal?, rng?)`                                     | `D2Result`-aware retry — retries failure shapes matching `shouldRetry`/`isTransient`.                                          |\n| `defaultIsTransient(err)`                                                                      | The default transient-error whitelist (TimeoutError / CircuitOpenError / genuine network failures).                            |\n| `RetryOptions<T>` / `RETRY_DEFAULTS`                                                           | Policy options + sensible defaults (3 attempts / 100ms base / 2x mul / 5s cap / 20% jitter).                                   |\n| `CircuitBreaker<T>`                                                                            | Three-state (Closed / Open / HalfOpen) breaker; single-probe HalfOpen; `fallback` / `isFailure` / `onStateChange` / `reset()`. |\n| `CircuitBreakerOptions<T>` / `CircuitState` / `CircuitOpenError`                               | Config (incl. `isFailure` / `onStateChange`) + state enum + error type.                                                        |\n| `Singleflight<K, V>`                                                                           | In-flight dedup by key.                                                                                                        |\n| `TimeoutLayer` / `TimeoutOptions` / `TimeoutError` / `TIMEOUT_DEFAULTS`                        | Wall-clock deadline per position (total-request or per-attempt). Default: 10 s.                                                |\n| `RateLimiterLayer` / `RateLimiterOptions` / `RateLimitRejectedError` / `RATE_LIMITER_DEFAULTS` | In-process concurrency limiter. Default: 100 slots, reject-fast.                                                               |\n| `ResilientPipeline.execute(key, op, signal?)` / `.PassThrough`                                 | Composed pipeline (`op` receives the `AbortSignal` it should observe) + zero-layer bypass sentinel.                            |\n| `ResilientPipelineBuilder`                                                                     | Fluent builder (outer-first ordering).                                                                                         |\n| `IResilientLayer`                                                                              | Layer contract — `execute(key, op, signal?)`, mirroring .NET `WrapAsync(key, next, ct)`.                                       |\n\n## Dependencies\n\n- `@dcsv-io/d2-utilities` — boundary helpers\n- `@dcsv-io/d2-result` (D2Result-aware retry overload)\n- `@dcsv-io/d2-logging` (reserved for transient-classification log enrichment; not currently consumed)\n\n## Usage example\n\n```ts\nimport {\n  RetryHelper,\n  CircuitBreaker,\n  ResilientPipelineBuilder,\n} from \"@dcsv-io/d2-resilience\";\n\n// Plain retry.\nconst r = await RetryHelper.retryAsync(() => fetchUser(id));\n\n// Pipeline composition — canonical full-stack ordering.\nconst pipe = new ResilientPipelineBuilder()\n  .useSingleflight() // outermost (optional)\n  .useRateLimiter({ maxConcurrency: 10 }) // admission control\n  .useTimeout({ durationMs: 30_000 }) // total-request budget\n  .useRetries({ maxAttempts: 3 })\n  .useCircuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 })\n  .useTimeout({ durationMs: 5_000 }) // per-attempt deadline\n  .build();\n\n// `op` receives the AbortSignal it should observe — pass it INTO fetch so the\n// TimeoutLayer genuinely cancels the request (and releases the socket) on expiry.\nconst data = await pipe.execute(`users:${id}`, (signal) =>\n  fetch(`/users/${id}`, { signal }),\n);\n\n// Bypass (no resilience — raw call, same call-site shape).\nconst raw = await ResilientPipeline.PassThrough.execute(\n  `users:${id}`,\n  (signal) => fetch(`/users/${id}`, { signal }),\n);\n```\n\n## Canonical layer ordering\n\nThe builder uses **outer-first ordering** — the first call added is the outermost\nwrapper. The canonical full-stack order (matching the .NET standard):\n\n```\nSingleflight → RateLimiter → TotalTimeout → Retry → CircuitBreaker → PerAttemptTimeout\n```\n\n**Order matters.** A circuit breaker _outside_ a retry trips after N total\nfailures across all retry attempts. A circuit breaker _inside_ a retry trips\nduring a single burst of N consecutive per-attempt failures and may re-close\non the next retry. Placing a rate limiter outermost ensures rejected callers\nnever consume retry or timeout budget.\n\n`useTimeout` can be called **twice** to add both a total-request timeout (outer,\nabove `useRetries`) and a per-attempt timeout (inner, below `useRetries`). Both\nare independent `TimeoutLayer` instances — the outer fires across all retries\ncombined; the inner fires per individual attempt and allows an outer `useRetries`\nto re-attempt.\n\n## Cancellation contract (`AbortSignal` threading)\n\n`IResilientLayer.execute(key, op, signal?)` threads an optional `AbortSignal`\ndown the layer stack — the structural mirror of .NET's\n`WrapAsync(key, next, ct)` / `CancellationToken`. The op is handed the\n`AbortSignal` it should observe for cooperative cancellation:\n\n```ts\nconst controller = new AbortController();\nconst data = await pipe.execute(\n  `users:${id}`,\n  (signal) => fetch(url, { signal }), // pass the threaded signal INTO fetch\n  controller.signal, // caller's own cancellation (optional)\n);\n```\n\nA layer may **substitute** the signal it passes inward: `TimeoutLayer` hands the\nop a signal linked to BOTH the caller's signal and its own deadline (mirroring\nthe .NET `TimeoutLayer` linked `CancellationToken`), so the op is genuinely\ncanceled on timeout. `Singleflight` is the deliberate exception — see below.\n\n## CircuitBreaker\n\nThree-state breaker (Closed → Open → HalfOpen) at **full feature parity** with\n.NET `CircuitBreaker<T>`:\n\n```ts\nconst cb = new CircuitBreaker<UserDto>({\n  failureThreshold: 5,\n  cooldownMs: 30_000,\n  // Value-based failures (a returned-but-failed value counts WITHOUT throwing):\n  isFailure: (r) => r.status === \"error\",\n  // Observability seam — the breaker emits no telemetry of its own:\n  onStateChange: (from, to) => metrics.circuitTransition(from, to),\n});\n\n// fallback (optional 2nd arg) serves cached/default data when the circuit is\n// open (or a HalfOpen probe slot is already taken) instead of throwing:\nconst user = await cb.execute(\n  () => fetchUser(id),\n  () => CACHED_USER, // omit to throw CircuitOpenError instead\n);\n\ncb.reset(); // manual return to Closed (clears the failure count)\n```\n\n- **HalfOpen single-probe.** After the cooldown elapses, exactly ONE caller is\n admitted as the probe; concurrent callers that arrive while the probe is\n in-flight receive the `fallback` (when supplied) or `CircuitOpenError`. JS is\n single-threaded, so the breaker uses a synchronous check-and-set on a\n probe-in-flight flag (performed before the first `await`) — the structural\n equivalent of .NET's lock-free `Interlocked.CompareExchange`. This prevents a\n thundering-herd of probes hammering a recovering upstream.\n- **`isFailure` value predicate.** Thrown errors ALWAYS count as failures.\n `isFailure` additionally counts a returned (non-thrown) value as a failure —\n essential for operations that surface failures as values (e.g. a `D2Result`).\n A value satisfying the predicate increments the failure counter and is then\n returned to the caller unchanged (it is NOT re-thrown).\n- **`fallback`.** The optional second argument to `execute`. Invoked when the\n circuit is Open or a HalfOpen probe slot is taken; returns the fallback's\n value instead of throwing `CircuitOpenError`.\n- **`onStateChange(from, to)`.** Fires synchronously on every REAL transition\n (an idempotent Closed→Closed on repeated success does NOT fire). The canonical\n observability seam. **Footgun:** a THROWING callback propagates out of\n `execute()` and REPLACES the upstream error — keep the body to non-throwing\n log/metric calls (matches the .NET remark).\n- **`reset()`.** Manually returns the breaker to Closed, clearing the failure\n count + probe flag; fires `onStateChange` only when the state actually changed.\n\nThe pipeline `CircuitBreakerLayer` calls `execute(op)` with **no fallback** (as\nthe .NET `CircuitBreakerLayer` does), so an open breaker throws `CircuitOpenError`\nto the pipeline boundary for the caller to map.\n\n## Retry — default transient classifier\n\nWhen a caller supplies NEITHER `shouldRetry` NOR `isTransient`, the retry helper\nclassifies errors with a **conservative whitelist** (`defaultIsTransient`) that\nmirrors the INTENT of .NET `RetryHelper.IsTransientException`: retry ONLY genuine\ntransient / network / timeout conditions, NEVER arbitrary programming bugs.\n\nThe JS error taxonomy differs from .NET's (there is no `HttpRequestException` /\n`SocketException`), so the TS transient set — matched by error `name`, the same\nconvention as the cancellation check — is:\n\n| TS error                                       | Transient? | .NET analogue                                   |\n| ---------------------------------------------- | ---------- | ----------------------------------------------- |\n| `TimeoutError` (this lib's `TimeoutLayer`)     | ✅ yes     | `TimeoutException` / `TaskCanceledException`    |\n| `CircuitOpenError`                             | ✅ yes     | `CircuitOpenException`                          |\n| `Error` with `name === \"NetworkError\"`         | ✅ yes     | (DOM/whatwg network error)                      |\n| `TypeError` with a `cause` (undici net fail)   | ✅ yes     | `SocketException` (network-level fetch failure) |\n| `TypeError` with a known fetch-failure message | ✅ yes     | `SocketException` (e.g. `\"fetch failed\"`)       |\n| plain `Error` / `RangeError` / assertion       | ❌ no      | (not in the .NET whitelist)                     |\n| bare `TypeError` (no cause, non-net message)   | ❌ no      | (programming bug — not transient)               |\n| non-`Error` thrown value                       | ❌ no      | (not transient)                                 |\n| `AbortError` / message `\"aborted\"`             | ❌ no      | caller cancellation (never retried)             |\n\nA caller-cancellation `AbortError` is rejected BEFORE the classifier and is\nnever retried. To retry an otherwise-non-transient error, supply an explicit\n`isTransient` / `shouldRetry` predicate.\n\n## TimeoutLayer\n\nBounds the inner operation with a wall-clock deadline AND **genuinely cancels**\nit on expiry. The op receives a linked `AbortSignal` that aborts when EITHER the\ncaller's signal fires OR the timeout elapses. On expiry the layer aborts that\nsignal — so a cooperative op (e.g. a `fetch`) is actually canceled and its\nsocket released — AND rejects with `TimeoutError` (name `\"TimeoutError\"`). The\ndeadline stays deterministic: the inner promise is raced against the timer, so a\nnon-cooperative op (one that ignores its signal) still times out.\n\n`TimeoutError` is distinct from a caller-initiated `AbortError`: an outer retry\nlayer treats `TimeoutError` as transient and re-attempts, but a caller abort is\nnever retried. A caller-initiated abort propagates as the caller's `AbortError`,\nNOT masked as `TimeoutError` (matching .NET's\n`when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)`\nguard — a caller cancellation wins over a coincident timeout).\n\nThe timer and the caller-signal listener are always cleaned up on every settle\npath (no leak). `durationMs <= 0` disables the timeout — the layer becomes a\npass-through (no timer is created; the caller signal is forwarded unchanged).\n\n## Singleflight cancellation guarantee\n\nThe deduplicated shared operation runs with **no signal** (`undefined`, the JS\nanalogue of `CancellationToken.None`): one caller aborting must NOT cancel the\nshared work that the other waiters still depend on. Each caller's _wait_ is\ncancellable by that caller's own signal — an aborting caller's promise rejects\nwith `AbortError` while the shared op continues and the remaining waiters\nreceive its result. This mirrors .NET `Singleflight.ExecuteAsync`'s\n`Task.WaitAsync(ct)` (cancels the wait, never the shared `Task`).\n\n## RateLimiterLayer\n\nHand-rolled in-process concurrency limiter (counter + FIFO waiter queue).\nLimits the number of concurrent in-flight operations to `maxConcurrency`.\nA caller that cannot acquire a permit within `acquisitionTimeoutMs` is rejected\nvia `RateLimitRejectedError` (not queued indefinitely).\n\n`acquisitionTimeoutMs <= 0` means reject-fast (non-blocking). Permits are\nreleased in a `finally` block — released on both success and throw, so no\npermits leak on inner-op failures.\n\n`maxConcurrency < 1` throws `RangeError` at construction (fail-loud).\n\n**Client-side, in-process only.** This is admission control for outbound calls\n— it limits concurrent pressure from this process on an upstream. It is NOT\nthe server-side distributed rate-limit middleware.\n\n## Caller-side opt-in and bypass\n\nResilience is **opt-in** — it costs latency (retries, timeouts, admission waits)\nand should be an explicit caller choice. Three usage modes:\n\n1. **Declared default** — resolve a keyed pipeline registered at the\n composition root and pass it to the client method.\n2. **Custom override** — pass a caller-owned `ResilientPipeline` to the client\n method, overriding any declared default.\n3. **Bypass** — use `ResilientPipeline.PassThrough` for a zero-layer pipeline\n that runs the op directly with no wrapping.\n\n## Parity with .NET\n\nMirrors `DcsvIo.D2.Resilience`:\n\n- `RetryHelper.retryAsync` ↔ `RetryHelper.RetryAsync<T>` — same conservative\n default classifier (`defaultIsTransient` ↔ `IsTransientException`): only\n genuine transient/network/timeout errors retry when no caller predicate is\n supplied.\n- `RetryHelper.retryD2ResultAsync` ↔ `RetryHelper.RetryD2ResultAsync<T>` —\n same \"only retry transient fail-results\" carve-out.\n- `CircuitBreaker` ↔ `CircuitBreaker<T>` — **full feature parity**: same\n three-state lifecycle, HalfOpen single-probe enforcement, `isFailure`\n value-based failure predicate, `fallback`, `onStateChange` observability\n seam, and `reset()`.\n- `Singleflight` ↔ `Singleflight<TKey, TValue>` — same key-coalescing; same\n per-caller-cancellation-only guarantee (the shared op runs uncancellable by\n any single caller; each caller's wait is cancellable by that caller's signal).\n- `TimeoutLayer` ↔ `TimeoutLayer<TKey, TValue>` — same deadline semantics; same\n linked-signal cooperative cancellation (TS threads an `AbortSignal` linked to\n the caller signal + the deadline, mirroring the .NET linked `CancellationToken`);\n timeout surfaces as a distinct error (not caller-abort); a caller abort wins\n over a coincident timeout.\n- `RateLimiterLayer` ↔ `RateLimiterLayer<TKey, TValue>` — same concurrency-\n limiter semantics; hand-rolled (no `System.Threading.RateLimiting` equivalent\n in Node).\n- `ResilientPipeline.PassThrough` ↔ `ResilientPipeline<TKey, TValue>.PassThrough`.\n- `ResilientPipelineBuilder` ↔ `ResilientPipelineBuilder` — same outer-first\n ordering; same layer-at-two-positions capability.\n- Cancellation never classified as transient (matches .NET behavior).\n\n**Documented divergences (ADR-0014):**\n\n- TS pipeline returns `Promise<T>` (throws); .NET pipeline returns\n `D2Result<T>` (maps). Callers map `TimeoutError` / `RateLimitRejectedError`\n to their own `D2Result` shape.\n- TS jitter is a fractional multiplier (e.g. `0.2` = ±20%); .NET jitter is a\n boolean (full-jitter `random(0, computed)`).\n- **Retry numeric defaults** differ intentionally: TS `RETRY_DEFAULTS` =\n 3 attempts / 100 ms base / 5 s cap; .NET `RetryDefaults` = 5 attempts /\n 1000 ms base / 30 s cap. The faster TS defaults are tuned for browser/Node\n UX — a browser request must not spend 30 s retrying — aligning with the same\n browser/Node-timing rationale as the multiplicative-jitter choice. Both\n surfaces accept explicit overrides; only the no-override default differs.\n- TS key type is always `string`; .NET key is generic `TKey`.\n- TS threads an `AbortSignal` through `IResilientLayer.execute(key, op, signal?)`\n — the structural mirror of .NET's `CancellationToken` in\n `WrapAsync(key, next, ct)`. `TimeoutLayer` cancels the inner op via a linked\n signal; `Singleflight` runs the shared op with no signal (≈ `CancellationToken.None`).\n\n## Edge cases\n\n- An already-aborted `AbortSignal` short-circuits the retry loop and the\n rate-limiter gate before the first attempt (`AbortError`).\n- A caller abort while the rate-limiter is waiting for a permit rejects\n `AbortError` and does not consume / leak a permit.\n- On `TimeoutLayer` expiry the inner op's (linked) signal is aborted — a\n cooperative op is genuinely canceled; a caller abort wins over a coincident\n timeout (`AbortError`, not `TimeoutError`).\n- A `Singleflight` caller aborting cancels only its own wait — the shared op\n keeps running and the remaining waiters still receive its result.\n- `maxAttempts < 1` → `RangeError`.\n- `TimeoutLayer` with `durationMs <= 0` → pass-through (no timer created).\n- `RateLimiterLayer` with `maxConcurrency < 1` → `RangeError` at construction.\n- `Singleflight` clears entries after settle — back-pressure does not\n accumulate indefinitely.\n- HalfOpen failure re-arms cooldown (single-trip semantics).\n- HalfOpen admits exactly ONE probe; concurrent callers during the probe get\n the `fallback` (or `CircuitOpenError` when none is supplied).\n- An Open `CircuitBreaker` with a `fallback` returns the fallback's value\n instead of throwing `CircuitOpenError`.\n- A returned value satisfying `isFailure` trips the breaker WITHOUT throwing.\n- `onStateChange` fires only on a real transition (idempotent Closed→Closed on\n repeated success does not fire); `reset()` returns to Closed and clears the\n failure count.\n- A non-transient default error (plain `Error`, programming `TypeError`, etc.)\n is NOT retried unless an explicit `isTransient` / `shouldRetry` opts it in.\n- `CircuitBreaker` rejects `failureThreshold < 1` and `cooldownMs < 0`.\n","readmeFilename":"README.md","_rev":"1-289d132e9a64d5acb152b21687f1c8f8"}