http-tower-hs: Composable HTTP client middleware for Haskell, inspired by Rust's Tower

[ library, mit, network ] [ Propose Tags ] [ Report a vulnerability ]
Versions [RSS] 0.1.0.0
Change log CHANGELOG.md
Dependencies async (>=2.2 && <2.3), base (>=4.14 && <5), bytestring (>=0.11 && <0.13), containers (>=0.6 && <0.8), crypton-connection (>=0.4 && <0.5), crypton-x509-store (>=1.6 && <1.7), crypton-x509-system (>=1.6 && <1.7), hs-opentelemetry-api (>=0.3 && <0.4), http-client (>=0.7 && <0.8), http-client-tls (>=0.3 && <0.4), http-types (>=0.12 && <0.13), safe-exceptions (>=0.1 && <0.2), stm (>=2.5 && <2.6), text (>=2.0 && <2.2), time (>=1.11 && <1.15), tls (>=2.0 && <2.2), unordered-containers (>=0.2 && <0.3), uuid (>=1.3 && <1.4) [details]
Tested with ghc ==9.6.6, ghc ==9.8.4, ghc ==9.10.1
License MIT
Copyright 2026 Jarl André Hübenthal
Author Jarl André Hübenthal
Maintainer jarlah@protonmail.com
Uploaded by jarlah at 2026-04-05T15:08:51Z
Category Network
Home page https://github.com/jarlah/http-tower-hs#readme
Bug tracker https://github.com/jarlah/http-tower-hs/issues
Source repo head: git clone https://github.com/jarlah/http-tower-hs
Downloads 0 total (0 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs available [build log]
Last success reported on 2026-04-05 [all 1 reports]

Readme for http-tower-hs-0.1.0.0

[back to package description]

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.

Set Header

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