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 }) {}Failing on bad status
Section titled “Failing on bad status”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))Retries
Section titled “Retries”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" }))Timeouts
Section titled “Timeouts”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")))Rate limiting
Section titled “Rate limiting”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.HttpClientdeclare 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 }))Configuring the underlying fetch
Section titled “Configuring the underlying fetch”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" }) ))API reference
Section titled “API reference”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>).
HttpClient.HttpClient
Section titled “HttpClient.HttpClient”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) ))// => HttpClientmakeWith
Section titled “makeWith”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))// => HttpClientisHttpClient
Section titled “isHttpClient”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) // => trueHttpClient.isHttpClient({}) // => falselayerMergedContext
Section titled “layerMergedContext”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>Status filtering
Section titled “Status filtering”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.
filterStatusOk
Section titled “filterStatusOk”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 responsefilterStatus
Section titled “filterStatus”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))filterOrElse
Section titled “filterOrElse”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.HttpClientdeclare 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) ))filterOrFail
Section titled “filterOrFail”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 }Retries
Section titled “Retries”retryTransient
Section titled “retryTransient”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 backoffSchedulebetween attempts.times— maximum number of retries.retryOn—"errors-only"(retry failed effects),"response-only"(retry successful-but-transient responses like a503you 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")))Request transforms
Section titled “Request transforms”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.
mapRequest
Section titled “mapRequest”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 )))mapRequestEffect
Section titled “mapRequestEffect”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.HttpClientdeclare const loadToken: Effect.Effect<string>
const client = base.pipe( HttpClient.mapRequestEffect((request) => Effect.map(loadToken, (token) => HttpClientRequest.bearerToken(request, token) ) ))mapRequestInput
Section titled “mapRequestInput”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()) ))mapRequestInputEffect
Section titled “mapRequestInputEffect”Prepends an effectful transformation, running before existing middleware.
import { Effect } from "effect"import { HttpClient, HttpClientRequest } from "effect/unstable/http"
declare const base: HttpClient.HttpClientdeclare const nextRequestId: Effect.Effect<string>
const client = base.pipe( HttpClient.mapRequestInputEffect((request) => Effect.map(nextRequestId, (id) => HttpClientRequest.setHeader(request, "x-request-id", id) ) ))Response transforms
Section titled “Response transforms”transform
Section titled “transform”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 ))transformResponse
Section titled “transformResponse”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")))withScope
Section titled “withScope”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>Observation taps
Section titled “Observation taps”Taps run effects for their side effects (logging, metrics) without changing the request, response, or error that flows through.
tapRequest
Section titled “tapRequest”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}`) ))tapError
Section titled “tapError”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) ))Error recovery
Section titled “Error recovery”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.HttpClientdeclare const cached: HttpClientResponse.HttpClientResponse
const client = base.pipe( HttpClient.filterStatusOk, HttpClient.catch((_error) => Effect.succeed(cached)))catchTag
Section titled “catchTag”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.HttpClientdeclare const empty: HttpClientResponse.HttpClientResponse
const client = base.pipe( HttpClient.filterOrFail( (response) => response.status !== 429, () => new RateLimited() ), HttpClient.catchTag("RateLimited", () => Effect.succeed(empty)))catchTags
Section titled “catchTags”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.HttpClientdeclare const fallback: HttpClientResponse.HttpClientResponse
const client = base.pipe( HttpClient.catchTags({ HttpClientError: (_error) => Effect.succeed(fallback) }))Redirects & cookies
Section titled “Redirects & cookies”followRedirects
Section titled “followRedirects”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)withCookiesRef
Section titled “withCookiesRef”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})Rate limiting
Section titled “Rate limiting”withRateLimiter
Section titled “withRateLimiter”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— theRateLimiterservice that gates requests.window—Duration.Inputfor 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(default1).disableResponseInspection— whentrue, skip reading limits andretry-afterfrom response headers.
import { Duration } from "effect"import { HttpClient } from "effect/unstable/http"import { RateLimiter } from "effect/unstable/persistence/RateLimiter"
declare const base: HttpClient.HttpClientdeclare 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>Tracing references
Section titled “Tracing references”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.
SpanNameGenerator
Section titled “SpanNameGenerator”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"TracerDisabledWhen
Section titled “TracerDisabledWhen”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)TracerPropagationEnabled
Section titled “TracerPropagationEnabled”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))Configuring fetch
Section titled “Configuring fetch”FetchHttpClient.layer
Section titled “FetchHttpClient.layer”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))FetchHttpClient.Fetch
Section titled “FetchHttpClient.Fetch”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)))FetchHttpClient.RequestInit
Section titled “FetchHttpClient.RequestInit”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.