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
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
// 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
// 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
// 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
const client = createHttpClient({
baseUrl: 'https://api.example.com',
auth: {
type: 'bearer',
token: 'your-jwt-token',
},
});API Key
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
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.
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
| Covered | Not covered |
|---|---|
| Method | Scheme, host and port — bind audience instead |
| Path | Every header except Content-Type |
| Query string, exactly as sent | The response |
Content-Type | Request ordering |
| Every byte of the body | Confidentiality |
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
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:
{ 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:
// 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
| Symbol | What it is |
|---|---|
OneBunAuthConfig | The auth: { type: 'onebun', … } shape: serviceId, secretKey, and the optional algorithm, keyId and audience |
signOneBunRequest | Produces the header value. The client calls it per attempt; call it yourself only when you are not using createHttpClient |
verifyOneBunRequest | Verifies a request on the callee |
OneBunVerifyInput | What the verifier needs: method, url, headers, and the body bytes you already read |
OneBunVerifyOptions | secret, audience, nonceStore, plus optional algorithms, maxAgeMs, maxSkewMs and an injectable now |
OneBunAuthResult | { valid, serviceId?, keyId?, reason? } |
OneBunAuthFailureReason | The closed set of failure causes, for logs and metrics only |
OneBunNonceStore | One method: remember(key, expiresAtMs, nowMs). The verifier passes its own clock in, so a store cannot disagree with the freshness check that just ran |
makeSingleReplicaNonceStore | The in-process implementation |
oneBunAuthFailureStatus | Maps a reason to 401 or 503 — a full or unreachable store is your outage, not the caller's fault |
isSigningAuth | Whether 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:
| Field | Default | Meaning |
|---|---|---|
max | 3 | Retries after the first attempt — up to 4 requests in total |
delay | 300 | Base delay in milliseconds |
backoff | 'exponential' | Waits 300ms, 600ms, 1200ms |
factor | 2 | Multiplier 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 |
retryOnNetworkError | true | Refused / DNS / TLS / reset — no response arrived |
retryOnTimeout | false | The 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:
// 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:
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:
// 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:
const response = await client.get('/reports');
if (response.retryCount && response.retryCount > 0) {
logger.warn('Upstream needed retries', { retryCount: response.retryCount });
}Retry Strategies
// 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:
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:
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:
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
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.
| Error | Left out of the caller's body |
|---|---|
HTTP_ERROR | details.headers (the upstream's set-cookie among them), details.url with its query, details.details (the upstream's body) |
REDIRECT_ERROR | details.url, details.location |
REQUEST_CONFIG_ERROR | details.url |
TIMEOUT_ERROR, ABORT_ERROR, FETCH_ERROR | details.details, the raw error: Bun's connection error names the request URL |
RESPONSE_PARSE_ERROR, RESPONSE_READ_ERROR, RESPONSE_DECODE_ERROR | details.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.
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:
{
"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.
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:
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:
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:
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:
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:
| Answer | Next request |
|---|---|
301 or 302 to a POST | GET, without the body and without Content-Type |
303 to anything but GET or HEAD | GET, without the body and without Content-Type |
307 or 308 | the 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,AcceptandAccept-Encodingtraceparent,X-Trace-IdandX-Span-IdContent-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.
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:
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 withREDIRECT_ERROR,details.reason: 'refused-by-policy'. TheLocationis not contacted, and the error is never retried.'manual'— resolve with the redirect itself: a success whosestatusCodeis the3xxand whoseheaders.locationis theLocation, exactly as the server sent it.
The option's type is RedirectPolicy, exported by @onebun/requests.
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.
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 redirectmissing-location— a301,302,303,307or308without aLocationinvalid-location— aLocationthat is not a URL, or not anhttp:orhttps:onerefused-by-policy— any of the five underredirect: 'error', with or without aLocation;redirectsis0other-host— underconnectAddress, aLocationon 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:
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,brandzstditself, 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-Lengthexceeds the limit. A compressed body'sContent-Lengthis its compressed size, so that body is counted instead. - Error statuses are read under the same limit. A
500whose body fits is anHTTP_ERRORwith that body indetails.details, as without a limit. A500whose body does not fit failsRESPONSE_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:
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'— aContent-Encodingother thangzip,x-gzip,deflate,br,zstdoridentity.details.encodingnames 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.fetchdrops such trailing bytes; the client does not.details.encodingis theContent-Encodingheader.
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:
responseType | result | The call resolves |
|---|---|---|
'auto' (the default) | parsed JSON, or text | once the whole body has arrived |
'bytes' | a Uint8Array of the body, byte for byte | once the whole body has arrived |
'stream' | a ReadableStream<Uint8Array> of the body | as 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 anHTTP_ERRORcarries the samedetails.detailswhichever mode was asked for. - An answer to
HEAD, a204and a304resolve withresult: undefinedin every mode (see Responses without a body). maxResponseBytesapplies in every mode, with the body decoded and counted by the client.responseTypeis one of the config markers, soclient.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:
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:
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 thantimeoutRESPONSE_TOO_LARGE— undermaxResponseBytes, at the chunk that takes the count past the limit; that chunk is not handed outRESPONSE_DECODE_ERROR— undermaxResponseBytes, a body the decoder rejectsRESPONSE_READ_ERROR— the connection broke off mid-body
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:
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:
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:
Hostis the URL's host, with its port when that is not the scheme's default (a.example.com:8443). AHostheader 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_INVALIDindetails.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 offetch— 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:
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: onlyundefinedmeans "noconnectAddress"not-an-http-url— the URL is not anhttp:orhttps: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 receivesCONNECT <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 anhttp:URL: the request line names the address (GET http://<address>:<port>/path) andHostthe name. Where the proxy connects is up to the proxy.NO_PROXYis 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:
{
/** 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
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 // 503Using in Services
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:
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:
// 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):
// 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 time | At run time | |
|---|---|---|
| Controller name | not checked: createServiceClient() returns Record<string, ControllerClient> | reading a name the definition lacks throws, and the error lists the controllers there are |
| Method name | not checked: every name of a ControllerClient is a method | reading a name the controller lacks throws |
| Path parameter values | not checked | a value that would change the route is refused before anything is sent, see Path parameter values |
| Arguments | not 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 |
| Result | any | not 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.
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 value | What it would do if sent |
|---|---|
null, undefined | send 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.
// 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:
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; // falseReading 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
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.
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:
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
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
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 });
}
}