DEV Community

Augusto Vivaldelli
Augusto Vivaldelli

Posted on

Brass TS — Building an Effect Runtime in TypeScript (Part 4)

A real HTTP client: retries, cancellation, streams, and pipelines

TL;DR
brass-http is not a fetch wrapper.
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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

🚫 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;
Enter fullscreen mode Exit fullscreen mode

Callout
getJson does 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",
});
Enter fullscreen mode Exit fullscreen mode

This is not a wrapper.

Retry:

  • is not a while loop
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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({});
Enter fullscreen mode Exit fullscreen mode
Base Request
   │
   ▼
mergeHeaders
   │
   ▼
setHeaderIfMissing
   │
   ▼
Final Request
   │
   ▼
http.request(effect)
Enter fullscreen mode Exit fullscreen mode

Callout
Requests are values, not actions.


🔗 Pipelines: HTTP as a flow of effects

Request
  │
  ▼
[ Enrich ]
  │
  ▼
[ Retry Policy ]
  │
  ▼
[ Fetch ]
  │
  ▼
[ Decode ]
  │
  ▼
Response / Wire
Enter fullscreen mode Exit fullscreen mode

🌊 Streaming (design-ready)

Producer (HTTP Body)
   │
   ▼
Stream Effect
   │
   ├─▶ Consumer A
   │
   └─▶ Consumer B

Cancel
  │
  ▼
Stream stops
Enter fullscreen mode Exit fullscreen mode

Callout
Streams are effects, not callbacks.


🤔 Why does this matter?

Promises:
  async + hope

Effects:
  describe → schedule → execute → control
Enter fullscreen mode Exit fullscreen mode

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)