ProjectsmahiHttp Client

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

HTTP client

@mahiframework/http-client makes outbound HTTP requests, the port of Laravel's Illuminate\Http\Client (Http::withToken()->post(), Http::fake(), Http::assertSent()).

It is independent of @mahiframework/http, which handles inbound requests. Neither package imports the other; both just speak WHATWG Request/Response.

import { Http } from "@mahiframework/http-client";

const response = await Http.withToken(token).post("https://api.example.com/users", {
  name: "Ada",
});

if (response.successful()) {
  console.log(response.json<{ id: number }>().id);
}

Contents

The basics

Every verb is async and returns a ClientResponse:

await Http.get("https://api.example.com/users");
await Http.get("https://api.example.com/users", { page: 2 });   // query params
await Http.post("https://api.example.com/users", { name: "Ada" });
await Http.put("https://api.example.com/users/1", { name: "Ada L" });
await Http.patch("https://api.example.com/users/1", { name: "Ada L" });
await Http.delete("https://api.example.com/users/1");
await Http.head("https://api.example.com/users");

A non-2xx response resolves, it does not reject. A 404 or a 500 is an ordinary return value you inspect:

const response = await Http.get("https://api.example.com/users/999");

response.status;        // 404
response.successful();  // false
response.notFound();    // true

Only a transport failure, DNS, connection refused, TLS, timeout, rejects, as a ConnectionError. If a send rejects, the request never got an answer. Raising on a failed status is opt-in via throw().

Built on fetch

The transport is the platform fetch (undici-backed on Node 26), not a third-party client. undici is fetch here, so depending on it would duplicate the runtime; axios would make this a port of axios wearing Laravel's method names. Anything fetch supports that this package doesn't wrap is reachable through withFetchOptions().

Building requests

The builder is immutable. Every method returns a new PendingRequest, so a configured client is safe to hold, reuse, and use concurrently:

const github = Http.baseUrl("https://api.github.com")
  .withToken(process.env.GITHUB_TOKEN!)
  .acceptJson()
  .timeout(10_000);

const [user, repos] = await Promise.all([github.get("/user"), github.get("/repos")]);

Body formats

await Http.asJson().post(url, { name: "Ada" });        // the default
await Http.asForm().post(url, { name: "Ada" });        // x-www-form-urlencoded
await Http.asMultipart().post(url, { name: "Ada" });   // multipart/form-data
await Http.withBody("raw text", "text/plain").post(url);
await Http.contentType("application/vnd.api+json").post(url, payload);

Attachments

attach() forces multipart and lets fetch generate the boundary:

await Http.attach("avatar", imageBytes, "me.png")
  .attach("resume", "plain text CV", "cv.txt")
  .post("https://api.example.com/profile", { name: "Ada" });

Headers and auth

Http.withHeaders({ "X-App": "mahi", Accept: "application/json" });
Http.withHeader("X-Request-Id", id);
Http.appendHeader("X-Multi", "a").appendHeader("X-Multi", "b");   // "a, b"
Http.accept("application/xml");
Http.acceptJson();
Http.withUserAgent("mahi/1.0");

Http.withToken(token);                    // Authorization: Bearer <token>
Http.withToken(token, "Token");           // Authorization: Token <token>
Http.withBasicAuth("ada", "s3cret");      // Authorization: Basic <base64>

withHeaders() replaces on collision. Laravel's accumulates into an array. See Differences.

URLs

Http.baseUrl("https://api.example.com").get("/users");   // absolute URLs still win

Http.withUrlParameters({ host: "api.github.com", repo: "mahi" })
  .get("https://{host}/repos/{repo}");

Http.withQueryParameters({ page: 1, tag: ["a", "b"] }).get(url);

Placeholder values are percent-encoded, so a parameter can't inject path segments. Query arrays expand to repeated keys (tag=a&tag=b); nested objects expand to bracket notation ({ filter: { status: "x" } }filter[status]=x), the inverse of the inbound query parser; null/ undefined values are skipped. The same nesting applies to asForm() and asMultipart() bodies.

Escape hatches

withFetchOptions() merges raw RequestInit over everything the builder produced, the analogue of dropping Guzzle options straight in. Proxies and TLS settings live here, via undici's non-standard dispatcher:

import { ProxyAgent } from "undici";   // the *app's* dependency, not this package's

await Http.withFetchOptions({ dispatcher: new ProxyAgent(proxyUrl) }).get(url);

withTransport() swaps the transport function outright, the seam fakes, mocks, and record/replay hook into:

await Http.withTransport(async (request, init) => new Response("stubbed")).get(url);

timeout() and a caller-supplied signal compose rather than override. When you pass your own AbortSignal via withFetchOptions({ signal }), a timeout() still applies, the request aborts as soon as either fires (AbortSignal.any), so a cancellation signal never silently disables the timeout:

await Http.timeout(5_000)
  .withFetchOptions({ signal: controller.signal })   // cancel + timeout both live
  .get(url);

Responses

The body is buffered once and memoised, so accessors are synchronous and repeatable, a platform Response body is single-use, and making every accessor async would poison every call site:

const response = await Http.get(url);

response.body();               // string
response.json<User>();         // parsed, memoised
response.json("user.name");    // dot-path lookup
response.json("user.age", 0);  // with a fallback
response.collect<Item>("items");
response.bytes();              // Uint8Array

response.status;        // number
response.url;           // effective URL, post-redirect
response.durationMs;
response.header("content-type");
response.headers();
response.cookies();     // parsed from Set-Cookie

Status predicates: successful(), ok(), created(), noContent(), redirect(), failed(), clientError(), serverError(), unauthorized(), forbidden(), notFound(), unprocessable(), tooManyRequests().

failed() covers 4xx and 5xx, but not a 3xx.

Error handling

throw() raises RequestFailedError on a failed response, is a no-op on success, and returns the response either way, so it chains:

const user = (await Http.get(url)).throw().json<User>();

The response body is included in the error message (truncated), because a bare "status code 422" is useless without the validation errors.

Http.throw();                                  // on the builder, before sending
Http.throwIf((response) => response.status === 419);
Http.throwUnless((response) => response.ok());

// On the response:
response.throw((r, error) => log(r.status));
response.throwIf(condition);
response.throwUnless(condition);
response.throwIfStatus(404);                   // unconditional — fires on a 2xx too
response.throwUnlessStatus(200);
response.onError((r) => log(r.status));        // never throws
response.toException();                        // the error, or undefined

Errors:

ErrorWhen
ConnectionErrorThe transport failed. No response was received.
RequestFailedErrorA failed status, and you opted into throwing.
StrayRequestErrorA request matched no stub while faking. Never thrown in production.

StrayRequestError extends Error directly, not a shared base, so catch (e) { if (e instanceof ConnectionError) ... } in application code cannot swallow a test-harness failure.

Retries

await Http.retry(3).get(url);              // 3 attempts, no delay
await Http.retry(3, 100).get(url);         // 100ms between attempts
await Http.retry(4, [100, 500, 2000]).get(url);
await Http.retry(3, (attempt) => attempt * 100).get(url);

Everything that failed is retryable by default, any 4xx or 5xx, including 401 and 422, plus ConnectionError. That is Laravel's behaviour.

Retrying a 422 that will never succeed is wasteful, but a status allow-list baked into the framework would be worse: a 401 is retryable when middleware refreshes an expired token between attempts, and a 409 is against an optimistic-locking API. A client can't tell those apart from the status alone, and the failure would look like the framework ignoring your retry(3). Predictability wins; narrowing is one predicate:

// "Retry only 5xx and connection failures" — the common intent.
await Http.retry(3, 100, (error, response) =>
  error instanceof ConnectionError || (response?.serverError() ?? false),
).get(url);

The predicate gets (undefined, response) for a failed status and (error, undefined) for a transport failure.

Retry-After is honoured on a 429 or 503 carrying it (delta-seconds or an HTTP-date), overriding the configured backoff, capped at 60s. Laravel ignores the header, which is the single most common reason a retrying client gets rate-limit-banned.

On exhaustion the final failed response is returned, not thrown, throw() still governs raising:

const response = await Http.retry(3).get(url);   // resolves with the last 500
await Http.retry(3).throw().get(url);            // raises after 3 attempts

Retry wraps the whole pipeline, so middleware re-runs and RequestSending fires once per attempt.

Bodies must be replayable. Each attempt re-sends the request body, so a one-shot ReadableStream body can't be retried. It is consumed by the first attempt and there is nothing left to send. Rather than let the second attempt fail with an opaque "body is disturbed or locked" error (which would be mislabelled as a ConnectionError and retried again), retry() refuses such a request up front with a clear message. Use a string or Uint8Array body, or buffer the stream yourself, when the request needs retries. String, Uint8Array, FormData, and JSON bodies are all replayable.

Middleware

Middleware is @mahiframework/pipeline's PipeFn, one mechanism covering Laravel's withMiddleware + beforeSending + afterResponse. A pipe sees the request on the way down and the response on the way back:

await Http.withMiddleware(async (request, next) => {
  const started = Date.now();
  const response = await next(request.withHeader("X-Trace", traceId));
  metrics.record(Date.now() - started, response.status);
  return response;
}).get(url);

Shorthands for the one-directional cases:

Http.withRequestMiddleware((request) => request.withHeader("X-Signed", sign(request)));
Http.withResponseMiddleware((response) => log(response) ?? response);

A pipe that returns without calling next() short-circuits. The transport never runs, which is how you'd build a cache layer.

Ordering is global middleware outermost, per-request inside it, transport innermost.

Concurrent requests

const { user, repos } = await Http.pool(
  (http) => ({
    user: () => http.get("https://api.example.com/user"),
    repos: () => http.get("https://api.example.com/repos"),
  }),
  { concurrency: 2 },
);

Keys are preserved and a per-entry failure lands as an Error value, so one failure never discards the other results. Check with instanceof Error before using an entry.

The callback returns thunks, not promises: a promise is already running by the time you hold one, so an array of them can't be concurrency-limited. That is why this needs none of Laravel's LazyPromise machinery.

Http.pool() is a thin wrapper over @mahiframework/core's pooled(), which is general. Pooling has nothing to do with HTTP.

Streaming and downloads

sink() writes the body straight to a file or WritableStream, so a large download never lands in memory:

await Http.sink("/tmp/big-file.zip").get("https://example.com/big-file.zip");
await Http.sink(writableStream).get(url);

stream() skips buffering entirely and hands you the raw stream. body(), json(), and bytes() then throw, pointing you at stream():

const response = await Http.stream().get("https://example.com/huge.ndjson");

for await (const chunk of response.stream()) {
  process(chunk);
}

Streaming request bodies work too. duplex: "half" is set for you whenever the body is a ReadableStream, which is exactly the detail that otherwise fails at runtime.

Testing

See Testing → Faking HTTP requests for the full guide. In short:

import { Http } from "@mahiframework/http-client";

afterEach(() => Http.restore());

it("notifies the webhook", async () => {
  Http.fake({ "hooks.example.com/*": { ok: true } });

  await notify();

  Http.assertSent("hooks.example.com/deploy");
});

An unmatched request raises StrayRequestError rather than reaching the network. Http.allowStrayRequests() opts out.

A note on SSRF

Like Laravel's client, this package does not apply an allow/deny list to request hosts: a URL built from user input can reach internal addresses (169.254.169.254, localhost, RFC 1918 ranges). If any part of a request URL is attacker-influenced, validate the host before sending, reject non-public addresses, or restrict to an explicit allow-list of hosts. The transport seam (withTransport()) or a request middleware is the natural place to enforce this centrally.

Configuration

Optional. The package works standalone with no container. Register HttpClientServiceProvider to configure defaults and named clients:

// config/http-client.ts
export default {
  timeout: 10_000,
  headers: { "User-Agent": "mahi/1.0" },
  clients: {
    github: {
      baseUrl: "https://api.github.com",
      headers: { Accept: "application/vnd.github+json" },
    },
  },
} satisfies HttpClientConfig;
await Http.client("github").get("/user");

The provider also wires event dispatch when @mahiframework/events is registered: RequestSending, ResponseReceived, and ConnectionFailed. Without a dispatcher, events are silently skipped.

Differences from Laravel

An unmatched fake raises instead of hitting the network. Laravel's Http::fake() falls through to the real handler for an unmatched request, so a typo'd pattern silently makes a live call from your test suite. preventStrayRequests() is opt-in there. Here the safe behaviour is the default and allowStrayRequests() opts out.

Retry-After is honoured. On a 429/503, the server's delay overrides your backoff (capped at 60s). Laravel ignores the header.

withHeaders() replaces rather than accumulates. Laravel uses array_merge_recursive, so withHeaders({X:'1'}).withHeaders({X:'2'}) yields X: ['1','2'], the reason its replaceHeaders() exists at all. Ours replaces; appendHeader() covers the genuine multi-value case. replaceHeaders() is not ported, having no reason to exist.

Everything is async. Laravel's LazyPromise, FluentPromise, async() toggle, and separate async retry loop all exist to bolt concurrency onto a synchronous default. Starting async means pool() is pooled() over thunks and there is exactly one retry implementation.

PendingRequest is immutable. Laravel's send() mutates the instance, nulling pendingBody, assigning request/cookies/transferStats, which makes a configured client unsafe to hold or use concurrently. Copy-on-write fixes that, and holding a baseUrl()-configured client is the entire point.

throwIf takes a real predicate. Laravel's has the wart that a callable condition is itself truthy, so throwIf($closure) always arms.

What fetch costs

Guzzle featureStatus here
connect_timeout separate from timeoutNot available. One AbortSignal.timeout() covers the whole exchange, so connectTimeout() is not ported.
on_stats / handlerStats()Not available. You get durationMs and the effective url. For more, use a response middleware.
Digest / NTLM authNot available. They need a challenge-response round trip; implement one as middleware if you need it.
Proxies, TLS optionsVia withFetchOptions({ dispatcher }), with undici as your app's dependency.
Cookie jarNot automatic. withCookies() sets a header, response.cookies() reads them, but nothing persists across requests.
maxRedirectsNot available. fetch is follow-or-manual; use withoutRedirecting().

Also not ported: dd() (it calls exit(1), which kills a Node test runner), Batch (promise combinators express it directly), and macros (the framework has no Macroable; facades forward a hand-written method list).