http-tower-hs
Composable HTTP client middleware for Haskell, inspired by Rust's Tower.
The Haskell ecosystem has solid HTTP clients (http-client, http-client-tls) but no middleware composition story. Every project ends up hand-rolling retry logic, timeout handling, and logging around raw HTTP calls. http-tower-hs fixes this with a simple Service/Middleware abstraction.
Quick start
import Network.HTTP.Tower
import qualified Network.HTTP.Client as HTTP
main :: IO ()
main = do
client <- newClient
let configured = client
|> withBearerAuth "my-api-token"
|> withRequestId
|> withRetry (constantBackoff 3 1.0)
|> withTimeout 5000
|> withValidateStatus (\c -> c >= 200 && c < 300)
|> withTracing
req <- HTTP.parseRequest "https://api.example.com/v1/users"
result <- runRequest configured req
case result of
Left err -> putStrLn $ "Failed: " <> show err
Right resp -> putStrLn $ "OK: " <> show (HTTP.responseStatus resp)
Core concepts
Service
A function from request to IO (Either ServiceError response):
newtype Service req res = Service { runService :: req -> IO (Either ServiceError res) }
Middleware
A function that wraps a service to add behavior:
type Middleware req res = Service req res -> Service req res
Client
An HTTP client with a middleware stack, built using the (|>) operator:
client <- newClient
let configured = client
|> withRetry (exponentialBackoff 5 0.5 2.0)
|> withTimeout 3000
TLS / mTLS
newClient uses HTTPS by default. For custom CA certificates or client certificate authentication (mTLS):
-- Custom CA (e.g., internal PKI)
client <- newClientWithTLS (Just "certs/ca.pem") Nothing
-- mTLS (client certificate authentication)
client <- newClientWithTLS
(Just "certs/ca.pem")
(Just ("certs/client.pem", "certs/client-key.pem"))
-- System CA store, no client cert (same as newClient)
client <- newClientWithTLS Nothing Nothing
For full control, use newClientWith with custom ManagerSettings.
Middleware
Retry
Retries failed requests with configurable backoff:
-- Constant: 3 retries, 1 second between each
client |> withRetry (constantBackoff 3 1.0)
-- Exponential: 5 retries, starting at 500ms, doubling each time
client |> withRetry (exponentialBackoff 5 0.5 2.0)
Timeout
Fails with TimeoutError if the request exceeds the given milliseconds:
client |> withTimeout 5000
Logging
Logs method, host, status, and duration:
client |> withLogging (\msg -> Data.Text.IO.putStrLn msg)
Circuit Breaker
Three-state circuit breaker (Closed → Open → HalfOpen) using STM:
breaker <- newCircuitBreaker
let configured = client
|> withCircuitBreaker (CircuitBreakerConfig 5 30) breaker
Trips open after 5 consecutive failures, rejects immediately for 30 seconds, then probes recovery with one request.
OpenTelemetry Tracing
Wraps each request in an OTel span with stable HTTP semantic conventions:
-- Uses global TracerProvider (no-ops if unconfigured)
client |> withTracing
-- Or with a specific tracer
client |> withTracingTracer myTracer
Attributes: http.request.method, server.address, server.port, url.full, http.response.status_code, error.type.
Add headers to every request:
client |> withBearerAuth "my-token"
client |> withUserAgent "my-app/1.0"
client |> withHeader "X-Custom" "value"
client |> withHeaders [("X-A", "1"), ("X-B", "2")]
Request ID
Generate a UUID v4 correlation ID per request:
client |> withRequestId -- X-Request-ID header
client |> withRequestIdHeader "X-Correlation-ID" -- custom header name
Follow Redirects
Automatically follow 3xx responses (301, 302, 303, 307, 308):
client |> withFollowRedirects 5 -- max 5 hops
Respects 303 → GET method change per HTTP spec.
Filter
Predicate-based request control:
-- Only allow GET requests
client |> withFilter (\req -> HTTP.method req == "GET")
-- Don't retry 4xx responses (place between retry and base service)
client |> withNoRetryOn (\resp -> statusCode (responseStatus resp) < 500)
Hedge
Speculative retry — if the primary request is slow, fire a second and return whichever finishes first:
client |> withHedge 200 -- hedge after 200ms
Only use for idempotent requests (GET, etc.).
Response Validation
Reject unexpected responses:
-- Only accept 2xx
client |> withValidateStatus (\c -> c >= 200 && c < 300)
-- Require JSON
client |> withValidateContentType "application/json"
-- Require a specific header
client |> withValidateHeader "X-Request-ID"
Test Doubles
Testing utilities — mock services, record requests:
-- Replace the service entirely
let testClient = client |> withMock (\req -> pure (Right fakeResponse))
-- Route-based mocks
let mocks = Map.fromList
[ ("api.example.com/v1/users", Right usersResponse)
, ("api.example.com/v1/health", Right healthResponse)
]
let testClient = client |> withMockMap mocks
-- Record requests for assertions
recorder <- newIORef []
let testClient = client |> withRecorder recorder
_ <- runRequest testClient someRequest
recorded <- readIORef recorder
length recorded `shouldBe` 1
Error handling
All errors are returned as Either ServiceError Response — no exceptions escape the middleware stack:
data ServiceError
= HttpError SomeException
| TimeoutError
| RetryExhausted Int ServiceError
| CircuitBreakerOpen
| CustomError Text
Building
stack build
stack test
hlint src/ test/
License
MIT