10·02HTTP Client Patterns

A `fetch` call returns an `unknown` body. Most codebases cast it (`as User`) and move on, until the API drops a field and the crash surfaces three layers deep in business logic.

57 - HTTP Client Patterns: Decode at the Boundary

Previous · Next

A fetch call returns an unknown body. Most codebases cast it (as User) and move on, until the API drops a field and the crash surfaces three layers deep in business logic.

The fix is to decode and classify every response at the boundary, before any domain code sees it. Effect’s @effect/platform ships an HttpClient service that makes this pattern routine: typed errors at every gate, Schema for validated decoding, and a default implementation (FetchHttpClient.layer) that is mockable in tests.

What you’ll work through

index.test.ts contains four beats: decoding a body with Schema at the boundary, branching on status code into typed errors, building a reusable client wrapped behind a service, and validating responses with Schema so domain code never receives unknown.

By the end you should treat every HTTP response as unknown until a schema decodes it — as User should look as suspicious as as any.


Four Gates

HttpClient.execute
│
▼
┌─── Gate 1: transport ────────► RequestError
│
▼
┌─── Gate 2: status code ──────► 4xx / 5xx → typed domain error
│
▼
┌─── Gate 3: schema decode ────► ParseError
│
▼
Validated domain value

Every gate that fails surfaces a typed error in the E channel; nothing reaches the caller as unknown.


The HttpClient Service

Configures the Context.Service exported from @effect/platform/HttpClient. The service exposes get, post, execute, plus combinators such as mapRequest, filterStatus, and retryTransient.

import { FetchHttpClient, HttpClient, HttpClientResponse } from "@effect/platform"
import { Effect, Schema } from "effect"
const User = Schema.Struct({
id: Schema.String,
name: Schema.String,
})
type User = typeof User.Type
// ┌─── Effect<User, HttpClientError | ParseError, HttpClient>
// ▼
const getUser = (id: string) =>
HttpClient.get(`https://api.example.com/users/${id}`).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(User)),
)
const runnable = getUser("user-1").pipe(Effect.provide(FetchHttpClient.layer))

Details

HttpClientResponse.schemaBodyJson does two jobs in one step: reads the body as JSON, then validates it against the Schema. The output type is User; any deviation lands as a ParseError in the E channel.

FetchHttpClient.layer wires the platform’s fetch-backed implementation. Swap it for a stub Layer in tests; no code that uses HttpClient.HttpClient needs to change.


Status-Code Branching

Use HttpClient.filterStatus (or the more specific filterStatusOk) to convert HTTP status codes into typed failures.

import { HttpClient, HttpClientResponse } from "@effect/platform"
import { Data, Effect } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: string
}> {}
const getUser = (id: string) =>
HttpClient.get(`/users/${id}`).pipe(
HttpClient.filterStatus((status) => status !== 404),
Effect.catchTag("ResponseError", (error) =>
error.response.status === 404
? Effect.fail(new UserNotFound({ id }))
: Effect.fail(error),
),
Effect.flatMap(HttpClientResponse.schemaBodyJson(User)),
)

When to Use

When different status codes map to different domain meanings, 404 is NotFound, 409 is Conflict, 5xx is transient. Each meaning deserves its own tagged error so the caller can catchTag precisely.


Reusable Clients with mapRequest

Configures a per-base-URL or per-auth client by composing combinators onto the default service. The Effect repo’s own tests use this pattern.

import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"
import { Context, Effect, Layer } from "effect"
class ApiClient extends Context.Service<ApiClient, HttpClient.HttpClient>()("ApiClient") {}
const ApiClientLive = Layer.effect(
ApiClient,
Effect.gen(function* () {
const base = yield* HttpClient.HttpClient
return base.pipe(
HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com")),
HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", "Bearer …")),
)
}),
).pipe(Layer.provide(FetchHttpClient.layer))

Details

mapRequest is the only place URL prefixes and auth headers should live. Callers ask for ApiClient and get a pre-wired HttpClient, they cannot accidentally forget the auth header.


Schema-validated responses

HttpClient.HttpClient yields a Response whose body is unknown. Schema.decodeUnknownSync (or Schema.decodeUnknownEffect for an Effect-returning variant) validates the body against a domain schema.

A ParseError from the decoder lands in the E channel as a tagged InvalidUserResponse so the caller sees decoding failures distinctly from network failures.

import { HttpClient, HttpClientResponse } from "@effect/platform"
import { Effect, Schema, Data } from "effect"
class InvalidUserResponse extends Data.TaggedError("InvalidUserResponse")<{
readonly cause: unknown
}> {}
const UserSchema = Schema.Struct({
id: Schema.String,
name: Schema.String,
})
const fetchUser = (id: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(`/users/${id}`)
const json = yield* HttpClientResponse.schemaBodyJson(UserSchema)(response).pipe(
Effect.mapError((cause) => new InvalidUserResponse({ cause })),
)
return json
})

The E channel now carries network errors and InvalidUserResponse. Each is a distinct tag, so catchTag recovers them independently — a decoding mismatch is not the same problem as a 500.

Your turn decode the response body with a `Schema.Struct` and map any `ParseError` to a tagged `InvalidUserResponse` so the caller sees a typed domain error, not a raw parse failure.


Retrying Transient Failures

Use HttpClient.retryTransient for the common case of retrying network errors and 5xx responses with backoff.

import { HttpClient } from "@effect/platform"
import { Schedule } from "effect"
const resilient = HttpClient.retryTransient({
times: 3,
schedule: Schedule.exponential("100 millis"),
})
const getUser = (id: string) =>
HttpClient.get(`/users/${id}`).pipe(resilient)

Details

retryTransient retries only on transport errors and 5xx. A 404 or 400 is permanent and is never retried, the schedule does not apply.

Combine it with the status-branching pattern above when both behaviours are needed.

Your turn wrap a flaky request with `HttpClient.retryTransient` (exponential backoff, three attempts) and confirm a `5xx` retries while a `404` falls through unchanged.

Posting JSON

HttpClientRequest.schemaBodyJson encodes a domain value through a Schema before sending it. Encoding errors surface in the type just like decoding errors do.

import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
import { Effect, Schema } from "effect"
const NewUser = Schema.Struct({ name: Schema.String })
const User = Schema.Struct({ id: Schema.String, name: Schema.String })
const createUser = (input: typeof NewUser.Type) =>
HttpClientRequest.post("/users").pipe(
HttpClientRequest.schemaBodyJson(NewUser)(input),
Effect.flatMap(HttpClient.execute),
Effect.flatMap(HttpClientResponse.schemaBodyJson(User)),
)

A Composite Example

Putting it together, a typed Users service that classifies 404s, validates the body, and retries 5xx.

import { FetchHttpClient, HttpClient, HttpClientResponse } from "@effect/platform"
import { Context, Data, Effect, Layer, Schedule, Schema } from "effect"
const User = Schema.Struct({ id: Schema.String, name: Schema.String })
type User = typeof User.Type
class UserNotFound extends Data.TaggedError("UserNotFound")<{ readonly id: string }> {}
class Users extends Context.Service<Users, { readonly find: (id: string) => Effect.Effect<User, UserNotFound | HttpClient.HttpClientError> }>()("Users") {}
const UsersLive = Layer.effect(
Users,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
return {
find: (id) =>
http.get(`/users/${id}`).pipe(
HttpClient.retryTransient({ times: 3, schedule: Schedule.exponential("100 millis") }),
Effect.catchTag("ResponseError", (error) =>
error.response.status === 404
? Effect.fail(new UserNotFound({ id }))
: Effect.fail(error),
),
Effect.flatMap(HttpClientResponse.schemaBodyJson(User)),
),
}
}),
).pipe(Layer.provide(FetchHttpClient.layer))

The E channel of Users.find is UserNotFound | HttpClientError | ParseError. Each case has its own meaning and its own recovery path.


Common Pitfalls

1, Decoding before checking status. Decoding a 404 body as User produces a ParseError, masking the actual NotFound case. Filter or branch on status first, then decode.

2, One catch-all HttpError. Folding every failure into one tag throws away the information the type system just gave you. Tag NotFound, Unauthorized, Conflict, and Transient separately.

3, Retrying everything. A 400 will never become a 200. Use retryTransient or Schedule.recurWhile with a predicate that excludes permanent failures.

4, Casting the response body. as User is the entire problem this step is here to solve. Use HttpClientResponse.schemaBodyJson(User) or Schema.decodeUnknownEffect(User).


stubbed. - [ ] Decode a response body with `Schema` at the boundary. - [ ] Branch on status code into typed errors — 4xx, 5xx, network failure. - [ ] Wrap the client behind a service so business code never sees `fetch`. - [ ] Validate the response with `Schema` so domain code never receives `unknown`.

See Also

  • Previous, 54-incremental-adoption: Effect.tryPromise for non-HTTP Promises.
  • Next, 58-observability: spans and structured logs around HTTP calls.
  • Reference, repos/effect/packages/platform/src/HttpClient.ts and repos/effect/packages/platform/test/HttpClient.test.ts.

Run the tests: pnpm test 57-http-client-patterns


The full test suite

The index.test.ts for this lesson, verbatim from steps/57-http-client-patterns/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
import { Context, Data, Effect, Schedule, Schema } from "effect"
// =============================================================================
// 31 - HTTP Client Patterns: Decode at the Boundary
// =============================================================================
//
// The README uses @effect/platform's HttpClient. Here we model the same
// four-gate flow against a minimal HttpClient tag so the test runs with
// effect alone: transport → status → schema → domain value.
class HttpFailure extends Data.TaggedError("HttpFailure")<{
readonly reason: string
}> {}
class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: string
}> {}
class InvalidUserResponse extends Data.TaggedError("InvalidUserResponse")<{
readonly cause: unknown
}> {}
const User = Schema.Struct({
id: Schema.String,
name: Schema.String,
})
type User = typeof User.Type
type Response = {
readonly status: number
readonly body: unknown
}
class HttpClient extends Context.Service<HttpClient, {
readonly get: (url: string) => Effect.Effect<Response, HttpFailure>
}>()("HttpClient") {}
// ┌─── Effect<User, HttpFailure | UserNotFound | InvalidUserResponse, HttpClient>
// ▼
const getUser = (id: string) =>
Effect.gen(function* () {
const http = yield* HttpClient
const response = yield* http.get(`/users/${id}`)
if (response.status === 404) {
return yield* Effect.fail(new UserNotFound({ id }))
}
if (response.status >= 400) {
return yield* Effect.fail(
new HttpFailure({ reason: `unexpected status ${response.status}` }),
)
}
return yield* Effect.try({
try: () => Schema.decodeUnknownSync(User)(response.body),
catch: (cause) => new InvalidUserResponse({ cause }),
})
})
describe("31 - HTTP Client Patterns", () => {
// @beat
// id: decode-body
// shape: one-todo
// title: Decode the body at the boundary
// teaches: HttpClient yields a Response; schema decoding turns its unknown body into a typed domain value.
// hint: the schema is identity-shaped — a matching 200 body returns the same fields
it("decodes a 200 body into a domain value", async () => {
const user = await Effect.runPromise(
getUser("1").pipe(
Effect.provideService(HttpClient, {
get: () => Effect.succeed({ status: 200, body: { id: "1", name: "Ada" } }),
}),
),
)
// Predict the decoded domain value the pipeline returns.
expect(user).toEqual(/* @stub */ { id: "1", name: "Ada" } /* @end */)
})
// @end-beat
// @beat
// id: status-branching
// shape: one-todo
// title: Branch on status before decoding
// teaches: A 404 becomes a UserNotFound tag before any schema runs, keeping decoding errors distinct from missing resources.
// hint: which tagged error in this file represents a missing resource? that's the tag a 404 should surface
it("translates a 404 into a tagged domain error", async () => {
const exit = await Effect.runPromiseExit(
getUser("missing").pipe(
Effect.provideService(HttpClient, {
get: () => Effect.succeed({ status: 404, body: { message: "missing" } }),
}),
),
)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
// Predict the domain tag a 404 surfaces in E.
expect(exit.cause.reasons[0].error._tag).toBe(/* @stub */ "UserNotFound" /* @end */)
}
})
// @end-beat
// @beat
// id: schema-validation
// shape: one-todo
// title: Reject wire shapes that violate the schema
// teaches: Schema.decodeUnknownSync surfaces a ParseError that lands in E as InvalidUserResponse instead of crashing downstream.
// hint: schema decode failures get caught and rethrown as which tagged error declared above?
it("fails fast when the wire format does not match the schema", async () => {
const exit = await Effect.runPromiseExit(
getUser("1").pipe(
Effect.provideService(HttpClient, {
get: () => Effect.succeed({ status: 200, body: { id: 1, wrong: true } }),
}),
),
)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
// Predict the tag the schema-decode catch handler emits.
expect(exit.cause.reasons[0].error._tag).toBe(/* @stub */ "InvalidUserResponse" /* @end */)
}
})
// @end-beat
// @beat
// id: add-retry
// shape: one-todo
// title: Add a retry schedule to a flaky HTTP request
// teaches: Effect.retry wraps any Effect and re-runs it on failure according to a Schedule; the E channel is unchanged — retries are transparent to callers.
// hint: program.pipe(Effect.retry(Schedule.recurs(2))) — retries up to 2 times on any failure
it("retries a flaky request up to N times before failing", async () => {
let attempts = 0
const flakyClient = {
get: (_url: string): Effect.Effect<Response, HttpFailure> => {
attempts++
if (attempts < 3) {
return Effect.fail(new HttpFailure({ reason: "transient" }))
}
return Effect.succeed({ status: 200, body: { id: "1", name: "Ada" } })
},
}
const program = /* @stub */ getUser("1").pipe(
Effect.provideService(HttpClient, flakyClient),
Effect.retry(Schedule.recurs(2)),
) /* @end */
const user = await Effect.runPromise(program)
expect(user.name).toBe("Ada")
expect(attempts).toBe(3)
})
// @end-beat
})
← PreviousRedactedNext →Observability