Skip to content

Handling Responses

A HttpClientResponse carries the status, headers, and a lazily-read body. You can read the body in several shapes — raw text, JSON, bytes, or a stream — but the idiomatic path is to decode it through a Schema, so the value you work with is fully typed and validated. Decoding helpers live on HttpClientResponse, and they are designed to be used with Effect.flatMap right after the request.

import { Effect, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.String,
email: Schema.String
}) {}
const getUser = Effect.fn("getUser")(function*(id: number) {
const client = yield* HttpClient.HttpClient
return yield* client.get(`https://api.example.com/users/${id}`).pipe(
// schemaBodyJson reads the JSON body and decodes it with the schema.
// It fails with HttpClientError (read/empty body) or Schema.SchemaError
// (the body did not match the shape).
Effect.flatMap(HttpClientResponse.schemaBodyJson(User))
)
})

When you do not need a schema, read the body through the accessors on the response. Each one is an Effect, because reading can fail:

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
const raw = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const response = yield* client.get("https://api.example.com/health")
// response.text : Effect<string, HttpClientError>
// response.json : Effect<Schema.Json, HttpClientError> (parsed JSON)
// response.urlParamsBody: Effect<UrlParams, HttpClientError>
// response.arrayBuffer : Effect<ArrayBuffer, HttpClientError>
// response.stream : Stream<Uint8Array, HttpClientError>
const text = yield* response.text
return { status: response.status, headers: response.headers, text }
})

The response also exposes status, headers, cookies, remoteAddress, and the original request synchronously, plus a formData effect for multipart/form-data responses.

HelperReadsDecodes
schemaBodyJson(schema)JSON bodythe body against schema
schemaBodyUrlParams(schema)URL-encoded bodythe params against schema
schemaJson(schema)JSON body{ status, headers, body } together
schemaNoBody(schema)nothing{ status, headers } only
schemaHeaders(schema)headersthe headers against schema

schemaJson is handy when the contract spans more than the body — for example when a header or the status code is part of the decoded value:

import { Effect, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
// Decode the status, a header, and the body in one pass.
const Paged = Schema.Struct({
status: Schema.Number,
headers: Schema.Struct({
"x-total-count": Schema.String
}),
body: Schema.Array(Schema.String)
})
const listTags = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
return yield* client.get("https://api.example.com/tags").pipe(
Effect.flatMap(HttpClientResponse.schemaJson(Paged))
)
})

Sometimes the response shape depends on the status code — a 404 means “absent” rather than “error”, or a 2xx and a 4xx decode to different schemas. HttpClientResponse.matchStatus lets you handle exact codes and status classes ("2xx", "3xx", "4xx", "5xx") with a required orElse:

import { Effect, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.String
}) {}
const findUser = Effect.fn("findUser")(function*(id: number) {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(`https://api.example.com/users/${id}`)
return yield* response.pipe(
HttpClientResponse.matchStatus({
// 404 is an expected outcome, not a failure.
404: () => Effect.succeed(Option.none<User>()),
// Any other 2xx: decode the body.
"2xx": (ok) =>
HttpClientResponse.schemaBodyJson(User)(ok).pipe(Effect.map(Option.some)),
// Everything else is unexpected — surface the response.
orElse: (other) =>
Effect.fail(new Error(`Unexpected status ${other.status}`))
})
)
})

For large or open-ended responses, read the body as a Stream of bytes instead of buffering it. response.stream is a Stream<Uint8Array, HttpClientError>, and HttpClientResponse.stream lifts a response effect straight into a stream so you can pipe a request directly into stream processing:

import { Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
const downloadLineCount = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
// HttpClientResponse.stream takes the request effect and yields the body as a
// byte stream — nothing is buffered into memory all at once.
const bytes = HttpClientResponse.stream(
client.get("https://api.example.com/export.csv")
)
return yield* bytes.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runCount
)
})

See Streaming for the full set of stream operators.


A response extends the shared HttpIncomingMessage body/header surface and adds client-specific fields. The synchronous fields are plain properties; the body accessors are Effects (or a Stream) because reading can fail.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
const inspect = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const res = yield* client.get("https://api.example.com/health")
res.status // => 200 (number)
res.headers // => { "content-type": "application/json", ... }
res.cookies // => Cookies parsed from Set-Cookie headers
res.remoteAddress // => Option<string> (none for the Web client)
res.request // => the originating HttpClientRequest
})
FieldTypeNotes
statusnumberThe HTTP status code. Data, not an error, until a filter is applied.
headersHeadersLowercase, single-value map from the Headers module.
cookiesCookiesParsed lazily from Set-Cookie headers and cached.
remoteAddressOption<string>Peer address when the platform exposes it; Option.none() for the Web client.
requestHttpClientRequestThe request that produced this response.
formDataEffect<FormData, HttpClientError>Reads a multipart/form-data body.

The body accessors inherited from HttpIncomingMessage:

Reads the body as a UTF-8 string. Cached, and populates arrayBuffer.

declare const res: import("effect/unstable/http/HttpClientResponse").HttpClientResponse
res.text // => Effect<string, HttpClientError>

Reads the body as text, then JSON.parses it; an empty body parses to null.

declare const res: import("effect/unstable/http/HttpClientResponse").HttpClientResponse
res.json // => Effect<Schema.Json, HttpClientError> (e.g. { ok: true })

Parses a URL-encoded (application/x-www-form-urlencoded) body into UrlParams.

declare const res: import("effect/unstable/http/HttpClientResponse").HttpClientResponse
res.urlParamsBody // => Effect<UrlParams, HttpClientError> e.g. [["a", "1"]]

Reads the raw body bytes. Cached, and populates text.

declare const res: import("effect/unstable/http/HttpClientResponse").HttpClientResponse
res.arrayBuffer // => Effect<ArrayBuffer, HttpClientError>

The body as a byte stream. Fails with EmptyBodyError when there is no body.

declare const res: import("effect/unstable/http/HttpClientResponse").HttpClientResponse
res.stream // => Stream<Uint8Array, HttpClientError>

Reads a multipart/form-data body into a Web FormData.

declare const res: import("effect/unstable/http/HttpClientResponse").HttpClientResponse
res.formData // => Effect<FormData, HttpClientError>

Each decoder is a function (schema) => (response) => Effect. They read part of the response and decode it, failing with HttpClientError (when the body cannot be read) or Schema.SchemaError (when the value does not match the schema).

Reads the JSON body and decodes it with the schema. The most common decoder.

import { Effect, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String })
Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const todo = yield* client.get("https://api.example.com/todos/1").pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
)
// todo => { id: 1, title: "Wash the car" }
})

Reads a URL-encoded body and decodes the parameters with the schema.

import { Schema } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
const Form = Schema.Struct({ token: Schema.String, ttl: Schema.NumberFromString })
declare const res: HttpClientResponse.HttpClientResponse
HttpClientResponse.schemaBodyUrlParams(Form)(res)
// body "token=abc&ttl=60" => { token: "abc", ttl: 60 }

Decodes the status, headers, and JSON body together as { status, headers, body }. Use when the contract spans more than the body.

import { Schema } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
const Envelope = Schema.Struct({
status: Schema.Number,
headers: Schema.Struct({ "x-request-id": Schema.String }),
body: Schema.Struct({ ok: Schema.Boolean })
})
declare const res: HttpClientResponse.HttpClientResponse
HttpClientResponse.schemaJson(Envelope)(res)
// => { status: 200, headers: { "x-request-id": "..." }, body: { ok: true } }

Decodes only { status, headers } without reading a body — useful for 204 responses or when only headers matter. Fails only with Schema.SchemaError.

import { Schema } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
const Meta = Schema.Struct({
status: Schema.Number,
headers: Schema.Struct({ location: Schema.String })
})
declare const res: HttpClientResponse.HttpClientResponse
HttpClientResponse.schemaNoBody(Meta)(res)
// => { status: 201, headers: { location: "/things/9" } }

Validates and decodes just the response headers with the schema.

import { Schema } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
const RateLimit = Schema.Struct({
"x-ratelimit-remaining": Schema.NumberFromString
})
declare const res: HttpClientResponse.HttpClientResponse
HttpClientResponse.schemaHeaders(RateLimit)(res)
// headers { "x-ratelimit-remaining": "42" } => { "x-ratelimit-remaining": 42 }

By default a non-2xx status is just data — the response succeeds. These helpers turn the status into success/failure or branch on it.

Pattern matches on the status, checking an exact code first, then the status class ("2xx", "3xx", "4xx", "5xx"), then the required orElse. See Branching on status above for a full example.

import { Effect } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
declare const res: HttpClientResponse.HttpClientResponse
HttpClientResponse.matchStatus(res, {
204: () => Effect.succeed("no content"),
"2xx": (ok) => ok.text,
orElse: (other) => Effect.fail(new Error(`status ${other.status}`))
})

Succeeds with the response when the status satisfies the predicate, otherwise fails with an HttpClientError whose reason is a StatusCodeError.

import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
yield* client.get("https://api.example.com/x").pipe(
Effect.flatMap(HttpClientResponse.filterStatus((status) => status < 400))
// status 500 => fails with HttpClientError(StatusCodeError)
)
})

Shorthand for “only accept 2xx”. Fails with StatusCodeError for any other status.

import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
yield* client.get("https://api.example.com/x").pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk)
// status 404 => fails with HttpClientError(StatusCodeError)
)
})

Wraps a platform Web Response and its originating HttpClientRequest into an HttpClientResponse. Useful in adapters and tests.

import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
const request = HttpClientRequest.get("https://api.example.com/ping")
const webResponse = new Response("pong", { status: 200 })
const res = HttpClientResponse.fromWeb(request, webResponse)
// res.status => 200 ; yield* res.text => "pong"

Converts an Effect<HttpClientResponse> directly into a byte stream, failing the stream if the request fails or the body is empty. See Streaming the body above.

import { HttpClient, HttpClientResponse } from "effect/unstable/http"
declare const client: HttpClient.HttpClient
HttpClientResponse.stream(client.get("https://api.example.com/big.bin"))
// => Stream<Uint8Array, HttpClientError>

All client failures are wrapped in a single HttpClientError from effect/unstable/http/HttpClientError. The wrapper carries the originating request and a reason describing exactly what went wrong; match on error.reason._tag to recover differently per failure.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
yield* client.get("https://api.example.com/x")
}).pipe(
Effect.catchTag("HttpClientError", (error) => {
error.request // => the failed HttpClientRequest
error.response // => HttpClientResponse | undefined (only for response errors)
switch (error.reason._tag) {
case "TransportError":
return Effect.logError("network failure")
case "StatusCodeError":
return Effect.logError(`bad status ${error.reason.response.status}`)
default:
return Effect.die(error)
}
})
)

The reasons split into two families. Request errors happen before a response exists (response is undefined); response errors carry the response that triggered them.

The single tagged error (_tag: "HttpClientError") wrapping every client failure. Exposes reason, plus request and response getters that delegate to the reason. Use isHttpClientError at untyped boundaries.

import { HttpClientError } from "effect/unstable/http"
declare const value: unknown
HttpClientError.isHttpClientError(value) // => boolean

Transport-level failure while sending the request (DNS, connection reset, etc.). No response.

// error.reason._tag === "TransportError"
// error.reason.request, error.reason.cause

Failure while encoding the request body before sending. No response.

// error.reason._tag === "EncodeError"

The request URL could not be constructed. No response.

// error.reason._tag === "InvalidUrlError"

A status filter (filterStatus / filterStatusOk) rejected the response’s status. Carries the response.

// error.reason._tag === "StatusCodeError"
// error.reason.response.status => 500

Reading or decoding the response body failed (bad JSON, stream read error, schema mismatch upstream). Carries the response.

// error.reason._tag === "DecodeError"

An operation expected a body but the response had none (e.g. streaming an empty body). Carries the response.

// error.reason._tag === "EmptyBodyError"

The module also exports the reason unions RequestError (TransportError | EncodeError | InvalidUrlError), ResponseError (StatusCodeError | DecodeError | EmptyBodyError), and HttpClientErrorReason (both combined), along with HttpClientErrorSchema for serializing a client error across a boundary.

Once you can read responses, the next step is making the client resilient — retries, timeouts, and rate limiting — covered in Resilience.