A real HTTP client: retries, cancellation, streams, and pipelines
TL;DR
brass-httpis not afetchwrapper.
It’s an effectful HTTP client built on top of a runtime with fibers, structured concurrency, and cancellation.
In the previous parts, we built the foundations of an effect runtime inspired by ZIO:
- fibers and scheduling
- structured concurrency
- integration with the JavaScript ecosystem
- a ZIO-style HTTP client with real DX
In this fourth part, I want to show what changes once HTTP is modeled as an effect — and why features like retry, cancellation, streaming, and pipelines stop being hacks and become runtime properties.
📦 Repository:
👉 https://github.com/BaldrVivaldelli/brass-runtime
⚠️ Important mental shift
HTTP is not an async function.
HTTP is a description of work.
In Brass:
- nothing executes until interpreted
- retries are scheduled, not looped
- cancellation is structural, not best-effort
Effect vs Promise
Promise-based HTTP
fetch()
└─▶ starts immediately
└─▶ cannot be stopped
└─▶ side-effects escape
Effect-based HTTP (Brass)
HTTP Effect
│
│ (pure description)
▼
Interpreter (toPromise / Fiber / Stream)
│
▼
Runtime Scheduler
│
▼
Execution
Callout
Effects delay execution.
Promises don’t.
Creating an effectful HTTP client
const http = httpClient({
baseUrl: "https://jsonplaceholder.typicode.com",
}).withRetry({
maxRetries: 3,
baseDelayMs: 200,
maxDelayMs: 2000,
});
🚫 No request has been executed yet.
✅ You’ve only described:
- how requests are built
- how retries behave
- how errors are classified
- how everything integrates with the runtime scheduler
▶️ Running effects: toPromise
const effect = http.getJson<Post>("/posts/1");
const promise = toPromise(effect, {});
const result = await promise;
Callout
getJsondoes not return a Promise.
It returns an Effect<Env, Error, Response<Post>>.
toPromise is just one interpreter.
🔁 Retry as a first-class concept
const http = httpClient({ baseUrl }).withRetry({
maxRetries: 3,
retryOnStatus: (s) => s >= 500,
retryOnError: (e) => e._tag === "FetchError",
});
This is not a wrapper.
Retry:
- is not a
whileloop - is not
try/catch + setTimeout - is not promise recursion
💡 Retry is part of the effect description.
Retry lifecycle
HTTP Effect
│
▼
Attempt #1 ──┐
│ │
│ error │ schedule
▼ │ delay
Retry Policy │ (scheduler)
│ │
▼ │
Attempt #2 ──┘
│
▼
Attempt #N
│
▼
Success / Failure
Callout
Retry is scheduled, not looped.
🧵 Cancellation that actually works
Canceling an effect cancels everything.
Thanks to fibers and structured concurrency, canceling a single fiber propagates through the entire HTTP lifecycle.
Fiber
│
├─▶ HTTP Effect
│ ├─▶ fetch
│ ├─▶ retry delay
│ └─▶ response decode
│
└─▶ other effects
Cancel Fiber
│
▼
╳ fetch aborted
╳ retry timers cleared
╳ decode stopped
Callout
Cancellation is structural, not best-effort.
📦 Raw wire responses (full control)
const wire = await toPromise(http.get("/posts/1"), {});
console.log(wire.status);
console.log(wire.bodyText);
Callout
Even raw wire responses are retryable, cancelable, and scheduled.
🧩 Requests are data (optics FTW)
const req = mergeHeaders({ accept: "application/json" })(
setHeaderIfMissing("content-type", "application/json")({
method: "POST",
url: "/posts",
body: JSON.stringify({
userId: 1,
title: "Hello Brass",
body: "Testing POST from Brass HTTP client",
}),
})
);
const response = await http.request(req).toPromise({});
Base Request
│
▼
mergeHeaders
│
▼
setHeaderIfMissing
│
▼
Final Request
│
▼
http.request(effect)
Callout
Requests are values, not actions.
🔗 Pipelines: HTTP as a flow of effects
Request
│
▼
[ Enrich ]
│
▼
[ Retry Policy ]
│
▼
[ Fetch ]
│
▼
[ Decode ]
│
▼
Response / Wire
🌊 Streaming (design-ready)
Producer (HTTP Body)
│
▼
Stream Effect
│
├─▶ Consumer A
│
└─▶ Consumer B
Cancel
│
▼
Stream stops
Callout
Streams are effects, not callbacks.
🤔 Why does this matter?
Promises:
async + hope
Effects:
describe → schedule → execute → control
Because once HTTP is an effect:
- retry stops being fragile
- cancellation becomes predictable
- testing becomes trivial
- composition becomes natural
🚀 What’s next
- full streaming APIs
- timeouts as effects
- tracing
- metrics
- resource scopes
Top comments (0)