Skip to content

Resilience & Client Combinators

Real networks fail. The HTTP client treats resilience as middleware you layer onto a client once, rather than logic you sprinkle across call sites. Because a configured client is just a value, you compose retries, timeouts, status checks, and custom request/response transforms with .pipe, and every request the client sends inherits them. The result is a single place that defines how your service behaves under load and partial failure.

import { Context, Effect, flow, Layer, Schedule, Schema } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse
} from "effect/unstable/http"
class Repo extends Schema.Class<Repo>("Repo")({
id: Schema.Number,
full_name: Schema.String,
stargazers_count: Schema.Number
}) {}
export class GitHub extends Context.Service<GitHub, {
getRepo(owner: string, name: string): Effect.Effect<Repo, GitHubError>
}>()("app/GitHub") {
static readonly layer = Layer.effect(
GitHub,
Effect.gen(function*() {
const client = (yield* HttpClient.HttpClient).pipe(
// Base URL + Accept header on every request.
HttpClient.mapRequest(flow(
HttpClientRequest.prependUrl("https://api.github.com"),
HttpClientRequest.acceptJson
)),
// Treat any non-2xx response as a failure.
HttpClient.filterStatusOk,
// Give each request 10 seconds before it is interrupted and fails.
HttpClient.transform(Effect.timeout("10 seconds")),
// Retry transient failures (network errors, timeouts, 5xx, 429) with an
// exponential backoff, up to 3 times.
HttpClient.retryTransient({
schedule: Schedule.exponential(200),
times: 3
})
)
const getRepo = Effect.fn("GitHub.getRepo")(
function*(owner: string, name: string) {
const response = yield* client.get(`/repos/${owner}/${name}`)
return yield* HttpClientResponse.schemaBodyJson(Repo)(response)
},
// Cross-cutting: wrap any failure as a domain error.
Effect.mapError((cause) => new GitHubError({ cause }))
)
return GitHub.of({ getRepo })
})
).pipe(Layer.provide(FetchHttpClient.layer))
}
export class GitHubError extends Schema.TaggedErrorClass<GitHubError>()(
"GitHubError",
{ cause: Schema.Defect }
) {}

By default the client does not fail on a 4xx or 5xx — it returns the response so you can inspect it. To turn a bad status into a typed failure, add HttpClient.filterStatusOk (2xx passes, everything else fails with HttpClientError). For a custom predicate, use HttpClient.filterStatus:

import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
// Accept 2xx and the redirect range, fail on everything else.
const client = base.pipe(
HttpClient.filterStatus((status) => status < 400)
)

HttpClient.retryTransient is the batteries-included retry. It already knows which failures are transient — connection (TransportError) failures, TimeoutError, and retryable status codes (408, 429, 500, 502, 503, 504) — so you usually only supply a schedule and a cap:

import { Schedule } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.retryTransient({
// Backoff between attempts. See the scheduling docs for richer policies.
schedule: Schedule.exponential("100 millis"),
// Maximum number of retries.
times: 5,
// Choose what counts: "errors-only", "response-only", or (default)
// "errors-and-responses".
retryOn: "errors-and-responses"
})
)

When you need full control over which errors to retry — including your own domain errors — use HttpClient.retry, which takes an Effect.retry options object or a raw Schedule:

import { Schedule } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.retry({
schedule: Schedule.exponential("100 millis"),
// Only retry while the predicate holds.
while: (error) => error.reason._tag === "TransportError"
})
)

Apply a timeout per request with HttpClient.transform, lifting Effect.timeout over the response effect. A timed-out request is interrupted and fails with a TimeoutError, which retryTransient then treats as transient:

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
// Each request must complete within 5 seconds.
HttpClient.transform(Effect.timeout("5 seconds"))
)

For client-side rate limiting, HttpClient.withRateLimiter integrates with the RateLimiter service from effect/unstable/persistence/RateLimiter. It can share a limit across requests by key, update the limit by reading standard rate limit response headers, and automatically retry 429 responses through the limiter:

import { Duration } from "effect"
import { HttpClient } from "effect/unstable/http"
import { RateLimiter } from "effect/unstable/persistence/RateLimiter"
declare const base: HttpClient.HttpClient
declare const limiter: RateLimiter
const client = base.pipe(
HttpClient.withRateLimiter({
limiter,
window: Duration.seconds(1),
limit: 10,
// Requests sharing a key share the limit — e.g. per-host or per-user.
key: (request) => new URL(request.url).host
})
)

FetchHttpClient lets you override two things from context: the Fetch reference (the fetch implementation, useful for testing or a polyfill) and the RequestInit service (default fetch options such as credentials or cache). Provide them through context to change transport behavior without touching your request code:

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
// Send credentials with every request.
const FetchLayer = FetchHttpClient.layer.pipe(
Layer.provide(
Layer.succeed(FetchHttpClient.RequestInit)({ credentials: "include" })
)
)

Every combinator below returns a new HttpClient with behavior layered around the previous one, so they all compose with .pipe. Combinators that add new error or requirement types widen the client’s E/R accordingly (HttpClient.HttpClient is HttpClient.With<HttpClientError>).

The service tag for the default outgoing client. Access it inside a Layer or Effect.gen, configure it, then store the configured client in your own service.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
const program = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const response = yield* client.get("https://api.example.com/health")
return response.status
// => 200
})

The module also re-exports accessor versions of every method (HttpClient.get, head, post, patch, put, del, options, and HttpClient.execute for a prebuilt request). They read the HttpClient service from context, so you do not have to yield* the client first:

import { HttpClient } from "effect/unstable/http"
// Effect<HttpClientResponse, HttpClientError, HttpClient>
const health = HttpClient.get("https://api.example.com/health")

Builds an HttpClient from a low-level request runner. The runner receives the request, the resolved URL, an AbortSignal, and the current fiber. The wrapper handles URL construction failures, tracing, header redaction, and aborting non-scoped requests on interruption — so a transport only has to produce a response.

import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
// A trivial transport backed by globalThis.fetch.
const client = HttpClient.make((request, url, signal) =>
Effect.map(
Effect.promise(() => fetch(url, { method: request.method, signal })),
(web) => HttpClientResponse.fromWeb(request, web)
)
)
// => HttpClient

The most general constructor: provide a postprocess function (turns the preprocessed request effect into a response effect) and a preprocess function (transforms the request before execution). Most combinators are implemented in terms of makeWith; reach for it only when adapting an unusual transport.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
// Re-wrap an existing client without changing behavior.
const client = HttpClient.makeWith(
(request) => base.postprocess(request),
(request) => base.preprocess(request)
)
// => HttpClient

Type guard that returns true for any value carrying the HttpClient type id.

import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
HttpClient.isHttpClient(base) // => true
HttpClient.isHttpClient({}) // => false

Wraps an Effect<HttpClient> into a Layer<HttpClient> whose response effects merge in the context captured at layer construction time. This is how FetchHttpClient.layer is built — services available where the layer is created remain available to every request the client makes.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const buildClient: Effect.Effect<HttpClient.HttpClient>
const layer = HttpClient.layerMergedContext(buildClient)
// => Layer<HttpClient>

By default a non-2xx response is a successful Effect — the response is returned so you can inspect it. These combinators turn unwanted statuses into a typed HttpClientError (or your own error) so retries and recovery can see them.

Fails with HttpClientError unless the response status is in the 2xx range.

import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = HttpClient.filterStatusOk(base)
// 404 => fails with HttpClientError (StatusCodeError)
// 200 => succeeds with the response

Fails with HttpClientError unless your predicate accepts the numeric status.

import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
// Accept 2xx and 3xx, fail on 4xx/5xx.
const client = base.pipe(
HttpClient.filterStatus((status) => status < 400)
)

Keeps responses that match the predicate; for the rest it runs an alternative effect that produces a replacement response (or fails). Useful for fallbacks.

import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
declare const fallback: HttpClientResponse.HttpClientResponse
const client = base.pipe(
HttpClient.filterOrElse(
(response) => response.status === 200,
// Substitute a cached/fallback response for anything else.
(_response) => Effect.succeed(fallback)
)
)

Keeps responses that match the predicate; for the rest it fails with an error you compute from the response. Use it to attach a domain error instead of the generic HttpClientError.

import { HttpClient } from "effect/unstable/http"
class NotOk {
readonly _tag = "NotOk"
constructor(readonly status: number) {}
}
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.filterOrFail(
(response) => response.status < 400,
(response) => new NotOk(response.status)
)
)
// 503 => fails with NotOk { status: 503 }

The batteries-included retry: it already classifies transient failures (TransportError, TimeoutError, and retryable statuses 408/429/500/ 502/503/504). Pass an options object or a bare Schedule.

Options:

  • schedule — the backoff Schedule between attempts.
  • times — maximum number of retries.
  • retryOn"errors-only" (retry failed effects), "response-only" (retry successful-but-transient responses like a 503 you have not filtered), or the default "errors-and-responses" (both).
  • while — an extra predicate that marks additional errors as transient (ignored in "response-only" mode).
import { Schedule } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.retryTransient({
schedule: Schedule.exponential("100 millis"),
times: 5,
retryOn: "errors-and-responses",
// Also retry our own InvalidUrlError-style transports if they showed up.
while: (error) => error.reason._tag === "TransportError"
})
)
// Bare schedule form (equivalent to retryOn: "errors-and-responses"):
const simple = base.pipe(HttpClient.retryTransient(Schedule.recurs(3)))

The general retry. Pass an Effect.retry options object (schedule, times, while, until, …) or a raw Schedule — nothing is classified as transient for you, so the predicate decides exactly what to retry.

import { Schedule } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.retry({
schedule: Schedule.exponential("100 millis"),
times: 3,
while: (error) => error.reason._tag === "StatusCodeError"
})
)
// Raw schedule form:
const fixed = base.pipe(HttpClient.retry(Schedule.spaced("1 second")))

These rewrite the outgoing request. mapRequest* runs after existing request middleware (it appends); mapRequestInput* runs before it (it prepends). The effectful variants can fail or read from context.

Appends a pure transformation of the request — base URLs, default headers, static auth.

import { flow } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.mapRequest(flow(
HttpClientRequest.prependUrl("https://api.example.com"),
HttpClientRequest.acceptJson
))
)

Appends an effectful transformation — use it when computing the new request is itself an effect, e.g. fetching a fresh token.

import { Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
declare const loadToken: Effect.Effect<string>
const client = base.pipe(
HttpClient.mapRequestEffect((request) =>
Effect.map(loadToken, (token) =>
HttpClientRequest.bearerToken(request, token)
)
)
)

Prepends a pure transformation — it runs before the client’s existing request middleware. Use it to seed defaults that later middleware may override.

import { HttpClient, HttpClientRequest } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.mapRequestInput(
HttpClientRequest.setHeader("x-request-id", crypto.randomUUID())
)
)

Prepends an effectful transformation, running before existing middleware.

import { Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
declare const nextRequestId: Effect.Effect<string>
const client = base.pipe(
HttpClient.mapRequestInputEffect((request) =>
Effect.map(nextRequestId, (id) =>
HttpClientRequest.setHeader(request, "x-request-id", id)
)
)
)

Wraps the response effect, with access to both the response effect and the original request. This is the most flexible postprocessing hook — timeouts, per-request fallbacks, request-aware retries.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.transform((effect, request) =>
request.method === "GET"
? Effect.timeout(effect, "5 seconds") // only time out reads
: effect
)
)

Wraps the response effect without the request argument. Many combinators (filterStatusOk, retry, catch, tap) are thin wrappers over this.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.transformResponse(Effect.timeout("5 seconds"))
)

Ties each request’s lifetime to the surrounding Scope instead of the individual request. The underlying connection is aborted when the scope closes — useful when you stream a response body across several steps and want the socket held open for that whole scope. Adds Scope to the client’s requirements.

import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = HttpClient.withScope(base)
// => HttpClient.With<HttpClientError, Scope>

Taps run effects for their side effects (logging, metrics) without changing the request, response, or error that flows through.

Runs an effect on the request before it is sent.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.tapRequest((request) =>
Effect.log(`${request.method} ${request.url}`)
)
)
// logs e.g. "GET https://api.example.com/users"

Runs an effect on a successful response.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.tap((response) =>
Effect.log(`status ${response.status}`)
)
)

Runs an effect on a failure, leaving the error in place.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const client = base.pipe(
HttpClient.tapError((error) =>
Effect.logError("HTTP request failed", error)
)
)

Handles all client failures with a recovery effect that produces a replacement response. Mirrors Effect.catch, scoped to the client.

import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
declare const cached: HttpClientResponse.HttpClientResponse
const client = base.pipe(
HttpClient.filterStatusOk,
HttpClient.catch((_error) => Effect.succeed(cached))
)

Recovers only failures whose _tag matches the given tag (or array of tags), leaving other errors untouched. Pair it with filterOrFail to introduce tagged domain errors at the client level.

import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
class RateLimited {
readonly _tag = "RateLimited"
}
declare const base: HttpClient.HttpClient
declare const empty: HttpClientResponse.HttpClientResponse
const client = base.pipe(
HttpClient.filterOrFail(
(response) => response.status !== 429,
() => new RateLimited()
),
HttpClient.catchTag("RateLimited", () => Effect.succeed(empty))
)

Handles several tagged failures at once via a case map.

import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
declare const fallback: HttpClientResponse.HttpClientResponse
const client = base.pipe(
HttpClient.catchTags({
HttpClientError: (_error) => Effect.succeed(fallback)
})
)

Transparently follows 3xx responses that carry a location header, re-issuing the request against the new URL up to maxRedirects times (default 10).

import { HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
// Follow up to 5 redirects automatically.
const client = HttpClient.followRedirects(base, 5)

Backs the client with a shared Ref<Cookies>: response Set-Cookie values are merged into the ref and sent as a cookie header on subsequent requests — session continuity without a cookie jar at every call site.

import { Effect, Ref } from "effect"
import { Cookies, HttpClient } from "effect/unstable/http"
declare const base: HttpClient.HttpClient
const program = Effect.gen(function*() {
const jar = yield* Ref.make(Cookies.empty)
const client = HttpClient.withCookiesRef(base, jar)
yield* client.get("https://api.example.com/login") // stores cookies
return yield* client.get("https://api.example.com/me") // resends them
})

Applies client-side rate limiting using the RateLimiter service. It inspects common rate limit response headers (ratelimit-limit, ratelimit-remaining, retry-after, …) to update the limit on the fly, and automatically retries 429 responses — including HttpClientErrors wrapping a 429 — by routing the retry back through the limiter. Adds RateLimiterError to the client’s errors.

WithRateLimiter.Options:

  • limiter — the RateLimiter service that gates requests.
  • windowDuration.Input for the initial limit window.
  • limit — initial maximum number of requests allowed in the window.
  • key — a fixed string or (request) => string; requests sharing a key share a limit (per-host, per-user, per-endpoint).
  • algorithm"fixed-window" (default) or "token-bucket".
  • tokens — cost of a request, a number or (request) => number (default 1).
  • disableResponseInspection — when true, skip reading limits and retry-after from response headers.
import { Duration } from "effect"
import { HttpClient } from "effect/unstable/http"
import { RateLimiter } from "effect/unstable/persistence/RateLimiter"
declare const base: HttpClient.HttpClient
declare const limiter: RateLimiter
const client = base.pipe(
HttpClient.withRateLimiter({
limiter,
window: Duration.seconds(1),
limit: 10,
key: (request) => new URL(request.url).host,
algorithm: "token-bucket",
tokens: 1,
disableResponseInspection: false
})
)
// => HttpClient.With<HttpClientError | RateLimiterError>

Every request is wrapped in a client span automatically, with attributes for method, URL, headers (redacted), and response status. These Context.References tune that behavior; override them via Layer or Effect.provideService.

Computes the span name for an outgoing request. Defaults to `http.client ${request.method}`.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
const program = Effect.provideService(
Effect.void,
HttpClient.SpanNameGenerator,
(request) => `fetch ${new URL(request.url).pathname}`
)
// span named e.g. "fetch /users"

A predicate that disables tracing for matching requests. Defaults to never disabling.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
const program = Effect.provideService(
Effect.void,
HttpClient.TracerDisabledWhen,
(request) => request.url.includes("/healthz") // don't trace health checks
)

Controls whether the outgoing span is propagated to request headers (W3C trace context). Defaults to true.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
const program = Effect.provideService(
Effect.void,
HttpClient.TracerPropagationEnabled,
false // don't inject traceparent headers (e.g. calling a 3rd-party API)
)

Provides an HttpClient backed by the Web Fetch API. Use it in browsers, edge runtimes, and Node where globalThis.fetch exists.

import { Effect } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
const program = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
return yield* client.get("https://api.example.com/ping")
}).pipe(Effect.provide(FetchHttpClient.layer))

A Context.Reference for the fetch implementation, defaulting to globalThis.fetch. Override it to inject a polyfill or capture requests in tests.

import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const stubFetch: typeof globalThis.fetch = async () =>
new Response(JSON.stringify({ ok: true }), { status: 200 })
const TestFetch = FetchHttpClient.layer.pipe(
Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(stubFetch))
)

A Context.Service carrying default RequestInit options (credentials, cache, redirect, integrity, …) applied to every request before request-specific fields override them.

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const FetchLayer = FetchHttpClient.layer.pipe(
Layer.provide(
Layer.succeed(FetchHttpClient.RequestInit)({
credentials: "include",
cache: "no-store"
})
)
)

See Platform for the platform-specific clients, and Observability — every request is traced automatically, so spans, timeouts, and retries all show up in your telemetry. See Error Management for the Effect-level combinators these client-level helpers mirror.