Skip to content

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 }
) {}

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 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"
})
)

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"))
)

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.

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.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" }))
)

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.