breakwater
v1.1.2
Published
Resilience toolkit for Node.js — retry, circuit breaker, timeout, and policy composition with first-class observability.
Maintainers
Readme
breakwater
Resilience toolkit for Node.js — retry, circuit breaker, timeout, fallback, and policy composition with first-class observability.
When waves of failure hit, the breakwater keeps your service standing. It brings the design of resilience4j (Java) and Polly (.NET) to Node.js: composable resilience policies with explicit ordering, typed events, and metrics — no third-party plugins required.
import { resilience, exponential } from 'breakwater'
const payments = resilience({
retry: { attempts: 3, backoff: exponential({ initial: 200 }) },
circuitBreaker: { name: 'payments-api', failureThreshold: 0.5 },
timeout: 2_000,
fallback: () => ({ status: 'pending', queued: true })
})
const charge = await payments.execute(({ signal }) => api.post('/charge', body, { signal }))Table of contents
- Why another resilience library?
- Install
- Quick start
- Core concepts
- The policies
- Composing policies
- Observability
- Errors
- Documentation
- Requirements
Why another resilience library?
| | breakwater | opossum | cockatiel |
|---|---|---|---|
| Circuit breaker | ✅ | ✅ | ✅ |
| Retry with backoff strategies | ✅ | ➖ rudimentary | ✅ |
| Timeout / fallback | ✅ | ➖ fallback only | ✅ |
| Bulkhead | ✅ | ❌ | ✅ |
| Rate limiter (token bucket / sliding window) | ✅ | ❌ | ❌ |
| Stale-while-open cache (last good response) | ✅ staleCache | ❌ | ❌ |
| Policy composition with explicit ordering | ✅ first-class | ❌ | ✅ |
| Named policy registry (central config) | ✅ | ❌ | ❌ |
| Typed events + pluggable metrics collector | ✅ native | ➖ via plugin | ❌ |
| Prometheus / OpenTelemetry adapters | ✅ /prometheus · /otel | ➖ via plugin | ❌ |
| Distributed circuit breaker state (Redis) | ✅ /redis | ❌ | ❌ |
| TypeScript | ✅ native | ➖ via @types | ✅ native |
| Runtime dependencies | 0 | 0 | 0 |
Design principles:
- TypeScript first — strict types, no
@typespackage - Zero dependencies in the core — integrations ship as optional entry points (Prometheus, OpenTelemetry and Redis)
- Declarative composition — policies combine into pipelines with explicit, documented ordering
- Native observability — typed events and a pluggable
MetricsCollectorin the core
Install
npm install breakwaterWorks with both module systems:
import { retry, timeout, circuitBreaker } from 'breakwater' // ESM
const { retry, timeout, circuitBreaker } = require('breakwater') // CJSQuick start
Protect a flaky HTTP call in three lines:
import { retry } from 'breakwater'
const policy = retry({ attempts: 3 })
const user = await policy.execute(() => fetchUser(id))Add a time budget and a circuit breaker, composed in an explicit order:
import { compose, retry, circuitBreaker, timeout } from 'breakwater'
const policy = compose(
retry({ attempts: 3 }), // outermost
circuitBreaker({ name: 'user-service' }),
timeout(2_000) // innermost, hugs the function
)
const user = await policy.execute(({ signal }) => fetchUser(id, { signal }))Or let resilience() pick the battle-tested default order for you:
import { resilience } from 'breakwater'
const policy = resilience({
retry: { attempts: 3 },
circuitBreaker: { name: 'user-service' },
timeout: 2_000,
fallback: cachedUser
})Core concepts
Every policy speaks the same contract
A policy — and the result of composing policies — is an object with:
policy.execute(fn, options?) // run fn under the policy's protection
policy.wrap(fn) // decorate: same signature in, protected function out
policy.invoke(fn, ctx) // composition primitive (used by compose())Two everyday shapes:
// 1. execute — fn receives the execution context (with the combined signal)
const data = await policy.execute(({ signal }) => fetch(url, { signal }))
// 2. wrap — decorate once, call everywhere
const safeFetchUser = policy.wrap(fetchUser)
const user = await safeFetchUser(id)
wrapkeeps the function signature untouched, so the wrapped function does not receive the context. Useexecutewhen you need the inner signal. Also notethisis not forwarded — bind methods first:policy.wrap(svc.method.bind(svc)).
The execution context
One context travels through the whole pipeline:
interface ExecutionContext {
signal: AbortSignal // external cancellation + timeouts, combined into ONE signal
attempt: number // 0 on the first execution; incremented by retry
correlationId: string // generated if not provided; present in every event payload
metadata: Record<string, unknown> // yours, crosses every policy
}Your function only ever needs to observe one AbortSignal — the policies
combine external cancellation, timeouts and retry cancellation into it:
await policy.execute(
({ signal }) => fetch(url, { signal }),
{ signal: request.signal, correlationId: request.id }
)Cancellation is not failure
Aborting via AbortSignal is treated as cancellation everywhere: retry does not
retry it, the circuit breaker does not count it, and fallback does not replace it.
The abort reason propagates to the caller untouched.
The policies
| Policy | One-liner | Docs |
|---|---|---|
| timeout(ms, options?) | Bound the time of each execution, cooperatively or aggressively | docs/timeout.md |
| retry(options?) | Retry transient failures with configurable backoff and a total deadline | docs/retry.md |
| circuitBreaker(options?) | Fail fast while a dependency is down; probe and recover automatically | docs/circuit-breaker.md |
| bulkhead(options?) | Bound concurrent executions, with an optional FIFO wait queue | docs/bulkhead.md |
| rateLimit(options) | Cap the execution rate — token bucket or exact sliding window | docs/rate-limit.md |
| fallback(handler, options?) | Replace a failure with a value, a function result, or a chain of them | docs/fallback.md |
| staleCache(options?) | Serve the last good response while the circuit is open | docs/stale-cache.md |
| compose(...policies) | Combine policies with explicit ordering; compositions compose again | docs/composition.md |
| resilience(options) | The batteries-included pipeline with a sane default order | docs/composition.md |
Composing policies
compose(a, b, c) runs exactly like the nested calls a(b(c(fn))) — the first
policy is the outermost. Order changes behavior: retry outside the circuit
breaker behaves very differently from retry inside it. This is the part most
libraries leave undocumented; we document it with diagrams in
docs/composition.md.
The default order used by resilience():
fallback( retry( rateLimit( bulkhead( circuitBreaker( timeout( fn ) ) ) ) ) )Every attempt flows through the breaker (feeding its stats individually), and once
the circuit opens, retry sees CircuitOpenError — which is not retryable — and
gives up immediately instead of hammering an open circuit.
Named policies
Define your resilience configuration once, at startup; ask for policies by name everywhere else — same name, same instance, genuinely shared state:
import { policies } from 'breakwater'
// config/resilience.ts
policies.define('payments-api', {
retry: { attempts: 3 },
circuitBreaker: { failureThreshold: 0.5 },
timeout: 2_000
})
// anywhere else
await policies.get('payments-api').execute(({ signal }) => api.post('/charge', body, { signal }))Typos fail fast (get throws listing the defined names), duplicates throw,
and the registry name flows into metrics automatically. See
docs/named-policies.md.
Observability
Every policy emits typed events — no plugin required:
const breaker = circuitBreaker({ name: 'payments-api' })
breaker
.on('stateChange', ({ from, to, stats }) => log.warn({ from, to, stats }, 'circuit state changed'))
.on('reject', ({ correlationId }) => log.debug({ correlationId }, 'request rejected fast'))
breaker.stats()
// { state, successes, failures, totalCalls, failureRate, latency, lastError, openedAt, nextAttemptAt }
// latency: { count, min, max, mean, p50, p95, p99 } over the same windowFor metrics pipelines, implement the MetricsCollector interface once and plug it
into resilience() — it wires every policy for you:
const policy = resilience({
retry: { attempts: 3 },
circuitBreaker: { name: 'payments-api' },
timeout: 2_000,
metrics: myCollector // onExecution, onRetry, onTimeout, onStateChange, onFallback, onReject
})Building with compose() instead? attachMetrics(pipeline, collector) wires
a whole composition in one call, and metricsPolicy(collector) measures the
pipeline as a regular outermost policy. Compositions also expose an
aggregated stats() of their inner policies.
Don't want to write a collector? Two ready-made adapters ship as optional
entry points. breakwater/prometheus emits prom-client metrics —
executions, durations, rejections and circuit states — plus a Grafana
dashboard to import:
import { prometheusCollector } from 'breakwater/prometheus' // prom-client is a peer dependency
const policy = resilience({ name: 'payments-api', metrics: prometheusCollector() })breakwater/otel emits the same signals as OpenTelemetry metrics, and
adds spanPolicy() — a composable policy that wraps each execution in an
active span, so instrumented HTTP clients and database drivers nest under it
in the trace:
import { otelCollector, spanPolicy } from 'breakwater/otel' // @opentelemetry/api is a peer dependency
const policy = resilience({ name: 'payments-api', metrics: otelCollector() })See docs/prometheus.md and docs/otel.md.
Errors
Every error breakwater throws extends BreakwaterError and carries a stable
code — branch on the code or the type guards, never on messages:
| Error | code | Thrown by |
|---|---|---|
| TimeoutError | TIMEOUT | timeout |
| RetryExhaustedError | RETRY_EXHAUSTED | retry (last error in cause) |
| CircuitOpenError | CIRCUIT_OPEN | circuit breaker (carries stats) |
| IsolatedError | CIRCUIT_ISOLATED | circuit breaker (manual isolation) |
| BulkheadRejectedError | BULKHEAD_REJECTED | bulkhead (carries stats; stays retryable) |
| RateLimitedError | RATE_LIMITED | rate limit (carries stats and retryAfterMs; stays retryable) |
| FallbackFailedError | FALLBACK_FAILED | fallback (operation error in originalError) |
import { isCircuitOpenError, isTimeoutError } from 'breakwater'
try {
await policy.execute(chargeCard)
} catch (error) {
if (isCircuitOpenError(error)) return res.status(503).json({ retryAfter: error.stats.nextAttemptAt })
if (isTimeoutError(error)) return res.status(504).end()
throw error
}See docs/errors.md.
Documentation
- Timeout
- Retry & backoff
- Circuit breaker
- Bulkhead
- Rate limit
- Fallback
- Stale cache (stale-while-open)
- Composition & ordering — read this one; ordering is where resilience goes right or wrong
- Named policies
- Observability: events, stats & metrics
- Prometheus adapter — ready-made prom-client collectors + a Grafana dashboard
- OpenTelemetry adapter — OTel metrics + spans as a composable policy
- Redis: distributed state — one circuit, and one rate limit quota, shared across every instance
- Errors
- Versioning policy — what the semver promise covers, and what it deliberately does not
Requirements
- Node.js >= 22
