Skip to content

HTTP Client API ​

Package: @onebun/requests

Overview ​

OneBun provides a unified HTTP client with:

  • Multiple authentication schemes
  • Automatic retries with configurable strategies
  • Redirects followed without taking credentials to another origin — or refused, or handed back
  • An optional cap on the decoded size of a response body
  • Binary bodies as bytes, and bodies streamed as they arrive
  • Integrated tracing and metrics
  • Standardized error handling

Creating HTTP Client ​

typescript
import { createHttpClient } from '@onebun/core';

const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  timeout: 10000,  // 10 seconds
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
});

Basic Requests ​

GET ​

typescript
// Simple GET
const response = await client.get('/users');

// With query parameters — the second argument *is* the query record
const response = await client.get('/users', { page: 1, limit: 10 });
// GET /users?page=1&limit=10

// With custom headers — an object carrying `method`, `headers`, `timeout`, `auth`, `tracing`,
// `metrics`, `maxResponseBytes`, `responseType` or `connectAddress` is read as per-request config
// instead of as query
const response = await client.get('/users', {
  headers: { 'X-Custom-Header': 'value' },
});

// Both at once: query second, config third
const response = await client.get('/users', { page: 1, limit: 10 }, { timeout: 5000 });

// Config with no query — a third argument makes the second one the query, even `undefined`
const response = await client.get('/users', undefined, { retries: { max: 0 } });

delete, head and options take the same three arguments and resolve them by the same rule. With two arguments, only the nine names above make the object config: every other key is query data, so client.get('/login', { redirect: '/home' }) sends GET /login?redirect=%2Fhome. retries, query and redirect are deliberately not on the list, so a config that sets only those takes the three-argument form, as in the last call above.

A record someone else wrote goes second of three

One of the nine names makes the whole record config, every other key included, and url, method and connectAddress are config keys. So never pass a record whose keys you did not choose — one built from an incoming request's query string, say — as the second of two arguments. { q: 'shoes', tracing: 'x', url: '/admin' } would call another path (another host, on a client without baseUrl), and { q: 'shoes', connectAddress: '10.0.0.5' } would pick the address the request connects to; q is dropped either way. client.get('/search', query, {}) keeps the record query data whatever it holds. A third argument that is undefined does not count: it is the two-argument form.

Do not wrap the query in a key

client.get('/users', { params: { page: 1 } }) — and { query: { page: 1 } } just the same — hands the wrapper object itself to the query builder, producing GET /users?params=%5Bobject+Object%5D. Pass the query record directly. The same applies to delete, head and options, which share the GET argument shape.

POST ​

typescript
// JSON body — the second argument *is* the payload
const response = await client.post('/users', { name: 'John', email: 'john@example.com' });

// With options — per-request config is the third argument
const response = await client.post('/users', userData, {
  headers: { 'X-Request-ID': requestId },
  timeout: 30000,
});

post, put and patch take the payload positionally and never inspect it. Wrapping it — client.post('/users', { body: userData, timeout: 30000 }) — sends {"body":{...},"timeout":30000} as the request body and applies no timeout.

PUT, PATCH, DELETE ​

typescript
// PUT
const response = await client.put('/users/123', { name: 'Updated Name' });

// PATCH
const response = await client.patch('/users/123', { name: 'Partial Update' });

// DELETE
const response = await client.delete('/users/123');

Authentication ​

Bearer Token ​

typescript
const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  auth: {
    type: 'bearer',
    token: 'your-jwt-token',
  },
});

API Key ​

typescript
const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  auth: {
    type: 'apikey',      // all lowercase — 'apiKey' is not a member of the union
    key: 'X-API-Key',    // the HEADER NAME
    value: 'your-api-key', // the secret
    location: 'header',  // 'header' (default) or 'query'
  },
});

Get the spelling right or the request goes out unauthenticated

key is the header name and value is the secret — the reverse of what the names suggest at a glance. And the discriminant is lowercase 'apikey': 'apiKey' matches no member of AuthConfig, so applyAuth falls through its default: branch and sends the request with no auth header at all. No error, no warning — the call simply arrives unauthenticated and comes back 401.

Basic Auth ​

typescript
const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  auth: {
    type: 'basic',
    username: 'user',
    password: 'pass',
  },
});

OneBun HMAC (Inter-service) ​

Signs each outgoing request so the callee can tell which service sent it.

typescript
const client = createHttpClient({
  baseUrl: 'https://billing.internal',
  auth: {
    type: 'onebun',
    serviceId: 'orders-service',
    secretKey: 'shared-secret',
    audience: 'billing-service',
  },
});

The client attaches one header:

X-OneBun-Signature: v=1;svc=orders-service;kid=default;alg=hmac-sha256;aud=billing-service;ts=…;nonce=…;sig=…

What the signature covers ​

CoveredNot covered
MethodScheme, host and port — bind audience instead
PathEvery header except Content-Type
Query string, exactly as sentThe response
Content-TypeRequest ordering
Every byte of the bodyConfidentiality
The whole parameter line: svc, kid, alg, aud, ts, nonce

Because only Content-Type is covered, a verified request tells you who called, and nothing else. Derive authorization from the verified serviceId alone — not from a second Authorization header, not from X-Forwarded-For, not from anything else that arrived unsigned alongside it.

This scheme authenticates the caller; it does not encrypt. Run it over a transport that provides confidentiality and response integrity — mTLS, or a service mesh — because an attacker who cannot forge a single request byte can still rewrite every reply.

Verifying on the callee ​

typescript
import {
  makeSingleReplicaNonceStore,
  oneBunAuthFailureStatus,
  verifyOneBunRequest,
} from '@onebun/requests';

const nonceStore = makeSingleReplicaNonceStore();

const result = await Effect.runPromise(verifyOneBunRequest(
  { method: req.method, url: req.url, headers: req.headers, body: await req.text() },
  { secret: 'shared-secret', audience: 'billing-service', nonceStore },
));

if (!result.valid) {
  // `result.reason` is for YOUR logs. Never put it in the response body: `unknown-key` versus
  // `signature-mismatch` tells an attacker which service ids and key ids exist.
  logger.warn('inter-service auth failed', { reason: result.reason, serviceId: result.serviceId });

  return new Response('Unauthorized', { status: oneBunAuthFailureStatus(result.reason!) });
}

audience and nonceStore are required, and false is a legal value for each. Both defend against attacks a default would silently leave open, so going without has to be written down in your code rather than inherited.

The body is yours to read, deliberately

verifyOneBunRequest takes body bytes you have already read. It cannot check the MAC without hashing the body, so whoever reads it is choosing to hash unauthenticated input — and that decision, with its size cap, belongs to the code that owns the server. Cap the read before you make it.

One shared secret across a fleet means every service can impersonate every other

The scheme authenticates a holder of the secret. If billing, orders and inventory all hold the same secretKey, any one of them can sign a request claiming svc= any of the others, and audience does not help — the caller chooses that too.

Use a secret per (caller, callee) pair and resolve it on the verifier:

typescript
{ secret: (serviceId, keyId) => secretsFor(serviceId)[keyId] }

makeSingleReplicaNonceStore is single-replica, as its name says

Behind N replicas a captured request is accepted up to once per replica per freshness window, because each process keeps its own set. A library that does not own your deployment cannot fix that. Supply your own OneBunNonceStore backed by shared storage when you run more than one replica — the interface is one method.

When full it refuses rather than evicting. Evicting would hand an attacker a bypass: flood the store, push out the entry for the request being replayed, replay it.

Rotating a secret ​

keyId travels in the signature, so a verifier can accept the old and the new key at once:

typescript
// Callers move to keyId: 'k2' one at a time; the callee already accepts both.
{ secret: (serviceId, keyId) => keyId === 'k2' ? NEW_SECRET : OLD_SECRET }

API reference ​

SymbolWhat it is
OneBunAuthConfigThe auth: { type: 'onebun', … } shape: serviceId, secretKey, and the optional algorithm, keyId and audience
signOneBunRequestProduces the header value. The client calls it per attempt; call it yourself only when you are not using createHttpClient
verifyOneBunRequestVerifies a request on the callee
OneBunVerifyInputWhat the verifier needs: method, url, headers, and the body bytes you already read
OneBunVerifyOptionssecret, audience, nonceStore, plus optional algorithms, maxAgeMs, maxSkewMs and an injectable now
OneBunAuthResult{ valid, serviceId?, keyId?, reason? }
OneBunAuthFailureReasonThe closed set of failure causes, for logs and metrics only
OneBunNonceStoreOne method: remember(key, expiresAtMs, nowMs). The verifier passes its own clock in, so a store cannot disagree with the freshness check that just ran
makeSingleReplicaNonceStoreThe in-process implementation
oneBunAuthFailureStatusMaps a reason to 401 or 503 — a full or unreachable store is your outage, not the caller's fault
isSigningAuthWhether a scheme signs the request (onebun) rather than shaping it (bearer, apikey, basic, custom). Drives pipeline order: shaping runs before the URL is built, signing after

HMAC scheme versions ​

The wire format is version 1 (since 0.6.0), and every signature header carries v=1. It is the only format the client produces and the only one the verifier accepts, so the caller and the callee must both speak it. Any other value fails with a named reason: an X-OneBun-Signature that holds a bare hex digest, the unversioned format, fails with reason: 'legacy-unversioned-signature', one that names another version (v=<n>;…) fails with 'unsupported-version', and anything else with 'malformed-header'. The single X-OneBun-Signature header is the whole protocol: X-OneBun-Service-Id, X-OneBun-Timestamp, X-OneBun-Nonce and X-OneBun-Algorithm are neither sent nor read.

There is no validateOneBunAuth. Verify with verifyOneBunRequest, which takes the request rather than a header set, because the method, path, query and body it checks are not in a header set. It compares signatures in constant time. With a nonceStore it records each nonce it accepts, so a captured request is refused when it is replayed; with nonceStore: false it records nothing, and a replay inside the freshness window is accepted.

Retry Configuration ​

Defaults ​

With no retries at all, a client uses:

FieldDefaultMeaning
max3Retries after the first attempt — up to 4 requests in total
delay300Base delay in milliseconds
backoff'exponential'Waits 300ms, 600ms, 1200ms
factor2Multiplier for exponential backoff
retryOn[408, 429, 500, 502, 503, 504]Status codes a server returned
methods['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']Methods allowed to be replayed
retryOnNetworkErrortrueRefused / DNS / TLS / reset — no response arrived
retryOnTimeoutfalseThe client-side timeout fired, before the headers or during the body

POST and PATCH are not retried unless you ask for it. Replaying them creates a second order, a second charge, a second message. The default list is the idempotent set of RFC 9110 §9.2.2, the same shape axios-retry and got use.

A client-side timeout is not retried either, for any method: the request may well have reached the server and been processed, so re-sending it duplicates the effect just as a POST replay would. A transport failure carries code: 0 (TRANSPORT_FAILURE_CODE) and the error name TIMEOUT_ERROR, ABORT_ERROR or FETCH_ERROR — it is never reported as a server 500, so retryOn stays a pure list of status codes.

The timeout covers the body too, so a 500 whose body stalls until the timeout is a TIMEOUT_ERROR: retryOnTimeout decides whether it is replayed, and retryOn never sees it. A 500 that arrives whole is a server answer and follows retryOn, even when its body does not parse. See Timeouts and interruption. The exception is a request under maxResponseBytes: a 5xx whose body is over the limit (RESPONSE_TOO_LARGE) or cannot be decoded (RESPONSE_DECODE_ERROR) is never replayed.

A network failure is not always a request the server never saw. A refused connection or a failed DNS lookup is; a connection reset after the request was sent is reported the same way (FETCH_ERROR), and that request may have been processed. retryOnNetworkError replays both for the methods in methods, which is safe for the idempotent defaults and one more reason to keep POST and PATCH out unless the endpoint dedupes.

Overriding ​

A partial config is merged field-wise onto the defaults, so overriding one field keeps every other one:

typescript
// max becomes 5; delay stays 300, retryOn still includes 429, POST is still not retried
const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  retries: { max: 5 },
});

The full shape:

typescript
const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  retries: {
    // Number of retry attempts after the first one
    max: 3,

    // Backoff strategy: 'fixed', 'linear', 'exponential'
    backoff: 'exponential',

    // Base delay in milliseconds
    delay: 300,

    // Multiplier for exponential backoff (default: 2)
    factor: 2,

    // HTTP status codes to retry
    retryOn: [408, 429, 500, 502, 503, 504],

    // Methods allowed to be replayed
    methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],

    // Retry when the connection failed before a response arrived
    retryOnNetworkError: true,

    // Retry when the client-side timeout fired, before the headers or during the body
    retryOnTimeout: false,

    // Callback on retry
    onRetry: (error, attempt) => {
      logger.warn(`Retry attempt ${attempt}`, error);
    },
  },
});

Retrying a non-idempotent method ​

Only opt POST or PATCH in when the endpoint is safe to call twice — because it is guarded by an idempotency key, or because a duplicate is harmless:

typescript
// This endpoint dedupes on Idempotency-Key, so a replay is safe
const response = await client.post('/charges', payload, {
  headers: { 'Idempotency-Key': chargeId },
  retries: { methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'POST'] },
});

Per-request retries merge onto the client's config, which merges onto the defaults.

Observing retries ​

Every retry emits a framework-level warning naming the method, URL, attempt number and the code that triggered it — independently of onRetry. After the fact, both success and error responses carry retryCount, the number of retries that were spent:

typescript
const response = await client.get('/reports');

if (response.retryCount && response.retryCount > 0) {
  logger.warn('Upstream needed retries', { retryCount: response.retryCount });
}

Retry Strategies ​

typescript
// Fixed delay
// Retries after: 1000ms, 1000ms, 1000ms
retries: { max: 3, backoff: 'fixed', delay: 1000 }

// Linear backoff
// Retries after: 1000ms, 2000ms, 3000ms
retries: { max: 3, backoff: 'linear', delay: 1000 }

// Exponential backoff
// Retries after: 1000ms, 2000ms, 4000ms, 8000ms...
retries: { max: 3, backoff: 'exponential', delay: 1000, factor: 2 }

Error Handling ​

A failed request rejects ​

The Promise methods run the underlying Effect with Effect.runPromise, and a failure — a 4xx/5xx the server returned, a timeout, a refused connection — is an Effect failure. So await on a failed request throws; it never resolves with an ErrorResponse:

typescript
import { isErrorResponse } from '@onebun/core';

const response = await client.get<User>('/users/123');

// The declared type is the `ApiResponse<T>` union, so TypeScript asks you to narrow it before
// reading `result`. The branch itself never runs — a failed request has already thrown.
if (isErrorResponse(response)) {
  throw new Error(response.error);
}

const user = response.result;

The failure names itself in error, not message: ErrorResponse has no message field — see Response Format.

Reading the ErrorResponse ​

The Effect API puts the failure in the error channel, which is where the ErrorResponse is actually reachable:

typescript
import { Effect, isErrorResponse } from '@onebun/core';

const outcome = await Effect.runPromise(Effect.either(client.getEffect<User>('/users/123')));

if (outcome._tag === 'Left') {
  outcome.left.error;       // 'HTTP_ERROR'
  outcome.left.code;        // 404
  outcome.left.details;     // { url, method, duration, headers, details }
  outcome.left.retryCount;  // retries spent before giving up
} else if (!isErrorResponse(outcome.right)) {
  const user = outcome.right.result;
}

Staying on the Promise API, the same ErrorResponse can be dug out of the rejection — it travels inside Effect's FiberFailure, so it is not on error.message and instanceof tells you nothing:

typescript
import { Cause, Runtime } from 'effect';
import { isErrorResponse } from '@onebun/core';

try {
  const response = await client.get<User>('/users/123');
  // ... use response
} catch (error) {
  if (Runtime.isFiberFailure(error)) {
    const failure = Cause.squash(error[Runtime.FiberFailureCauseId]);

    if (isErrorResponse(failure)) {
      // failure.code === 404, failure.error === 'HTTP_ERROR'
    }
  }
}

Mapping upstream failures to framework errors ​

typescript
import { Effect, isErrorResponse, NotFoundError, InternalServerError } from '@onebun/core';

@Service()
export class UserService extends BaseService {
  async findById(id: string): Promise<User> {
    const outcome = await Effect.runPromise(
      Effect.either(this.client.getEffect<User>(`/users/${id}`)),
    );

    if (outcome._tag === 'Left') {
      if (outcome.left.code === 404) {
        throw new NotFoundError('User', { id });
      }
      throw new InternalServerError(outcome.left.error, outcome.left.details);
    }

    // Narrows the success channel, which is typed as the `ApiResponse<T>` union
    if (isErrorResponse(outcome.right)) {
      throw new InternalServerError(outcome.right.error);
    }

    return outcome.right.result;
  }
}

An uncaught client error and your caller ​

A client error that a controller does not catch is answered by the default exception filter. client.req() throws a OneBunBaseError, the RequestsService Effect API fails with one, and an error you build from a failure's details, as in the example above, carries the client's record as its own. The filter sends a OneBunBaseError whole, details included, except the transport details of an error the client produced: what the upstream answered with and where the request went. The upstream sent those to your application, not to its caller.

ErrorLeft out of the caller's body
HTTP_ERRORdetails.headers (the upstream's set-cookie among them), details.url with its query, details.details (the upstream's body)
REDIRECT_ERRORdetails.url, details.location
REQUEST_CONFIG_ERRORdetails.url
TIMEOUT_ERROR, ABORT_ERROR, FETCH_ERRORdetails.details, the raw error: Bun's connection error names the request URL
RESPONSE_PARSE_ERROR, RESPONSE_READ_ERROR, RESPONSE_DECODE_ERRORdetails.details: the upstream's body, or the raw error

The rest of the body is sent: the status, error, code, details.method, details.duration, details.transport, and every field of an error you wrote yourself. The error object keeps everything: only the body the filter serializes leaves the transport details out.

typescript
import { Controller, Get, Param } from '@onebun/core';
import { createHttpClient } from '@onebun/requests';

const billing = createHttpClient({ baseUrl: 'http://billing.internal:8080' });

@Controller('/invoices')
export class InvoicesController {
  @Get('/:id')
  async findOne(@Param('id') id: string) {
    // Billing answers 404 with its own cookies: the caller gets a 500 REQUEST_FAILED
    return await billing.req('GET', `/invoices/${id}`);
  }
}

The caller of GET /invoices/42 reads the failure's name, status, method and duration, and none of billing's headers, its body or http://billing.internal:8080/invoices/42:

json
{
  "success": false,
  "error": "REQUEST_FAILED",
  "code": 500,
  "details": {
    "originalError": {
      "_id": "FiberFailure",
      "cause": {
        "_id": "Cause",
        "_tag": "Fail",
        "failure": {
          "success": false,
          "error": "HTTP_ERROR",
          "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
          "code": 404,
          "details": { "duration": 12, "method": "GET" },
          "retryCount": 0
        }
      }
    }
  }
}

exposeErrorDetails sends them. With the application option on, the filter sends such an error whole, as it sends the stack of an unhandled one: the upstream's set-cookie, the URL and its query, the upstream's body. Turn it on knowingly, in a development configuration you can read.

typescript
import { OneBunApplication } from '@onebun/core';

const app = new OneBunApplication(AppModule, { exposeErrorDetails: true });

A copy of the client's record is yours. The filter recognises the record the client built, wherever it sits: an error whose details IS that record keeps the transport details out. A spread makes a new record, and the filter sends it whole, the upstream's headers and URL included. Pick the fields your caller should read instead:

typescript
import { Effect } from '@onebun/core';
import { BadGatewayError } from '@onebun/requests';

const outcome = await Effect.runPromise(Effect.either(billing.getEffect(`/invoices/${id}`)));

if (outcome._tag === 'Left') {
  // Not `{ ...outcome.left.details, invoiceId: id }`: that copy is sent whole
  throw new BadGatewayError('BILLING_UNAVAILABLE', { invoiceId: id, upstreamStatus: outcome.left.code });
}

A failure you return is not an error. Filters see only what a handler throws. A handler that returns the client's failure (return outcome.left) sends it as its result, inside { success: true, result }, and sends it whole: the upstream's headers, its body and the URL included. Throw it, or map it as above.

A filter of your own that serializes a OneBunBaseError itself leaves the transport details out with the same replacer the default filter uses:

typescript
import { createExceptionFilter, OneBunBaseError, withoutTransportDetails } from '@onebun/core';

export const envelopeFilter = createExceptionFilter((error) => {
  if (!(error instanceof OneBunBaseError)) {
    return undefined; // not ours: the next filter out answers
  }

  return new Response(JSON.stringify(error.toErrorResponse(), withoutTransportDetails), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
});

Timeouts and interruption ​

timeout bounds the whole response, body included. fetch resolves as soon as the status line and headers arrive, and the body is read afterwards under the same deadline. Whichever part it catches, the call fails with TIMEOUT_ERROR and code: 0. When the headers had already arrived, details.phase is 'body' and details.statusCode is the status they carried:

typescript
import { Effect } from '@onebun/core';
import { getTransportFailureKind } from '@onebun/requests';

const outcome = await Effect.runPromise(
  Effect.either(client.getEffect('/reports/export', { timeout: 5000 })),
);

if (outcome._tag === 'Left' && getTransportFailureKind(outcome.left) === 'timeout') {
  outcome.left.error;               // 'TIMEOUT_ERROR'
  outcome.left.code;                // 0
  outcome.left.details?.phase;      // 'body' once the headers had arrived
  outcome.left.details?.statusCode; // the status that arrived, e.g. 200 or 500
}

The upstream sees the connection close when the timeout fires, in either phase.

A streamed response is the exception: under responseType: 'stream', timeout bounds each wait on the upstream rather than the whole response (see Streaming a response).

A 500 whose body stalls is therefore a timeout, not a 500: the status arrived, the response did not, and retryOnTimeout — off by default — decides whether it is replayed.

Interrupting the Effect aborts the request. Effect.timeout, Effect.race or Fiber.interrupt around any *Effect method cancels the fetch itself — before the headers or in the middle of the body — so the upstream sees the connection close at once, not at the client's own timeout:

typescript
import { Effect } from '@onebun/core';

// Aborted after 2 s, although the client's own timeout is 10 s
const outcome = await Effect.runPromise(
  Effect.either(Effect.timeout(client.getEffect('/reports'), '2 seconds')),
);

The interrupted Effect reports the interruption (here a TimeoutException), not an ErrorResponse. The Promise methods run to completion or to their own timeout.

Redirects ​

The client follows 301, 302, 303, 307 and 308 itself, up to 20 in one call, and resolves with the final response — statusCode is the final hop's. A relative Location is resolved against the URL that answered it. The method changes the way fetch changes it:

AnswerNext request
301 or 302 to a POSTGET, without the body and without Content-Type
303 to anything but GET or HEADGET, without the body and without Content-Type
307 or 308the same method with the same body bytes

Any other method answered 301 or 302 keeps its method and body.

The chain is one request as far as the options go. timeout bounds all of its hops together, not each one. Interrupting the Effect aborts whichever hop is in flight. A retry replays the chain from the original URL. The metrics sink gets one record for the chain, not one per hop, with the original URL and the final status.

That is the default 'follow' policy. To refuse redirects, or to handle them yourself, see Redirect policy.

Credentials stay with their origin ​

A hop to the same origin — same scheme, host and port — carries every header of the original request. Only an onebun signature may change: see Signed requests. A hop to any other origin carries only these:

  • User-Agent, Accept and Accept-Encoding
  • traceparent, X-Trace-Id and X-Span-Id
  • Content-Type, while the hop still sends a body (307, 308)

Every other header is dropped. That covers Authorization (bearer and basic), the apikey header, custom auth headers, X-OneBun-Signature, Cookie and Proxy-Authorization. It also covers every header set through RequestsOptions.headers or config.headers, credential or not, such as Accept-Language or X-Request-Id. 127.0.0.1 and localhost are different origins, and so are two ports of one host. A header a hop dropped stays dropped, even when the chain comes back to the first origin.

typescript
import { createHttpClient } from '@onebun/core';

const api = createHttpClient({
  baseUrl: 'https://api.example.com',
  auth: { type: 'apikey', key: 'X-Api-Key', value: apiKey },
});

// /v1/files/42 answers 302 to https://cdn.example.net/files/42. The CDN request carries
// User-Agent, Accept and the trace headers, never X-Api-Key.
const file = await api.get('/v1/files/42');

If the other origin needs credentials, call it with a client of its own.

An apikey with location: 'query' is part of the URL, so whether it reaches the next hop depends on whether the redirecting server's Location repeats it.

Signed requests ​

onebun auth signs the method, URL and body of a request. A redirect to the same origin changes at least the URL, so the original signature does not verify there. When the auth names an audience, the client signs each same-origin hop afresh, over the method, URL and body that hop sends:

typescript
import { createHttpClient } from '@onebun/core';

const billing = createHttpClient({
  baseUrl: 'http://billing:3000',
  auth: {
    type: 'onebun',
    serviceId: 'orders-service',
    secretKey: process.env.BILLING_SECRET ?? '',
    audience: 'billing',
  },
});

// /v1/invoices answers 307 to /v2/invoices. The second request carries a signature over
// POST /v2/invoices and the same body, with a fresh timestamp and nonce, so billing verifies
// it as it verified the first.
await billing.post('/v1/invoices', { orderId: 42 });

Without an audience a same-origin hop carries the original signature unchanged, and the callee rejects it with signature-mismatch. Set an audience, or call the final URL directly. A hop to another origin carries no signature at all, re-signed or not, and neither does any hop after it, even one that comes back to the first origin.

Following a redirect gives whoever sent it a fresh signature for a method, path and body of its choosing on that origin. An audience makes that signature valid only at the callee it names, which is why a hop is re-signed only when there is one.

Redirect policy ​

redirect decides what a 301, 302, 303, 307 or 308 leads to. Set it on the client, or on one request:

  • 'follow' — the default: follow it, as described above.
  • 'error' — fail with REDIRECT_ERROR, details.reason: 'refused-by-policy'. The Location is not contacted, and the error is never retried.
  • 'manual' — resolve with the redirect itself: a success whose statusCode is the 3xx and whose headers.location is the Location, exactly as the server sent it.

The option's type is RedirectPolicy, exported by @onebun/requests.

typescript
import { createHttpClient } from '@onebun/core';

const client = createHttpClient({ baseUrl: 'https://api.example.com', redirect: 'error' });

// Hand this one redirect back instead of failing on it
const login = await client.post('/login', { user: 'ada' }, { redirect: 'manual' });

if (login.success && login.statusCode === 302) {
  login.headers?.location; // e.g. '/dashboard', relative as the server sent it
}

// get, delete, head and options take the policy in their third argument
const report = await client.get('/reports/latest', undefined, { redirect: 'follow' });

A request's own redirect wins over the client's. redirect is not one of the config markers, so client.get('/login', { redirect: '/home' }) sends GET /login?redirect=%2Fhome. Pass the policy in the third argument, as in the last call above.

Under 'manual' the body of the 3xx is read like any other body, under maxResponseBytes too, and becomes result. An empty one that says Content-Type: application/json resolves with result: undefined instead of failing, so the Location is still there to read. A Location that is relative stays relative: resolve it against the URL you called, for example with new URL(location, 'https://api.example.com/login').

RequestsService methods return result alone, so under 'manual' they resolve with the body of the 3xx, and its status and Location are out of reach. Read a redirect through HttpClient, as above.

REDIRECT_ERROR ​

A redirect the client cannot follow, or may not, fails with REDIRECT_ERROR, and code is the 3xx that was not followed. It is never retried, whatever retryOn lists: asking again gets the same redirect.

typescript
import { Effect } from '@onebun/core';

const outcome = await Effect.runPromise(Effect.either(client.getEffect('/files/42')));

if (outcome._tag === 'Left' && outcome.left.error === 'REDIRECT_ERROR') {
  outcome.left.code;               // the 3xx that was not followed, e.g. 302
  outcome.left.details?.reason;    // 'too-many-redirects' | 'missing-location' | 'invalid-location'
                                   // | 'refused-by-policy' | 'other-host'
  outcome.left.details?.location;  // the Location header, when there was one
  outcome.left.details?.redirects; // how many redirects this call had followed
}
  • too-many-redirects — the answer that would have been the 21st redirect
  • missing-location — a 301, 302, 303, 307 or 308 without a Location
  • invalid-location — a Location that is not a URL, or not an http: or https: one
  • refused-by-policy — any of the five under redirect: 'error', with or without a Location; redirects is 0
  • other-host — under connectAddress, a Location on another host

Any other 3xx is an answer, not a redirect, and its Location is not followed, under every policy — a 304, for one, resolves as a success (see Responses without a body).

Limiting the response size ​

Without a limit the client reads a body whole, however large it is. maxResponseBytes sets one, in bytes of decoded body — on the client for all of its requests, or on one request:

typescript
import { createHttpClient } from '@onebun/core';

const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  maxResponseBytes: 1024 * 1024, // 1 MiB
});

// Tighter for one call
const status = await client.get('/status', undefined, { maxResponseBytes: 4096 });

// No limit for one call
const archive = await client.get('/exports/latest', undefined, {
  maxResponseBytes: Number.POSITIVE_INFINITY,
});

A request's own maxResponseBytes wins over the client's. Infinity means no limit: that request is read exactly as one without the option, and fetch decompresses it. The option is config in the two-argument form too: client.get('/status', { maxResponseBytes: 4096 }).

The limit is checked while the body is read, never after:

  • Decoded bytes are counted. A limited request takes the body as it came off the wire and undoes gzip, deflate, br and zstd itself, counting what comes out. A small compressed body that inflates far past the limit, such as a gzip bomb, is stopped at the chunk that crosses the limit. It is never inflated whole.
  • A declared size over the limit is refused before reading. This applies to a body sent uncompressed whose Content-Length exceeds the limit. A compressed body's Content-Length is its compressed size, so that body is counted instead.
  • Error statuses are read under the same limit. A 500 whose body fits is an HTTP_ERROR with that body in details.details, as without a limit. A 500 whose body does not fit fails RESPONSE_TOO_LARGE.
  • Reading stops at once. The connection is closed and the upstream sees its response stream cancelled.

A limited request sends Accept-Encoding: gzip, deflate, br, zstd, the same value fetch sends by itself. An Accept-Encoding header you set is sent instead. When the client decoded the body, neither a success's headers nor an HTTP_ERROR's details.headers has content-encoding or content-length: those describe the compressed bytes, not result or details.details.

RESPONSE_TOO_LARGE ​

A body larger than the limit fails with RESPONSE_TOO_LARGE. Its code is the status that arrived:

typescript
import { Effect } from '@onebun/core';

const outcome = await Effect.runPromise(Effect.either(client.getEffect('/exports/latest')));

if (outcome._tag === 'Left' && outcome.left.error === 'RESPONSE_TOO_LARGE') {
  outcome.left.code;                   // the status that arrived, e.g. 200 or 500
  outcome.left.details?.limit;         // the limit, e.g. 1048576
  outcome.left.details?.received;      // decoded bytes counted when reading stopped
  outcome.left.details?.statusCode;    // the status again
  outcome.left.details?.contentLength; // the declared size, when refused before reading
}

The error carries no part of the body. It is never retried, whatever retryOn lists: asking again gets the same body.

RESPONSE_DECODE_ERROR ​

A limited request whose body the client cannot decode fails with RESPONSE_DECODE_ERROR. code is the status that arrived, and details.reason says why:

  • 'unsupported-encoding' — a Content-Encoding other than gzip, x-gzip, deflate, br, zstd or identity. details.encoding names it, and nothing is read.
  • 'corrupt-body' — the decoder rejected the bytes: they were corrupt, the compressed data stopped short of its end, or bytes followed its end. fetch drops such trailing bytes; the client does not. details.encoding is the Content-Encoding header.
typescript
import { Effect } from '@onebun/core';

const outcome = await Effect.runPromise(Effect.either(client.getEffect('/legacy/feed')));

if (outcome._tag === 'Left' && outcome.left.error === 'RESPONSE_DECODE_ERROR') {
  outcome.left.details?.reason;   // 'unsupported-encoding' | 'corrupt-body'
  outcome.left.details?.encoding; // e.g. 'x-foo'
}

It is never retried either, whatever retryOn lists. A request without a limit is decoded by fetch, which hands a body in an unknown coding over as it is.

Response types ​

A success's result is the body parsed as JSON when the response says application/json, and text otherwise. responseType asks for something else, per request:

responseTyperesultThe call resolves
'auto' (the default)parsed JSON, or textonce the whole body has arrived
'bytes'a Uint8Array of the body, byte for byteonce the whole body has arrived
'stream'a ReadableStream<Uint8Array> of the bodyas soon as the headers arrive

The option's type is ResponseType, exported by @onebun/requests. Name the type of result as the call's type argument, as in the examples below.

responseType changes only what a success resolves with:

  • An error status's body is read as under 'auto', so an HTTP_ERROR carries the same details.details whichever mode was asked for.
  • An answer to HEAD, a 204 and a 304 resolve with result: undefined in every mode (see Responses without a body).
  • maxResponseBytes applies in every mode, with the body decoded and counted by the client.
  • responseType is one of the config markers, so client.get('/events', { responseType: 'stream' }) is config, not query data.

Binary bodies ​

Decoding a body as text replaces every byte sequence that is not valid UTF-8 with U+FFFD, so an image, a font, a PDF or a protobuf message comes back corrupted under 'auto'. 'bytes' hands the body over as it is:

typescript
import { createHttpClient } from '@onebun/core';

const cdn = createHttpClient({ baseUrl: 'https://cdn.example.com' });

const font = await cdn.get<Uint8Array>('/fonts/inter.woff2', undefined, { responseType: 'bytes' });

if (font.success) {
  font.result;                    // Uint8Array
  font.headers?.['content-type']; // 'font/woff2'
}

The content coding is undone first, so result holds the resource itself, not its gzip or br transfer form. A JSON body is not parsed either: result is its bytes.

Streaming a response ​

'stream' resolves as soon as the status line and headers arrive, with the body as a ReadableStream<Uint8Array> that you read as it comes. Use it for a server-sent event stream, a long export, or a download you pass on:

typescript
import { createHttpClient } from '@onebun/core';

const api = createHttpClient({ baseUrl: 'https://api.example.com', timeout: 30_000 });

const feed = await api.get<ReadableStream<Uint8Array>>('/events', undefined, {
  responseType: 'stream',
  headers: { Accept: 'text/event-stream' },
});

if (feed.success) {
  const decoder = new TextDecoder();

  for await (const chunk of feed.result) {
    handleEvents(decoder.decode(chunk, { stream: true }));
  }
}

timeout bounds each wait, not the whole response. A stream may rightly go on for hours, so under 'stream' timeout bounds:

  • the wait for the headers, as for any request, across the whole redirect chain;
  • then every read's wait for the next chunk. A read that waits longer errors the stream with TIMEOUT_ERROR, and the connection is closed.

A stream whose chunks keep coming lives as long as they do, and one that stalls is cut off timeout after its last chunk. Only the time spent waiting on the upstream counts; the time your code takes between two reads does not. Keep timeout above the longest silence the upstream allows itself, such as its heartbeat interval.

A failed read rejects with an ErrorResponse, the same record the Effect API fails with:

  • TIMEOUT_ERROR, code: 0, details.phase: 'body' — a read waited longer than timeout
  • RESPONSE_TOO_LARGE — under maxResponseBytes, at the chunk that takes the count past the limit; that chunk is not handed out
  • RESPONSE_DECODE_ERROR — under maxResponseBytes, a body the decoder rejects
  • RESPONSE_READ_ERROR — the connection broke off mid-body
typescript
import { getTransportFailureKind, isErrorResponse } from '@onebun/requests';

async function readFeed(feed: ReadableStream<Uint8Array>): Promise<void> {
  try {
    for await (const chunk of feed) {
      handleChunk(chunk);
    }
  } catch (error) {
    if (isErrorResponse(error) && getTransportFailureKind(error) === 'timeout') {
      // The upstream went quiet for longer than `timeout`: reconnect
    }
  }
}

Up to the headers, a streamed request is an ordinary one. An error status fails the call with HTTP_ERROR and is retried as retryOn says. A body refused before reading under maxResponseBytes fails the call too. Once the stream is handed over, nothing is retried: the call has resolved, and a failure errors the stream. The metrics record the time to the headers.

Read the stream to its end, or cancel it. The connection stays open for as long as the stream does. cancel() on the stream or its reader, or a break out of a for await loop, closes the connection, and the upstream sees its stream cancelled.

To pass a body on, return it in a Response; the framework sends it as it arrives, and when the caller disconnects the upstream stream is cancelled:

typescript
import { BaseController, Controller, Get, Param, createHttpClient, isErrorResponse } from '@onebun/core';

@Controller('/files')
export class FilesController extends BaseController {
  private readonly storage = createHttpClient({ baseUrl: 'https://storage.example.com' });

  @Get('/:id')
  async download(@Param('id') id: string): Promise<Response> {
    const file = await this.storage.get<ReadableStream<Uint8Array>>(`/objects/${id}`, undefined, {
      responseType: 'stream',
    });

    if (isErrorResponse(file)) {
      throw new Error(file.error);
    }

    return new Response(file.result, {
      headers: { 'Content-Type': file.headers?.['content-type'] ?? 'application/octet-stream' },
    });
  }
}

Pass on content-type, never content-encoding or content-length: fetch has already undone the coding, and those two describe the compressed bytes.

Connecting to a validated address ​

A request to a host name makes fetch look the name up. An application that checks where a URL points before calling it — to refuse private and internal addresses when it fetches a URL a user supplied, say — checks one lookup, and then fetch makes a second one. The two can answer differently: that is DNS rebinding, and the check guarded nothing. connectAddress closes the gap. Pass the IP address you validated, and the request connects to that address without a lookup:

typescript
import { lookup } from 'node:dns/promises';

import { createHttpClient } from '@onebun/core';

const client = createHttpClient({ timeout: 5000 });

export async function fetchPreview(url: string) {
  const { address } = await lookup(new URL(url).hostname);

  // Your own policy: private ranges, loopback, link-local, metadata endpoints...
  if (!isAllowedAddress(address)) {
    throw new Error(`${url} resolves to an address this service may not call`);
  }

  return await client.get(url, undefined, { connectAddress: address });
}

Only the connection changes. The server sees the request it would see without connectAddress:

  • Host is the URL's host, with its port when that is not the scheme's default (a.example.com:8443). A Host header you set yourself is sent as it is.
  • Over HTTPS, the TLS SNI is the URL's host name, and the certificate is verified against that name, not against the address. A server at the address that cannot show a certificate for the name fails the request with FETCH_ERROR (ERR_TLS_CERT_ALTNAME_INVALID in details.details).
  • Signing, metrics, retry logs and error details use the URL with its host name. Only a transport failure's details.details — the raw error of fetch — names the address it connected to.
  • Retries connect to the same address.

The framework checks only that the value is an IP address. Which addresses are allowed is your application's policy, and so is the lookup.

Redirects under connectAddress ​

A redirect to the same host name, on any port and either scheme, connects to the same address. A redirect to another host fails with REDIRECT_ERROR, details.reason: 'other-host', before that host is contacted. The address was validated for one host name, and another host is one nobody has checked — an IP literal in the Location included. To follow such a redirect, take it with redirect: 'manual', validate its host and call it with a connectAddress of its own.

Values that are refused ​

connectAddress is an IPv4 address in four decimal parts, or an IPv6 address without brackets and without a zone — what isIP from node:net accepts, and what dns.lookup returns. Anything else fails with REQUEST_CONFIG_ERROR before anything is sent:

typescript
import { Effect } from '@onebun/core';

const outcome = await Effect.runPromise(Effect.either(
  client.getEffect('https://a.example.com/x', undefined, { connectAddress: 'a.example.com' }),
));

if (outcome._tag === 'Left' && outcome.left.error === 'REQUEST_CONFIG_ERROR') {
  outcome.left.code;             // 500
  outcome.left.details?.option;  // 'connectAddress'
  outcome.left.details?.reason;  // 'not-an-ip' | 'not-an-http-url'
  outcome.left.details?.value;   // 'a.example.com'
}
  • not-an-ip — a host name, '[::1]', 'fe80::1%eth0', '010.0.0.1', '127.1', an empty string, null: only undefined means "no connectAddress"
  • not-an-http-url — the URL is not an http: or https: one, or is not absolute

It is never retried and no metrics are recorded: no request was made. connectAddress is one of the config markers, so client.get(url, { connectAddress }) is config, not query data.

Proxies ​

fetch reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY from the environment, and it sees the URL with the address in it:

  • HTTPS_PROXY: the proxy receives CONNECT <address>:<port>, the IP literal, never the host name. Nothing is looked up, but the traffic goes through the proxy, and the SNI inside the tunnel is the host name.
  • HTTP_PROXY, for an http: URL: the request line names the address (GET http://<address>:<port>/path) and Host the name. Where the proxy connects is up to the proxy.
  • NO_PROXY is matched against the address, not the host name. An entry naming the host does not exempt the request; an entry naming the address does.

Request Configuration ​

Every per-request config argument is a Partial<RequestConfig> — method and url come from the method you call and the path you pass, so only these fields are yours to set:

typescript
{
  /** Request timeout in milliseconds — covers the body as well as the headers */
  timeout?: number;

  /** Custom headers */
  headers?: Record<string, string>;

  /** Query parameters — `query`, not `params` */
  query?: Record<string, unknown>;

  /** Request body for POST, PUT, PATCH — `data`, not `body` */
  data?: unknown;

  /** Override retry config for this request; merges field-wise onto the defaults */
  retries?: Partial<RetryConfig>;

  /** Override auth for this request */
  auth?: AuthConfig;

  /** Set to `false` to skip the trace headers / the metrics for this one request */
  tracing?: boolean;
  metrics?: boolean;

  /** The largest body this request accepts, in decoded bytes; `Infinity` lifts the client's */
  maxResponseBytes?: number;

  /** What a redirect leads to: 'follow' (the default), 'error' or 'manual' */
  redirect?: 'follow' | 'error' | 'manual';

  /** What a success's `result` is: parsed JSON or text, a Uint8Array, or a ReadableStream */
  responseType?: 'auto' | 'bytes' | 'stream';

  /** The IP address to connect to instead of looking the URL's host name up */
  connectAddress?: string;
}

query and data are what the client reads off a config object, but the way to set them is positional — client.get(url, query, config) and client.post(url, data, config).

The third argument is always the config, whatever the second one holds: client.get('/x', undefined, { headers: { … } }) sends the headers. That holds for all seven methods — get, delete, head, options, post, put and patch.

HTTP Status Codes ​

typescript
import { HttpStatusCode } from '@onebun/core';

HttpStatusCode.OK                    // 200
HttpStatusCode.CREATED               // 201
HttpStatusCode.NO_CONTENT            // 204
HttpStatusCode.BAD_REQUEST           // 400
HttpStatusCode.UNAUTHORIZED          // 401
HttpStatusCode.FORBIDDEN             // 403
HttpStatusCode.NOT_FOUND             // 404
HttpStatusCode.CONFLICT              // 409
HttpStatusCode.UNPROCESSABLE_ENTITY  // 422
HttpStatusCode.INTERNAL_SERVER_ERROR // 500
HttpStatusCode.BAD_GATEWAY           // 502
HttpStatusCode.SERVICE_UNAVAILABLE   // 503

Using in Services ​

typescript
import { Effect, Service, BaseService, createHttpClient, isErrorResponse } from '@onebun/core';

@Service()
export class ExternalApiService extends BaseService {
  private readonly client;

  constructor() {
    super();
    this.client = createHttpClient({
      baseUrl: this.config.get('external.apiUrl'),
      auth: {
        type: 'bearer',
        token: this.config.get('external.apiToken'),
      },
      retries: {
        max: 3,
        backoff: 'exponential',
        delay: 1000,
      },
    });
  }

  async fetchData(id: string): Promise<ExternalData> {
    this.logger.debug('Fetching external data', { id });

    const outcome = await Effect.runPromise(
      Effect.either(this.client.getEffect<ExternalData>(`/data/${id}`)),
    );

    if (outcome._tag === 'Left') {
      this.logger.error('External API error', {
        id,
        code: outcome.left.code,
        error: outcome.left.error,
      });
      throw new Error(`External API error: ${outcome.left.error}`);
    }

    if (isErrorResponse(outcome.right)) {
      throw new Error(outcome.right.error);
    }

    return outcome.right.result;
  }

  async createResource(data: CreateResourceDto): Promise<Resource> {
    // A failure throws — the caller's exception filter turns it into the error response
    const response = await this.client.post<Resource>('/resources', data);

    if (isErrorResponse(response)) {
      throw new Error(response.error);
    }

    return response.result;
  }
}

Service Client (Inter-service Communication) ​

createServiceClient() calls another OneBun service over HTTP by controller and method name. It is exported from @onebun/core and uses this package's HTTP client as its transport. The routes come from the callee's module; the types do not: arguments and results are untyped. See What the client checks.

createServiceDefinition() takes the module class of the service being called and reflects its endpoints out of the decorator metadata already on its controllers. There is no literal to hand-maintain, and no way for the definition to drift from the routes it describes:

typescript
import { createServiceDefinition, createServiceClient } from '@onebun/core';

import { UsersModule } from './users/users.module';

// Reflected from @Controller/@Get/@Post metadata — nothing to keep in step by hand
export const UsersServiceDefinition = createServiceDefinition(UsersModule);

The client is configured with url (required), not baseUrl — ServiceClientOptions deliberately omits baseUrl so the two cannot be confused:

typescript
// In a service, use this.config.get() for the address and secrets
const usersClient = createServiceClient(UsersServiceDefinition, {
  url: this.config.get('services.usersUrl'),
  serviceName: 'orders-service',
  auth: {
    type: 'onebun',
    serviceId: 'orders-service',
    secretKey: this.config.get('services.secretKey'),
  },
});

Controllers are reached by their class name, and handler arguments are passed positionally, in the order the handler declares its decorated parameters (see What the client checks for the ones the client does not send):

typescript
// Each call resolves `any`: the response envelope, not the handler's value (see below)
const all = await usersClient.UsersController.findAll();
const found = await usersClient.UsersController.findById('123');
const created = await usersClient.UsersController.create({ name: 'John' });

What the client checks ​

The client routes by name and checks the names. It does not check types.

At compile timeAt run time
Controller namenot checked: createServiceClient() returns Record<string, ControllerClient>reading a name the definition lacks throws, and the error lists the controllers there are
Method namenot checked: every name of a ControllerClient is a methodreading a name the controller lacks throws
Path parameter valuesnot checkeda value that would change the route is refused before anything is sent, see Path parameter values
Argumentsnot checked: every method is (...args: any[]) => Promise<any>not checked, except that a path parameter left out (undefined) or null is refused like any other route-changing value. A missing @Query or @Body argument is not sent, and extra arguments are dropped
Resultanynot checked against the handler's return type

Arguments are matched by position to every decorated parameter of the handler, in the order it declares them, and the client sends only the @Param, @Query and @Body values. Any other decorated parameter (@Header, @Cookie, @Req, a custom extractor, a file or form field) still takes a position, and whatever is passed there is dropped without an error. For findById(@Header('x-tenant') tenant: string, @Param('id') id: string) the call is findById(undefined, '123'): findById('123') puts '123' in the header's position, and rejects with a TypeError because id is undefined. Extra arguments are dropped too, and a path value is sent as text: findById(42) requests /users/42.

The promise resolves the HTTP client's success envelope, and its result is the server's own { success, result } body. The value the handler returned is therefore at response.result.result. A 4xx or 5xx response rejects the promise instead, as described in A failed request rejects.

typescript
interface User {
  id: string;
  name: string;
}

const response = await usersClient.UsersController.findById('123');
// { success: true, result: { success: true, result: { id: '123', name: 'Ada' } }, statusCode: 200, retryCount: 0 }

// The annotation is an assertion: nothing compares it with what the handler returns
const user: User = response.result.result;

Keep the expected types next to the call, as above: the client has no way to derive them from the callee's handlers.

Path parameter values ​

A path parameter value fills exactly one segment of the route, and it is inserted as is, without encoding. A value that would make the request reach a different route is refused: the call rejects with a TypeError naming the controller, the method and the parameter, and no request is made. Sent, such a value would carry this client's credentials to that other route — '../admin/secret' as a user id would read GET /admin/secret.

Refused valueWhat it would do if sent
null, undefinedsend the text null / undefined as the id
contains /, \, ? or #end the segment: the rest becomes more path, a query string or a fragment. A URL parser treats \ like /
'', ., ..make an empty segment or a dot segment: '' and . reach the sibling route (GET /users/), .. climbs one level
%2e for a dot: %2e, %2e%2e, .%2E, %2E.the same as . and ..: the URL parser reads %2e as a dot
one of the above with a tab or newline inside ('.\t.') or whitespace at the end ('.. ')the same again: the URL parser deletes tabs and newlines, and trims the end of the URL, before it looks for dot segments

Every other value is sent unchanged. To send a value that contains /, ?, # or \, percent-encode it: the router decodes the segment, and the handler receives the original text.

typescript
// Sent as GET /users/team%2Falice; the handler's @Param('id') receives 'team/alice'
const member = await usersClient.UsersController.findById(encodeURIComponent('team/alice'));

// Rejects with a TypeError and sends nothing:
// UsersController.findById: path parameter "id" contains "/", which ends a path segment, ...
await usersClient.UsersController.findById('team/alice');

encodeURIComponent leaves . alone, so . and .. cannot be sent as a path segment at all. Pass such a value as a @Query parameter or in the @Body.

The client as a value ​

The client and each client.<Controller> are ordinary values. An async factory can build and return the client, a promise can resolve to it, and JSON.stringify, String and string templates accept it. in tells whether the service definition has a controller, or a controller has a method:

typescript
import { createServiceClient, createServiceDefinition } from '@onebun/core';

import { UsersModule } from './users/users.module';

const UsersServiceDefinition = createServiceDefinition(UsersModule);

// An async factory resolves to the client itself
async function connectUsers(url: string) {
  return createServiceClient(UsersServiceDefinition, { url });
}

const usersClient = await connectUsers('http://users:3001');
const user = await usersClient.UsersController.findById('123');

const hasUsers = 'UsersController' in usersClient; // true
const hasFindById = 'findById' in usersClient.UsersController; // true
const hasOrders = 'OrdersController' in usersClient; // false

Reading a controller or method that the definition does not have throws, at the line that reads it. A missing controller gives Controller "OrdersController" not found in service definition. Available controllers: UsersController, which lists the controllers there are. A missing method gives Method "remove" not found in controller "UsersController".

Response Format ​

All responses follow the standard format:

Success Response ​

typescript
interface SuccessResponse<T> {
  success: true;
  result: T;
  traceId?: string;
  /** Retries spent before this response was produced; `0` means one request */
  retryCount?: number;
  /** The HTTP status the upstream returned: 200, 201, 204, 304, ... (a 3xx under 'manual') */
  statusCode?: number;
  /** The headers the upstream answered with, names lower-cased. Not enumerable */
  headers?: Record<string, string>;
}

result is what responseType asks for: parsed JSON or text by default, a Uint8Array under 'bytes', a ReadableStream<Uint8Array> under 'stream'.

headers holds the upstream's response headers on every success the client produced: an etag, a location, a rate-limit header. Names are lower-cased. A header sent more than once is joined with , , as Headers.get() joins it, and set-cookie is joined the same way. After a redirect, they are the final hop's headers; under redirect: 'manual', the redirect's own, location included. An error's details.headers is collected differently: there, a set-cookie sent more than once keeps only its last value. Under maxResponseBytes, both leave out content-encoding and content-length when the client decoded the body.

typescript
const head = await client.head('/files/report.pdf');

if (head.success) {
  head.headers?.etag;               // '"v7"'
  head.headers?.['content-length']; // '52431'
}

const created = await client.post('/files', { name: 'report.pdf' });

if (created.success) {
  const location = created.headers?.location; // '/files/42'
}

headers is not enumerable. JSON.stringify, Object.keys, a spread ({ ...response }) and structuredClone skip it. response.headers and 'headers' in response work as usual. Because of this, a controller that returns the envelope as it is does not send the upstream's set-cookie, server or any other header on to its own caller. That holds on every route. To pass a header on, set it on a Response you return (see Custom Response Headers). A success the framework builds around a handler's return value has no headers.

Bun's own inspector does list a non-enumerable property. console.log(response), Bun.inspect and a toMatchSnapshot() snapshot all show headers, with the upstream's set-cookie and date. To log or snapshot an envelope, use { ...response } or the fields you need.

Responses without a body ​

An answer to HEAD, and every 204 No Content and 304 Not Modified, has no content by definition, so the client does not read a body for them: result is undefined and statusCode says which one arrived. The content-type does not matter — Response.json() keeps application/json on its answer to HEAD, and a 204 may carry it too.

A 304 resolves as a success. A server sends one only in answer to a conditional request (If-None-Match, If-Modified-Since), so it is the outcome you asked about — your copy is current — and not a failure:

typescript
const head = await client.head('/users/123');
// { success: true, result: undefined, statusCode: 200 }

const removed = await client.delete('/users/123');
// a 204 No Content: { success: true, result: undefined, statusCode: 204 }

const response = await client.get<User>('/users/123', undefined, {
  headers: { 'If-None-Match': etag },
});
if (response.success && response.statusCode === 304) {
  // keep the copy you already have
}

Error Response ​

typescript
interface ErrorResponse<E extends string = string, R extends string = string>
  extends OneBunError<E, R> {
  success: false;
  retryCount?: number;
}

interface OneBunError<E extends string = string, R extends string = string> {
  /** Machine-readable error name, e.g. 'HTTP_ERROR' or 'TIMEOUT_ERROR' */
  error: E;
  /** HTTP status, or `0` (`TRANSPORT_FAILURE_CODE`) for a timeout, an abort or a network failure */
  code: number;
  traceId?: string;
  /** Request context: url, method, duration, response headers, raw body */
  details?: Record<string, unknown>;
  originalError?: OneBunError<R>;
}

There is no message field. error carries the machine-readable name and details the context. The context is for your application: when a client error escapes a controller, the default exception filter leaves its transport details out of the caller's body (see An uncaught client error and your caller).

Complete Example ​

typescript
import {
  Effect,
  Service,
  BaseService,
  createHttpClient,
  isErrorResponse,
  NotFoundError,
} from '@onebun/core';
import { Span } from '@onebun/trace';

interface User {
  id: string;
  name: string;
  email: string;
}

interface CreateUserDto {
  name: string;
  email: string;
}

@Service()
export class UserApiService extends BaseService {
  private readonly client;

  constructor() {
    super();
    // Config is available after super() — use it to initialize the HTTP client
    this.client = createHttpClient({
      baseUrl: this.config.get('services.usersUrl'),
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json',
      },
      auth: {
        type: 'onebun',
        serviceId: 'my-service',
        secretKey: this.config.get('services.secretKey'),
      },
      retries: {
        max: 3,
        backoff: 'exponential',
        delay: 1000,
        factor: 2,
        retryOn: [408, 429, 500, 502, 503, 504],
      },
    });
  }

  @Span('fetch-users')
  async findAll(page = 1, limit = 10): Promise<User[]> {
    this.logger.debug('Fetching users', { page, limit });

    // A failure throws, so this line only continues on success
    const response = await this.client.get<User[]>('/users', { page, limit });

    if (isErrorResponse(response)) {
      throw new Error(response.error);
    }

    this.logger.info('Users fetched', { count: response.result.length });
    return response.result;
  }

  @Span('fetch-user-by-id')
  async findById(id: string): Promise<User> {
    // Effect.either to inspect the status code the upstream returned
    const outcome = await Effect.runPromise(
      Effect.either(this.client.getEffect<User>(`/users/${id}`)),
    );

    if (outcome._tag === 'Left') {
      if (outcome.left.code === 404) {
        throw new NotFoundError('User', { id });
      }
      throw new Error(outcome.left.error);
    }

    if (isErrorResponse(outcome.right)) {
      throw new Error(outcome.right.error);
    }

    return outcome.right.result;
  }

  @Span('create-user')
  async create(data: CreateUserDto): Promise<User> {
    this.logger.info('Creating user', { email: data.email });

    const response = await this.client.post<User>('/users', data);

    if (isErrorResponse(response)) {
      throw new Error(response.error);
    }

    this.logger.info('User created', { userId: response.result.id });
    return response.result;
  }

  @Span('update-user')
  async update(id: string, data: Partial<CreateUserDto>): Promise<User> {
    const outcome = await Effect.runPromise(
      Effect.either(this.client.patchEffect<User>(`/users/${id}`, data)),
    );

    if (outcome._tag === 'Left') {
      if (outcome.left.code === 404) {
        throw new NotFoundError('User', { id });
      }
      throw new Error(outcome.left.error);
    }

    if (isErrorResponse(outcome.right)) {
      throw new Error(outcome.right.error);
    }

    return outcome.right.result;
  }

  @Span('delete-user')
  async delete(id: string): Promise<void> {
    const outcome = await Effect.runPromise(
      Effect.either(this.client.deleteEffect(`/users/${id}`)),
    );

    if (outcome._tag === 'Left') {
      if (outcome.left.code === 404) {
        throw new NotFoundError('User', { id });
      }
      throw new Error(outcome.left.error);
    }

    this.logger.info('User deleted', { userId: id });
  }
}

Released under the MPL-2.0 License.