packages feed

http-tower-hs-0.1.0.0: README.md

# http-tower-hs

Composable HTTP client middleware for Haskell, inspired by Rust's [Tower](https://docs.rs/tower/latest/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

```haskell
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)`:

```haskell
newtype Service req res = Service { runService :: req -> IO (Either ServiceError res) }
```

### Middleware

A function that wraps a service to add behavior:

```haskell
type Middleware req res = Service req res -> Service req res
```

### Client

An HTTP client with a middleware stack, built using the `(|>)` operator:

```haskell
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):

```haskell
-- 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:

```haskell
-- 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:

```haskell
client |> withTimeout 5000
```

### Logging

Logs method, host, status, and duration:

```haskell
client |> withLogging (\msg -> Data.Text.IO.putStrLn msg)
```

### Circuit Breaker

Three-state circuit breaker (Closed → Open → HalfOpen) using STM:

```haskell
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](https://opentelemetry.io/docs/specs/semconv/http/http-spans/):

```haskell
-- 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:

```haskell
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:

```haskell
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):

```haskell
client |> withFollowRedirects 5  -- max 5 hops
```

Respects 303 → GET method change per HTTP spec.

### Filter

Predicate-based request control:

```haskell
-- 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:

```haskell
client |> withHedge 200  -- hedge after 200ms
```

Only use for idempotent requests (GET, etc.).

### Response Validation

Reject unexpected responses:

```haskell
-- 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:

```haskell
-- 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:

```haskell
data ServiceError
  = HttpError SomeException
  | TimeoutError
  | RetryExhausted Int ServiceError
  | CircuitBreakerOpen
  | CustomError Text
```

## Building

```bash
stack build
stack test
hlint src/ test/
```

## License

MIT