Skip to main content
zerotal

HTTP Client

Your application calling somebody else's: charging a card, sending a message, looking up an address. @zerotal/client is the outbound HTTP client — a thin, expressive wrapper over fetch that adds what every real integration ends up needing anyway and that is tedious to get right by hand.

import { Client } from "@zerotal/client";

const charge = await Client.post("https://api.stripe.com/v1/charges", {
  amount: 2000,
  currency: "usd",
  source: token,
});

Timeouts, retries with backoff, bearer tokens, a circuit breaker for the upstream that has started failing, and errors you can branch on.

For requests coming into your app, see Routing. This page is only about requests going out.

Install and register

bun add @zerotal/client

Add ClientProvider to bootstrap/providers.ts to get one shared, configured client from the container:

// bootstrap/providers.ts
import { ClientProvider } from "@zerotal/client";

const providers = [
  // …your other providers
  ClientProvider,
];

export default providers;

It binds a client under the client key in the web, console, worker, test and repl environments — so a queued job calling a gateway uses the same client a controller does — and exposes it as the Client facade.

Making requests

Five methods, each taking a URL. Pass an absolute URL, or set a baseUrl in config and pass a path:

import { Client } from "@zerotal/client";

await Client.get("https://api.example.com/v1/invoices");
await Client.post("https://api.example.com/v1/invoices", { customer: "cus_123" });
await Client.put("https://api.example.com/v1/invoices/in_9", { memo: "Paid" });
await Client.patch("https://api.example.com/v1/invoices/in_9", { memo: "Paid" });
await Client.delete("https://api.example.com/v1/invoices/in_9");

The response body is decoded for you — JSON when the response is JSON, text otherwise. A 2xx returns; anything else throws, which Errors covers.

Query parameters

await Client.get("https://api.example.com/v1/charges", undefined, {
  query: { limit: 25, status: "succeeded", created: { gte: 1_700_000_000 } },
});

Nested objects and arrays are serialized with bracket notation, so { created: { gte: 1 } } becomes created[gte]=1.

Request bodies

A plain object or array is JSON-encoded with the matching Content-Type. Anything fetch understands natively — FormData, URLSearchParams, Blob, ArrayBuffer, a raw string — passes straight through, so the runtime sets the header itself (including multipart boundaries):

// Form-encoded, which several gateways still require
await Client.post(
  "https://api.example.com/v1/charges",
  new URLSearchParams({ amount: "2000", currency: "usd" }),
);

Headers

Per request, merged over the client's defaults:

await Client.post("https://api.example.com/v1/messages", payload, {
  headers: { "Idempotency-Key": crypto.randomUUID() },
});

Authentication

Most APIs want a bearer token. Set it once and every request carries Authorization: Bearer …:

// config/client.ts
import { ClientConfig } from "@zerotal/client";
import { env } from "zerotal";

export default ClientConfig({
  baseUrl: "https://api.example.com",
  token: env("PAYMENTS_API_KEY", ""),
  headers: { Accept: "application/json" },
});

A token may also be a function, including an async one, which is how credentials that expire are handled — it is resolved per request:

export default ClientConfig({
  token: async () => await currentAccessToken(),
});

Change it at runtime with setToken(token), or pass null to clear it. Its type is TokenSource.

For an API that wants something other than a bearer token, set the header directly:

export default ClientConfig({
  headers: { "X-Api-Key": env("PARTNER_API_KEY", "") },
});

Refreshing on a 401

onUnauthorized receives the error and a retry function, so a token can be refreshed and the original request replayed once:

export default ClientConfig({
  onUnauthorized: async (error, retry) => retry({ Authorization: `Bearer ${await refresh()}` }),
});

Timeouts

There is no timeout by default, because the right one depends on the upstream. Set a default and override per request:

export default ClientConfig({ timeout: 10_000 });

// This one is slow and we accept that
await Client.post("https://api.example.com/v1/reports", body, { timeout: 60_000 });

timeout: 0 disables it. Pass your own signal to cancel for any other reason; it is combined with the timeout rather than replacing it.

Retries

A network blip or a 503 is worth trying again; a 422 never is. retry retries idempotent requests on network errors, 5xx and 429 with exponential backoff, and honours a Retry-After header when the server sends one:

export default ClientConfig({ retry: 2 });

await Client.post("https://api.example.com/v1/charges", body, { retry: false });

Turning retries off for a charge is the deliberate part: retrying a payment you cannot prove failed is how a customer gets billed twice. Send an idempotency key if you want both. Pass RetryOptions instead of a number to tune the delays and which statuses qualify.

Errors

A non-2xx throws ApiClientError, carrying what you need to decide what happened:

import { ApiClientError } from "@zerotal/client";

try {
  await Client.post("https://api.example.com/v1/charges", body);
} catch (error) {
  if (error instanceof ApiClientError) {
    error.status; // 402
    error.body; // raw response text
    error.headers; // Headers, when available
    error.retryAfterMs; // parsed Retry-After, or null
  }
  throw error;
}

A 422 carrying a field-error body throws ValidationError, a subclass that reads those fields for you — has(field), first(field), all(), fields(), and validationMessage for the top-level message.

onError fires for every non-2xx before the throw and onForbidden for a 403 — both good places to log or alert without wrapping each call site.

Circuit breaker

When an upstream is down, continuing to call it wastes your own capacity and slows everything queued behind it. A CircuitBreaker stops after a threshold of consecutive failures, fails fast for a cooldown, then lets a single request through to test the water:

export default ClientConfig({
  circuitBreaker: { threshold: 5, cooldownMs: 30_000 },
});

While it is open, calls throw CircuitBreakerOpenError without a request being made — catch that specifically to fall back to a cached answer or a queued retry. state reports the CircuitState (closed, open, half-open), failures the current count, and reset() closes it by hand.

Pass a shared CircuitBreaker instance instead of options when several clients talk to the same upstream and should trip together.

Files

Upload with FormData; download by asking for the body you want:

const form = new FormData();
form.append("file", Bun.file("./invoice.pdf"));
await Client.post("https://api.example.com/v1/documents", form);

const pdf = await Client.get("https://api.example.com/v1/documents/doc_1", undefined, {
  responseType: "blob",
});

responseType takes auto (the default), json, text, blob or arrayBuffer.

Interceptors

onRequest runs before every request and can rewrite the outgoing RequestConfig; onResponse runs after every 2xx with the ResponseContext. Both take one function or an array:

export default ClientConfig({
  onRequest: (config) => {
    config.headers["X-Request-Id"] = crypto.randomUUID();
    return config;
  },
});

To read the status or headers of a single response without a global interceptor, pass meta in the request options.

Testing

The client calls fetch, so a test replaces fetch:

import { test, expect } from "bun:test";

test("charges the card", async () => {
  globalThis.fetch = async () => Response.json({ id: "ch_1", paid: true });

  const charge = await Client.post("https://api.example.com/v1/charges", { amount: 2000 });
  expect(charge.paid).toBe(true);
});

Return a non-2xx Response to cover the failure paths, and throw from the stub to exercise retries and the breaker. For requests your app receives in tests, see Testing.

Configuration reference

Every field of ClientConfigShape (which extends ApiClientConfig) is optional:

FieldDescription
baseUrlPrepended to every request path. Omit it and pass absolute URLs.
headersDefault headers for every request.
tokenBearer token — a string, or a (possibly async) resolver called per request.
timeoutDefault per-request timeout in ms. 0 disables.
retryDefault retry policy — a count, or RetryOptions.
circuitBreakerA CircuitBreaker instance to share, or options for a dedicated one.
withCredentialsSend cookies (credentials: 'include'), and turn CSRF on.
csrfCSRF cookie/header names, or a boolean.
onRequestInterceptor(s) run before every request.
onResponseInterceptor(s) run after every 2xx.
onErrorCalled for every non-2xx before the error is thrown.
onUnauthorizedCalled on 401, with a retry function.
onForbiddenCalled on 403.

Per-request options — RequestOptions, plus query on GetOptions and params/query on MutationOptions — override the client's defaults: headers, timeout, retry, responseType, signal, init and meta.

Calling your own API

Everything above treats a URL as a string, which is the right trade for a service you hit a handful of endpoints on. Nobody should describe someone else's API in types to send three requests to it.

If you are calling your own API repeatedly, createApiClient<Routes>(config) binds a route map, so paths, params, query and response types are inferred and a renamed endpoint becomes a build error. It returns the same ApiClient with a narrower type; the Client facade above is bound to the base map and answers unknown. The route-map types are listed in the API reference.

Realtime

This package also ships Socket, Channel and PresenceChannel — a WebSocket client speaking Zerotal's broadcast protocol. Different job, documented where it is used: Broadcasting → Client.

Next steps

  • Rate Limiting — throttle what you send and what you accept.
  • Telemetry — trace outbound calls and watch circuit state.
  • Queues — where a slow or flaky integration usually belongs.