Resilience
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 errors, timeouts, and retryable status
codes like 429/503 — 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._tag === "HttpClientError" }))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")))Request and response middleware
Section titled “Request and response middleware”The transforms that build a client all return a new client, so you can layer cross-cutting behavior:
HttpClient.mapRequest/mapRequestEffect— rewrite every outgoing request (base URL, default headers, auth).HttpClient.transformResponse/transform— wrap the response effect (timeouts, retries, fallbacks).HttpClient.tapRequest/tap/tapError— observe requests, responses, or failures without changing them (logging, metrics).HttpClient.followRedirects— transparently follow 3xx responses.HttpClient.withCookiesRef— persist and resend cookies across requests.
import { Effect } from "effect"import { HttpClient, HttpClientRequest } from "effect/unstable/http"
declare const base: HttpClient.HttpClient// An effect that produces a fresh token — e.g. a refreshed OAuth credential.declare const loadToken: Effect.Effect<string>
const client = base.pipe( // Attach a dynamic auth header for every request (mapRequestEffect because // computing the token is itself an effect). HttpClient.mapRequestEffect((request) => Effect.map(loadToken, (token) => HttpClientRequest.bearerToken(request, token) ) ), // Log every request before it is sent. HttpClient.tapRequest((request) => Effect.log(`${request.method} ${request.url}`) ), // Log failures with their full structured cause. HttpClient.tapError((error) => Effect.logError("HTTP request failed", error)), // Follow up to 5 redirects automatically. HttpClient.followRedirects(5))Because these are all just functions over a HttpClient, you can also catch and
recover at the client level with HttpClient.catchTag / catchTags, mirroring
the error-management combinators on Effect but scoped to
responses.
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 limits 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" })))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.