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) #
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) #
{
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) #
{
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) #
{
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.