I’ve been building http-tower-hs, a composable HTTP client middleware library for Haskell, inspired by Rust’s Tower.
The problem
Haskell has solid HTTP clients (http-client, http-client-tls), but no middleware composition story. Every project ends up hand-rolling retry logic, timeout handling, logging, and circuit breakers around raw HTTP calls. Mercury’s recent blog post “A Couple Million Lines of Haskell” described this exact gap — they write all their own HTTP client bindings because there’s no way to inject cross-cutting concerns like tracing, retries, or circuit breakers.
The approach
Two core types:
newtype Service req res = Service
{ runService :: req -> IO (Either ServiceError res) }
type Middleware req res = Service req res -> Service req res
A Service is a function from request to response. Middleware wraps a service to add behavior. You compose them with the |> operator:
client <- newClient
let configured = client
|> withBearerAuth "my-token"
|> withRequestId
|> withRetry (exponentialBackoff 3 0.5 2.0)
|> withTimeout 5000
|> withCircuitBreaker config breaker
|> withValidateStatus (\c -> c >= 200 && c < 300)
|> withTracing
result <- runRequest configured request
case result of
Left err -> handleError err
Right resp -> handleSuccess resp
All errors are Either ServiceError Response — no exceptions escape the stack.
Middleware included (12 so far)
Middleware
Description
Retry
Constant or exponential backoff
Timeout
Per-request millisecond timeouts
Logging
Pluggable request/response logging
Circuit Breaker
Three-state (Closed/Open/HalfOpen) via STM
OpenTelemetry Tracing
Automatic spans with stable HTTP semantic conventions
Simple function composition over type-level lists for v1 — middleware is just Service -> Service
Either errors, not exceptions — ServiceError covers HTTP errors, timeouts, retry exhaustion, circuit breaker open
OpenTelemetry via hs-opentelemetry-api — no-ops when no TracerProvider is configured, zero overhead for users who don’t use tracing
Generic where possible — Filter, Hedge, Retry, Timeout work with any req/res, not just HTTP
Testing
69 tests including property-based tests (QuickCheck) and a Jaeger Docker integration test via testcontainers-haskell that verifies real OTLP span export end-to-end.
Status
This is an early release (0.1.0.0). I’m working on getting it published on Hackage. The source is at github.com/jarlah/http-tower-hs.
I’d appreciate feedback on the API design, especially:
Is Service req res the right core abstraction, or should it be more constrained?
Should middleware ordering be enforced at the type level in a future version?
CI runs against GHC 9.6.6, 9.8.4, and 9.10.1 (both cabal and stack), so it should work across recent compiler versions. Workflow here if you’re curious: .github/workflows/ci.yml.
Still very much open to feedback on the API design.
Also wanted to share a real-world usage example: sentinel, a small infrastructure health monitor built on top of http-tower-hs.
Each probe builds its own middleware stack from config (timeout, retry, circuit breaker, redirect following, header injection, OpenTelemetry tracing), only enabling what’s configured. The README has a full middleware ordering sectionshowing how the code maps to the YAML config.
It was a useful real-world test of the API. If anything felt awkward, it showed up quickly.
Ill propably convert the library to multiple repos. For ex tower-hs, http-tower-hs and servant-tower-hs. This is also not far from how tower does it in rust.
working on it. Trying to see if it makes sense. I use an adapter approach now. Where http-tower-hs holds the main implementation and servant-tower-hs is just a clean and simple adapter. The question begs, will this work for other libraries as well? is it generic enough? with the current change I’m also able to do generic implementation like this (which sort of answers my question):
import Tower
-- Wrap a database query as a Service
let dbService :: Service SQL.Query [SQL.Row]
dbService = Service $ \query -> do
result <- try $ SQL.query conn query
pure $ case result of
Left err -> Left (TransportError err)
Right rows -> Right rows
-- Add resilience with the same middleware you'd use for HTTP
breaker <- newCircuitBreaker
let config = CircuitBreakerConfig { cbFailureThreshold = 5, cbCooldownPeriod = 30 }
robust = withRetry (exponentialBackoff 3 0.5 2.0)
. withTimeout 5000
. withCircuitBreaker config breaker
$ dbService
result <- runService robust "SELECT * FROM users"
which sort of should give the “aha” towards whether this is tied into http directly. The answer is no.
EDIT: working on it now. Making it a mono repo and trying to make as much of the middlewares as generic if possible.
Following up on the monorepo refactoring I mentioned, the library is now published as three separate packages on Hackage:
tower-hs — The generic Service/Middleware core. Protocol-agnostic middleware (retry, timeout, circuit breaker, hedge, filter,
tracing, logging, etc.). Not tied to HTTP at all, works with any req → IO (Either ServiceError res) service.
http-tower-hs — HTTP client middleware built on tower-hs (bearer auth, headers, redirects, request IDs, status validation,
OTel tracing).
servant-tower-hs — Servant ClientMiddleware adapter, so you can use the full tower-hs middleware stack with Servant clients,
plus servant-specific middleware.
The idea is that tower-hs is a standalone foundation you can use to wrap anything, a database client, a gRPC stub, a message queue, with the same resilience patterns. The HTTP and Servant packages layer on top with transport-specific concerns.
You can depend on just the packages you need, e.g. only tower-hs if you’re wrapping non-HTTP services.
Very interesting. I’ll keep an eye on this as a way to factor out common patterns from service bindings. It’s frustrating that many service bindings implement retries/logging/etc in an ad-hoc way and a good common idiom for that would help.
By the way, (&) is the conventional name for the (|>) operator. I’ve written an earlier explanation about why, but it came first and has much better consistency with the rest of the visual language of Haskell operators.
Cool. Thanks. I will definitely add in (&). I could even keep (|>) or phase it out.
Personally i have no strong preferences. Been doing some Elm and other languages that also have used the shove operator but thats the problem right there. Shove. You need a mental picture.
I don’t think the (|>) question has a good answer at this point. It does smooth the on-ramp for people coming from pretty much any other FP language. I think defining it with a custom warning group is probably the best compromise position I’ve found so far, because then people coming to Haskell from other languages will search for it by reflex, find it, and then learn what we actually call the operator. But I don’t know who can best carry that responsibility, since it is redundant to have it fall to each package implementer.
P.S.: (&) is defined in base already, in module Data.Function.
Ah thanks for the double hints. Ok so there is shove style composition already in ghc with (&)? I will try it out. Maybe change README.md and test examples to use it.
By the way, it might also be useful to give your Service type a Functor instance (can be derived automatically) and a Profunctor instance. If someone was writing a binding to a remote HTTP API, it’d provide a good way to lift a Service Http.Request Http.Response into a Service MyBinding.SomeRequest MyBinding.SomeResponse.
added implementations for them. not released yet, just playing with the concept. If you know a better way to describe the section in the readme for the changes, feel free to help me the implementations isn’t hard to add, but need to be correct, so I added test to verify the laws are followed.
Description in README.md is extremely generic atm. We need to find good use cases with the library
Toying more with the idea of defining composite semi complex pipelines
Looking at it however i need someone with actual experience with this to see if there is a valid use case for it. It allows great freedom for the use of the Service. But for what ? Cant just add stuff because its nice and shiny
My own conclusion at this point is that its not adding complexity to the package. But it might not be remotely useful unless in very specific corner cases.
It only adds value, its fine. I toned down README a bit. Will release tomorrow Im happy to make the library as versatile as possible without making it solve everything at once. Still open to suggestions