Vereda
    Preparing search index...

    Vereda

    Vereda

    Vereda

    Make fetch resilient.

    Retries, backoff, timeouts, per-host isolation, and circuit breaking for fetch on Node.js, Bun and Cloudflare Workers — with typed results instead of thrown errors.

    CI License Node 22+ Runs on Bun and Cloudflare Workers ESM only TypeScript Zero runtime dependencies

    import { HttpClient } from "@vereda/http";

    const api = HttpClient.create({
    baseUrl: "https://api.example.com",
    timeout: { attemptMs: 5_000 },
    });

    const result = await api.get("/users/42").toPromise(); // never rejects

    if (result.success) {
    const user = await result.raw.json();
    } else {
    console.error(result.error.kind, result.error.message); // a typed error
    }

    A dropped connection, a timeout, or a 503 on that request is retried up to three times with jittered exponential backoff before your code sees an error. Reading result.raw afterwards is bounded too: the body read has to finish within the same attemptMs (counted from when the attempt started) and totalMs limits, or the read rejects with Vereda's own TimeoutError (attempt bound) or DeadlineExceededError (totalMs bound).

    fetch makes one attempt. Everything after that is yours to write:

    async function getUser(id: string) {
    for (let attempt = 0; ; attempt++) {
    try {
    const res = await fetch(`https://api.example.com/users/${id}`, {
    signal: AbortSignal.timeout(5_000),
    });
    if (res.ok) return await res.json();
    throw new Error(`HTTP ${res.status}`); // 404 or 503? retry both?
    } catch (err) {
    // timeout, DNS failure, or the throw above? caught all the same
    // is this request safe to repeat? what if it were a POST?
    if (attempt === 3) throw err;
    }
    await new Promise((r) => setTimeout(r, 200 * 2 ** attempt)); // jitter? Retry-After?
    }
    }

    Even once that loop is correct, it has no limit on how many retries pile onto a struggling host, no way to cancel, and callers that can only tell a 404 from a timeout by parsing an error message. Vereda is that loop written carefully, once:

    When… Vereda…
    A request fails transiently (connection reset, timeout, 408, 425, 429, 500, 502–504) retries it with exponential backoff and full jitter, honoring Retry-After
    A retry could duplicate a side effect retries only idempotent methods unless you opt in or send an Idempotency-Key
    A request hangs aborts each attempt at timeout.attemptMs; an optional timeout.totalMs caps the whole request
    One failing host would soak up your retries caps retry concurrency and queue size per host, so one host's retries can't fill another's queue (all hosts still share the global concurrency cap)
    A host has a requests-per-second quota an opt-in token bucket per host spaces attempts out, retries included
    A host is down, not just slow an opt-in circuit breaker fails fast with CircuitOpenError until it recovers
    The response isn't the shape you expected validates it with your parse function (or Zod); a failed parse is never retried
    The caller no longer needs the answer cancels via the ticket or your AbortSignal; a cancelled request is never retried
    You need to know what happened emits typed lifecycle events (with attempt counts and queue time) and metrics (requests, retries, latency, in-flight, queue depth, breaker trips), tagged per partition
    You need auth headers, logging, URL rewriting runs onion middleware around every attempt

    Most HTTP clients retry. Fewer protect the host you're calling, or the rest of your service, once retrying stops helping:

    Vereda ky axios got ofetch cockatiel opossum
    Built on fetch ✅ ✅ ⚠️ ❌ ✅ — —
    Jittered backoff ✅ ✅ ⚠️ ⚠️ ❌ ✅ ❌
    Retry-After ✅ ✅ ✅ ✅ ❌ ⚠️ —
    Attempt and total timeout ✅ ✅ ⚠️ ⚠️ ⚠️ ✅ ⚠️
    Circuit breaker ✅ ❌ ❌ ❌ ❌ ✅ ✅
    Per-host concurrency ✅ ❌ ❌ ❌ ❌ ⚠️ ⚠️
    Retry budget ✅ ❌ ❌ ❌ ❌ ❌ —
    Rate limit ✅ ❌ ❌ ❌ ❌ ❌ ❌
    Errors as values ✅ ❌ ❌ ❌ ❌ — —
    Dependencies 0 0 5 10 3 0 0

    ✅ built in · ⚠️ partial (see below) · ❌ no · — not applicable

    • axios (with axios-retry): fetch is one adapter among several; exponential delay with no jitter; timeout bounds either the whole request or, with shouldResetTimeout, each attempt, not both. 4 dependencies plus 1 for axios-retry.
    • got: a fixed ±100 ms of noise instead of jitter; one overall timeout.request.
    • ofetch: a per-attempt timeout, but no deadline across retries.
    • cockatiel: Retry-After needs a custom backoff; per-host limits need one bulkhead per host.
    • opossum: a per-call timeout and no retries; its capacity limit applies per breaker, so per host means one breaker per host.

    ky, axios, got and ofetch are HTTP clients; cockatiel and opossum are resilience policies you wrap around any async function, fetch included. If retries are all you need, ky is an excellent choice. Vereda is for services where one slow dependency must not take the others down with it. Checked against ky 2.2.0, axios 1.20.0 with axios-retry 4.5.0, got 16.1.0, ofetch 1.5.1, cockatiel 4.0.0 and opossum 10.0.0 in October 2026, from each project's docs and source; if a row is out of date, please open an issue.

    What Vereda does not do. No response caching, no request deduplication, no streaming helpers, no browser support. It targets services that depend on other services, on Node.js 22+, Bun or Cloudflare Workers (no nodejs_compat needed); for a handful of calls in a script, plain fetch is fine.

    npm install @vereda/http
    
    import { HttpClient, json } from "@vereda/http";

    type User = { id: number; name: string };

    const api = HttpClient.create({
    baseUrl: "https://api.example.com",
    timeout: { attemptMs: 5_000 },
    });

    const result = await api.get("/users/42", { parse: json<User>() }).toPromise();

    if (result.success) {
    result.data.name; // typed: string
    } else {
    switch (result.error.kind) {
    case "http": // non-retryable status, e.g. 404
    console.warn(result.error.statusCode);
    break;
    case "max_retries": // transient failures outlasted every retry
    console.error(result.error.lastError);
    break;
    default:
    console.error(result.error.message);
    }
    }

    toPromise() never rejects: every outcome is a Result, and every failure is one of Vereda's own error classes, discriminated by kind (Error handling). json<T>() casts without checking; pass a real validator, or use the Zod adapter, when you need the shape enforced.

    timeout.attemptMs is the one required setting. Most HTTP clients have no overall request timeout by default, which is how one hung dependency takes a service down. Vereda makes you choose a number, or pass Infinity to opt out on purpose. Everything else has a default:

    Setting Default
    Retries 3 retries after the first attempt (4 total executions)
    Backoff Exponential: 200ms base, 30s cap, full jitter
    Retry-on status codes [408, 425, 429, 500, 502, 503, 504]
    Per-partition concurrency 5 retries in flight per host
    Per-partition queue size 100 waiting retries per host
    Global concurrency 50 in-flight executions across all partitions
    Global queue size 100 waiting executions; beyond that a request resolves with QueueFullError (scope: "global")
    First attempts Skip the per-partition bulkhead (unless partition.limitFirstAttempts is set), but still take a global permit
    Total deadline None — set timeout.totalMs to cap the whole request
    Circuit breaker Disabled — opt in with circuitBreaker: { enabled: true }
    Retry budget None — retries are limited only by maxRetries until you set retryBudget
    Rate limit None — opt in with rateLimit: { tokensPerSec }

    A checkout service calls three hosts. The payment provider starts returning 503.

    import { HttpClient } from "@vereda/http";

    const client = HttpClient.create({
    timeout: { attemptMs: 3_000, totalMs: 15_000 },
    partitions: {
    "payments.example.com": {
    concurrency: 2,
    maxQueueSize: 20,
    circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeoutMs: 30_000 },
    },
    },
    });

    // POST isn't retried by default; the idempotency key tells Vereda a repeat is safe.
    const charge = client.post("https://payments.example.com/charges", JSON.stringify(order), {
    headers: { "Content-Type": "application/json", "Idempotency-Key": order.id },
    });
    checkout service
    │
    ├──► payments.example.com 503, 503, 503 …
    │ retries: at most 2 in flight, 20 waiting, the rest fail fast with QueueFullError
    │ after 5 straight failures: circuit opens, calls fail instantly with CircuitOpenError
    │ after 30s: one trial request; success closes the circuit
    │
    ├──► inventory.example.com own partition: its retries aren't queued behind payments'
    └──► shipping.example.com own partition: same

    Every request is assigned to a partition by host, and each partition has its own retry queue and its own breaker. Payments' retries are capped at 2 in flight, and once its breaker opens, payment calls stop reaching the network, apart from the half-open trial and any retry that had already passed the breaker check before its backoff. The totalMs deadline means no single call waits longer than 15 seconds, retries included.

    One limit is shared: every attempt, first attempts included, takes a permit from the client-wide concurrency cap (default 50, with 100 waiting). A host that fails slowly holds those permits while it hangs, so under enough load it can delay or reject requests to healthy hosts. Keep attemptMs short for dependencies that tend to hang, and set limitFirstAttempts: true on a partition to put its fresh traffic behind its own bulkhead too.

    examples/checkout/ is this scenario as a runnable app: stub upstreams on localhost, a small checkout server, and a driver that asserts the retries and the breaker tripping while inventory and shipping keep succeeding (sequential traffic and smaller numbers, so it runs fast; it doesn't exercise the queue limits). Clone the repo and run npm run example:checkout.

    It ends by printing what a dashboard fed from Vereda's metrics would show for that run. Every metric is tagged with its partition, so the failing dependency is easy to pick out:

    dependency  requests  retries  p50 ms  max ms  circuit_open
    inventory 5 0 3 8 0
    payments 5 2 1 21 1
    shipping 5 0 3 7 0

    Note payments' low median latency, despite the outage: once its breaker opened, its calls were rejected on the spot instead of waiting on a failing host.

    client.get(url)
    │
    ▼
    breaker check ─> global permit ─> first attempt
    │
    ┌────────────────────────────────►┤ outcome of each attempt
    │ ├─ success ─────────────────> done
    │ ├─ non-retryable or vetoed ─> resolves with that error
    │ ├─ transient, none left ────> MaxRetriesExceededError
    │ └─ transient, retries left
    │ │
    │ breaker check ─> backoff ─> partition bulkhead ─> global permit ─> retry
    │ (per host) │
    └───────────────────────────────────────────────────────────────────────┘

    The first attempt skips the partition bulkhead. Only requests that need another attempt go through their partition's queue, so a host's retry backlog waits in its own partition queue, not ahead of other hosts' retries. Once admitted, a retry still needs a permit from the global concurrency cap, which it waits for alongside fresh requests. When the circuit breaker is enabled, it is checked before the first attempt and again before every retry.

    Retries and backoff
    Jittered exponential retries for transient failures
    Timeouts
    Per-attempt timeout plus an optional total deadline
    Bulkhead isolation
    A concurrency limit and queue per host
    Circuit breaker
    Stop calling a host that is clearly failing
    Cancellation
    Cancel from the ticket or an AbortSignal
    Tickets
    Await, subscribe to, or cancel a request
    Typed results
    Validate the body with any parse function
    Error handling
    A typed error hierarchy with a literal kind
    Middleware
    Onion-style hooks around every attempt
    Custom fetch
    Swap globalThis.fetch for your own
    Lifecycle events
    Typed client-wide events for logging
    Metrics
    Counters, histograms and gauges to any sink
    OpenTelemetry
    Spans per ticket and attempt, plus an OTel metrics sink
    Retry budget
    Cap a host's retries to a fraction of its traffic
    Rate limiting
    A token bucket per host for req/s quotas

    Each card links into one of four guides: Resilience · Results and errors · Working with requests · Observability.

    Fresh traffic comes first. The first attempt skips the partition bulkhead, which exists to throttle retry pressure onto struggling hosts — that's where thundering herds come from.

    Backpressure beats unbounded queues. When a queue is full, fail explicitly rather than consuming infinite memory.

    Cancellation is final. A cancelled request never enters the retry loop, regardless of timeout or retry configuration.

    Validation failures aren't transient. A response that fails your parse function resolves immediately — retrying would parse the same payload again.

    No silent infinite waits. The per-attempt timeout is the one setting without a default, because a missing timeout is the failure you only find in production.

    • Feature guides
      • Resilience — retries and backoff, retry budget, timeouts, bulkhead isolation, rate limiting, circuit breaker.
      • Results and errors — typed results, the Zod adapter, error handling.
      • Working with requests — cancellation, tickets, middleware, custom fetch.
      • Observability — lifecycle events, metrics, OpenTelemetry.
    • Operations guide — sizing concurrency and queues, attemptMs vs. totalMs, reading partitions() and circuits(), wiring a metrics sink, the shutdown sequence, and log redaction.
    • The Vereda Book — how the library works inside: the request lifecycle, the invariants, and the bugs that shaped them. For contributors.
    • API reference — generated from source via TypeDoc on every push to main; every public option documents its default.

    Vereda follows Semantic Versioning from 1.0.0 onward: breaking changes land only in a major version, and anything scheduled for removal is deprecated in a minor release first and noted in CHANGELOG.md before it goes. The error hierarchy is open to additions: a minor release may add an error class with a new kind (always listed in the CHANGELOG), so give a switch over error.kind a default branch rather than a never check. Existing classes, kinds and fields don't change outside a major. The public surface is exactly what src/core/index.ts, src/middleware/index.ts, src/adapters/zod.ts, and src/otel/index.ts export — anything under src/queue/ and src/ticket/ that those entry points don't re-export is internal, even though it's readable source.

    Node support: the currently supported line is whatever engines.node in package.json declares (>=22 today); CI runs the full suite against Node 22 and 24 on every change, so those two are the versions actually verified. The floor moves only in a major release.

    Other runtimes: the library imports no Node builtins and uses only web-standard APIs (fetch, AbortController, crypto.getRandomValues, timers). Each row below says how that's checked:

    Runtime Status How it's verified
    Node 22, 24 Supported Full test suite in CI
    Bun Supported Full test suite in CI
    Cloudflare Workers Supported, no nodejs_compat needed CI smoke test loads the built package into workerd and drives every entry point (scripts/smoke/workers.mjs)
    Deno Expected to work Manual smoke test (deno run scripts/smoke/deno.mjs); not in CI
    Browsers Expected to work Not tested

    New to Vereda? Three on-ramps:

    • Self-guided — read ONBOARDING.md, a tour that follows one request through the library.
    • In depth — read The Vereda Book: thirteen chapters on how the client works inside and why, following one request through every subsystem, with exercises against the real code.
    • Interactive — run the guide-me skill in your coding harness (Claude Code, OpenCode, etc.). It's bundled in the repo and walks you through the internals interactively.

    When you're ready, read CONTRIBUTING.md for setup, commands, and the behavioral invariants your change must preserve. Tests are self-contained: no network, services, or env vars needed.

    Vereda is Brazilian Portuguese for a narrow trail: a resilient route through terrain. That maps directly to what the library does: give your requests a reliable path through flaky networks, retries, and backpressure. veh-REH-da.

    MIT