Retry

Retry plus circuit-breaker primitives — exponential backoff with jitter, half-open probe, and built-in classification of OS network error codes plus retryable HTTP status codes.

b.retry.withRetry(fn, opts) wraps a single call: exponential backoff (baseDelayMs * 2^(attempt-1), capped at maxDelayMs) with cryptographic jitter so a thundering-herd of retrying clients does not realign on the same boundary. The default classifier targets HTTP 408 / 425 / 429 / 5xx and the Node net-layer codes (ECONNRESET, ECONNREFUSED, ECONNABORTED, ETIMEDOUT, EPIPE, EAGAIN, ENOTFOUND, ENETUNREACH); callers with non-network semantics override via opts.isRetryable. The retry loop honors opts.signal (AbortSignal) so a caller who aborts mid-retry is unblocked immediately rather than waiting out the backoff.

b.retry.CircuitBreaker is the per-target sibling: N consecutive failures opens the circuit, opening fast-fails subsequent calls for cooldownMs, then a half-open state lets one probe call through. M consecutive probe successes close the circuit; one probe failure reopens it. Permanent errors (err.permanent) do not trip the breaker — those are caller bugs, not backend health issues. Both primitives are side-effect-free on success and compose with b.safeAsync.withTimeout and any caller-side instrumentation. HTTP-client auto-retry is intentionally not provided here so timeout, idempotency, and body-replay decisions stay explicit at the call site.

b.retry.isRetryable(err) #

0.5.0

Default classifier — returns true when an error looks transient and worth retrying, false otherwise. Honors err.permanent and err.isObjectStoreError && err.permanent; recognizes retryable HTTP status codes (408 / 425 / 429 / 5xx) and Node net-layer codes (ECONNRESET, ECONNREFUSED, ECONNABORTED, ETIMEDOUT, EPIPE, EAGAIN, ENOTFOUND, ENETUNREACH). Defensive read — missing fields return false rather than throwing, so a malformed error never crashes the retry loop.

var transient = new Error("timeout");
transient.code = "ETIMEDOUT";
b.retry.isRetryable(transient);   // → true

var fatal = new Error("bad request");
fatal.statusCode = 400;
b.retry.isRetryable(fatal);       // → false

b.retry.backoffDelay(attempt, opts) #

0.5.0
{
  baseDelayMs:  number,   // initial backoff (default 100)
  maxDelayMs:   number,   // cap between attempts (default 10s)
  jitterFactor: number,   // 0..1; 0 = no jitter, 1 = full jitter (default 0.5)
}

Compute the backoff in milliseconds for a given (1-based) attempt number. Exponential growth baseDelayMs * 2^(attempt-1) capped at maxDelayMs, then subtract a Math.random-sourced jitter sample scaled by jitterFactor so retrying clients spread across the millisecond window instead of realigning on the same boundary (thundering-herd avoidance). Throws TypeError when attempt is not a positive integer. opts defaults to b.retry.DEFAULT_RETRY when absent.

Jitter is intentionally NOT a CSPRNG sample — the per-request delay is observable to every peer client by construction (the request that comes in carries its own arrival timing), so there is no confidentiality property a stronger random source would protect. Math.random is the right tool for thundering-herd avoidance and costs ~50x less than a CSPRNG randomInt() under a retry storm.

var d1 = b.retry.backoffDelay(1, { baseDelayMs: 100, maxDelayMs: 1000, jitterFactor: 0 });
d1;                       // → 100
var d3 = b.retry.backoffDelay(3, { baseDelayMs: 100, maxDelayMs: 1000, jitterFactor: 0 });
d3;                       // → 400

b.retry.withRetry(fn, opts) #

0.5.0
{
  maxAttempts:  number,    // total tries incl. first (default 5)
  baseDelayMs:  number,    // initial backoff (default 100)
  maxDelayMs:   number,    // cap between attempts (default 10s)
  jitterFactor: number,    // 0..1 (default 0.5)
  isRetryable:  function,  // override classifier (default b.retry.isRetryable)
  onRetry:      function,  // ({ attempt, delay, error }) -> void
  signal:       object,    // AbortSignal — cancels the backoff sleep
}

Run fn(attempt) with exponential backoff plus jitter. Retries up to maxAttempts while the classifier reports the failure transient; on a non-retryable error or after the final attempt the underlying error rethrows. Honors opts.signal so an AbortSignal cancels the backoff sleep. The opts.onRetry hook is invoked between attempts with { attempt, delay, error }; throws inside the hook are captured and surfaced as retry.onRetry.threw observability events — the retry loop itself never crashes.

var attempts = 0;
var result = await b.retry.withRetry(async function () {
  attempts += 1;
  if (attempts < 2) {
    var err = new Error("nope");
    err.code = "ECONNRESET";
    throw err;
  }
  return "ok";
}, { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, jitterFactor: 0 });
result;                  // → "ok"

b.retry.withBreaker(fn, opts) #

stable0.9.13
{
  retry:    Object,                // forwarded to withRetry — same options
  breaker:  CircuitBreaker,        // existing breaker instance (required)
}

Compose withRetry + a CircuitBreaker so one retry-loop invocation counts as exactly one breaker call. The breaker observes the eventual outcome of the retry loop (success or final failure), not each intermediate retry attempt — otherwise every retried call inflates the breaker's failure counter and the breaker opens far sooner than intended.

Every downstream consumer that wants this composition writes breaker.wrap(() => b.retry.withRetry(fn, retryOpts)) by hand; this primitive captures the pattern so the convention is uniform.

var cb = b.circuitBreaker.create({ name: "upstream-billing", failureThreshold: 5 });
var result = await b.retry.withBreaker(async function () {
  return await fetch("https://billing.example.com/v1/charges");
}, {
  retry:   { maxAttempts: 3, baseDelayMs: 500 },
  breaker: cb,
});

Last updated 2026-08-08T16:39:49.652Z by seeder.