Skip to content

Instantly share code, notes, and snippets.

@schickling
Created December 5, 2025 08:32
Show Gist options
  • Select an option

  • Save schickling/86e507a891932996066114db9e84e21b to your computer and use it in GitHub Desktop.

Select an option

Save schickling/86e507a891932996066114db9e84e21b to your computer and use it in GitHub Desktop.
Effect HttpClient usage patterns with verified examples (Bun + FetchHttpClient)

Effect HttpClient Usage Guide

Minimal verified recipe (Bun + FetchHttpClient)

  • Tested against httpbin; includes retries, base URL, schema decoding.
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
import { Effect, Schema, Schedule } from "effect"

const Args = Schema.Record({ key: Schema.String, value: Schema.String })
const GetSchema = Schema.Struct({ url: Schema.String, args: Args })
const PostSchema = Schema.Struct({
  json: Schema.Struct({ foo: Schema.String }),
  headers: Schema.Record({ key: Schema.String, value: Schema.String }),
})

const program = Effect.gen(function* () {
  const client = (yield* HttpClient.HttpClient).pipe(
    HttpClient.mapRequest(HttpClientRequest.prependUrl("https://httpbin.org")),
    HttpClient.filterStatusOk,
    HttpClient.retryTransient({ schedule: Schedule.recurs(1) }),
  )

  const getResp = yield* client
    .execute(
      HttpClientRequest.get("/get").pipe(
        HttpClientRequest.setUrlParams({ hello: "world" }),
      ),
    )
    .pipe(Effect.andThen(HttpClientResponse.schemaBodyJson(GetSchema)))

  const postResp = yield* client
    .execute(
      HttpClientRequest.post("/post").pipe(
        HttpClientRequest.bodyUnsafeJson({ foo: "bar" }),
        HttpClientRequest.setHeader("content-type", "application/json"),
      ),
    )
    .pipe(Effect.andThen(HttpClientResponse.schemaBodyJson(PostSchema)))

  console.log(getResp.url, getResp.args)
  console.log(postResp.json, postResp.headers["Content-Type"])
}).pipe(Effect.provide(FetchHttpClient.layer))

program.pipe(Effect.runPromise)

Core building blocks

  • Layers: FetchHttpClient.layer (Bun/browser/edge) or NodeHttpClient.layerUndici (Node). Provide once at the edge.
  • Client shaping: mapRequest(prependUrl), mapRequest(setHeaders defaults), filterStatusOk, retryTransient.
  • Requests: build via HttpClientRequest.get/post/...; mutate with setHeader, setUrlParams, bodyUnsafeJson, schemaBodyJson.
  • Responses: prefer HttpClientResponse.schemaBodyJson(schema) / schemaHeaders; use text/stream/matchStatus when needed.
  • Errors: filterStatusOk fails on non-2xx; wrap in tagged errors for domain context.

Patterns from real repos

  • Base URL + retries (tim-smart/cheffect CorsProxy):
    • HttpClient.mapRequest(HttpClientRequest.prependUrl(...))
    • HttpClient.filterStatusOk
    • HttpClient.retryTransient(Schedule.spaced(1000))
  • Caching + decode (tim-smart/receipts ExchangeRates):
    • Client with prependUrl + retry + withTracerPropagation(false)
    • Decode with HttpClientResponse.schemaBodyJson(Rates); cache with Cache.make.
  • SDK transform retries (receipts AiWorker/atoms.ts):
    • transformClient: HttpClient.retryTransient({ schedule: Schedule.spaced("1 second") })
    • Provided via FetchHttpClient.layer.
  • Generated API + cookies (tim-smart/effect-http-play src/client.ts):
    • HttpApiClient.make(Api, { baseUrl, transformClient: HttpClient.withCookiesRef(cookies) })
    • Run with NodeHttpClient.layerUndici.
  • Schema’d bodies + error translation (livestore scripts/src/shared/netlify.ts):
    • HttpClientRequest.schemaBodyJson(...) + httpClient.pipe(HttpClient.filterStatusOk).execute
    • Wrap errors in Schema.TaggedError.
  • Generated clients with transforms (livestore sync-s2/http-client-generated.ts):
    • setUrlParams, bodyUnsafeJson, matchStatus, optional transformClient to inject auth/headers.

Recommended recipes

  • Base client factory:
    const makeClient = (base: string) =>
      HttpClient.HttpClient.pipe(
        Effect.andThen(HttpClient.mapRequest(HttpClientRequest.prependUrl(base))),
        Effect.andThen(HttpClient.filterStatusOk),
        Effect.andThen(HttpClient.retryTransient({ schedule: Schedule.spaced("500 millis") })),
      )
  • Auth decorator:
    const authed = (token: string) =>
      makeClient("https://api.example.com").pipe(
        HttpClient.mapRequest(
          HttpClientRequest.setHeaders({ authorization: `Bearer ${token}` }),
        ),
      )
  • Status branching:
    const okSchema = Schema.Struct({ message: Schema.String })
    const errorSchema = Schema.Struct({ error: Schema.String })
    
    const effect = authed(token)
      .execute(HttpClientRequest.get("/hello"))
      .pipe(
        Effect.andThen(
          HttpClientResponse.matchStatus({
            200: HttpClientResponse.schemaBodyJson(okSchema),
            400: HttpClientResponse.schemaBodyJson(errorSchema),
            orElse: () => new shouldNeverHappen("unexpected status"),
          }),
        ),
      )
  • SDK hook: pass transformClient (retries, cookies, tracer propagation toggle) to any client factory that accepts it.

Observability & resilience

  • Add spans at definition (Effect.withSpan("http-call") or service-level spans).
  • Use retryTransient for transport errors; Schedule.spaced/exponential with limits.
  • Decode expected errors into Schema.TaggedError; avoid unknown/any in error channels.

Choosing the transport

  • Bun/browser/edge: FetchHttpClient.layer (works in Bun; used in the verified snippet).
  • Node: NodeHttpClient.layerUndici (use in pure Node runtimes).
  • Provide exactly one HTTP client layer in the environment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment