All checks were successful
CI / test (push) Successful in 31s
Six examples covering the full API surface: - basic-client: retry, timeout, logging, response size limit - form-request: form-encoded POST for OAuth/webhooks - load-balancing: weighted endpoints, circuit breaker, health checks - server-basic: routing, groups, JSON helpers, health, custom 404 - server-protected: CORS, rate limiting, body limits, timeouts - request-id-propagation: cross-service request ID forwarding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
214 lines
7.3 KiB
Markdown
214 lines
7.3 KiB
Markdown
# httpx
|
|
|
|
HTTP client and server toolkit for Go microservices. Client side: retry, load balancing, circuit breaking, request ID propagation, response size limits — all as `http.RoundTripper` middleware. Server side: routing, middleware (request ID, recovery, logging, CORS, rate limiting, body limits, timeouts), health checks, JSON helpers, graceful shutdown. stdlib only, zero external deps.
|
|
|
|
```
|
|
go get git.codelab.vc/pkg/httpx
|
|
```
|
|
|
|
## Quick start
|
|
|
|
```go
|
|
client := httpx.New(
|
|
httpx.WithBaseURL("https://api.example.com"),
|
|
httpx.WithTimeout(10*time.Second),
|
|
httpx.WithRetry(retry.WithMaxAttempts(3)),
|
|
httpx.WithMiddleware(
|
|
middleware.UserAgent("my-service/1.0"),
|
|
middleware.BearerAuth(func(ctx context.Context) (string, error) {
|
|
return os.Getenv("API_TOKEN"), nil
|
|
}),
|
|
),
|
|
)
|
|
defer client.Close()
|
|
|
|
resp, err := client.Get(ctx, "/users/123")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var user User
|
|
resp.JSON(&user)
|
|
|
|
// PATCH request
|
|
resp, err = client.Patch(ctx, "/users/123", strings.NewReader(`{"name":"updated"}`))
|
|
|
|
// Form-encoded request (OAuth, webhooks, etc.)
|
|
req, _ := httpx.NewFormRequest(ctx, http.MethodPost, "/oauth/token", url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"scope": {"read write"},
|
|
})
|
|
resp, err = client.Do(ctx, req)
|
|
```
|
|
|
|
## Packages
|
|
|
|
### Client
|
|
|
|
Client middleware is `func(http.RoundTripper) http.RoundTripper`. Use them with `httpx.Client` or plug into a plain `http.Client`.
|
|
|
|
| Package | What it does |
|
|
|---------|-------------|
|
|
| `retry` | Exponential/constant backoff, Retry-After support. Idempotent methods only by default. |
|
|
| `balancer` | Round robin, failover, weighted random. Optional background health checks. |
|
|
| `circuitbreaker` | Per-host state machine (closed/open/half-open). Stops hammering dead endpoints. |
|
|
| `middleware` | Logging (slog), default headers, bearer/basic auth, panic recovery, request ID propagation. |
|
|
|
|
### Server
|
|
|
|
Server middleware is `func(http.Handler) http.Handler`. The `server` package provides a production-ready HTTP server.
|
|
|
|
| Component | What it does |
|
|
|-----------|-------------|
|
|
| `server.Server` | Wraps `http.Server` with graceful shutdown, signal handling, lifecycle logging. |
|
|
| `server.Router` | Lightweight wrapper around `http.ServeMux` with groups, prefix routing, sub-router mounting. |
|
|
| `server.RequestID` | Assigns/propagates `X-Request-Id` (UUID v4 via `crypto/rand`). |
|
|
| `server.Recovery` | Recovers panics, returns 500, logs stack trace. |
|
|
| `server.Logging` | Structured request logging (method, path, status, duration, request ID). |
|
|
| `server.HealthHandler` | Liveness (`/healthz`) and readiness (`/readyz`) endpoints with pluggable checkers. |
|
|
| `server.CORS` | Cross-origin resource sharing with preflight handling and functional options. |
|
|
| `server.RateLimit` | Per-key token bucket rate limiting with IP extraction and `Retry-After`. |
|
|
| `server.MaxBodySize` | Limits request body size via `http.MaxBytesReader`. |
|
|
| `server.Timeout` | Context-based request timeout, returns 503 on expiry. |
|
|
| `server.WriteJSON` | JSON response helper, sets Content-Type and status. |
|
|
| `server.WriteError` | JSON error response (`{"error": "..."}`) helper. |
|
|
| `server.Defaults` | Production preset: RequestID → Recovery → Logging + sensible timeouts. |
|
|
|
|
The client assembles them in this order:
|
|
|
|
```
|
|
Request → Logging → Your Middleware → Retry → Circuit Breaker → Balancer → Transport
|
|
```
|
|
|
|
Retry wraps the circuit breaker and balancer, so each attempt can pick a different endpoint.
|
|
|
|
## Multi-DC setup
|
|
|
|
```go
|
|
client := httpx.New(
|
|
httpx.WithEndpoints(
|
|
balancer.Endpoint{URL: "https://dc1.api.internal", Weight: 3},
|
|
balancer.Endpoint{URL: "https://dc2.api.internal", Weight: 1},
|
|
),
|
|
httpx.WithBalancer(balancer.WithStrategy(balancer.WeightedRandom())),
|
|
httpx.WithRetry(retry.WithMaxAttempts(4)),
|
|
httpx.WithCircuitBreaker(circuitbreaker.WithFailureThreshold(5)),
|
|
httpx.WithLogger(slog.Default()),
|
|
)
|
|
defer client.Close()
|
|
```
|
|
|
|
## Standalone usage
|
|
|
|
Each component works with any `http.Client`, no need for the full wrapper:
|
|
|
|
```go
|
|
// Just retry, nothing else
|
|
transport := retry.Transport(retry.WithMaxAttempts(3))
|
|
httpClient := &http.Client{
|
|
Transport: transport(http.DefaultTransport),
|
|
}
|
|
```
|
|
|
|
```go
|
|
// Chain a few middlewares together
|
|
chain := middleware.Chain(
|
|
middleware.Logging(slog.Default()),
|
|
middleware.UserAgent("my-service/1.0"),
|
|
retry.Transport(retry.WithMaxAttempts(2)),
|
|
)
|
|
httpClient := &http.Client{
|
|
Transport: chain(http.DefaultTransport),
|
|
}
|
|
```
|
|
|
|
## Server
|
|
|
|
```go
|
|
logger := slog.Default()
|
|
|
|
r := server.NewRouter(
|
|
// Custom JSON 404 instead of plain text
|
|
server.WithNotFoundHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
server.WriteError(w, 404, "not found")
|
|
})),
|
|
)
|
|
|
|
r.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
|
|
server.WriteJSON(w, 200, map[string]string{"message": "world"})
|
|
})
|
|
|
|
// Groups with middleware
|
|
api := r.Group("/api/v1", authMiddleware)
|
|
api.HandleFunc("GET /users/{id}", getUser)
|
|
|
|
// Health checks
|
|
r.Mount("/", server.HealthHandler(
|
|
func() error { return db.Ping() },
|
|
))
|
|
|
|
srv := server.New(r,
|
|
append(server.Defaults(logger),
|
|
// Protection middleware
|
|
server.WithMiddleware(
|
|
server.CORS(
|
|
server.AllowOrigins("https://app.example.com"),
|
|
server.AllowMethods("GET", "POST", "PUT", "PATCH", "DELETE"),
|
|
server.AllowHeaders("Authorization", "Content-Type"),
|
|
server.MaxAge(3600),
|
|
),
|
|
server.RateLimit(
|
|
server.WithRate(100),
|
|
server.WithBurst(200),
|
|
),
|
|
server.MaxBodySize(1<<20), // 1 MB
|
|
server.Timeout(30*time.Second),
|
|
),
|
|
)...,
|
|
)
|
|
log.Fatal(srv.ListenAndServe()) // graceful shutdown on SIGINT/SIGTERM
|
|
```
|
|
|
|
## Client request ID propagation
|
|
|
|
In microservices, forward the incoming request ID to downstream calls:
|
|
|
|
```go
|
|
client := httpx.New(
|
|
httpx.WithMiddleware(middleware.RequestID()),
|
|
)
|
|
|
|
// In a server handler — the context already has the request ID from server.RequestID():
|
|
func handler(w http.ResponseWriter, r *http.Request) {
|
|
// ID is automatically forwarded as X-Request-Id
|
|
resp, err := client.Get(r.Context(), "https://downstream/api")
|
|
}
|
|
```
|
|
|
|
## Response body limit
|
|
|
|
Protect against OOM from unexpectedly large upstream responses:
|
|
|
|
```go
|
|
client := httpx.New(
|
|
httpx.WithMaxResponseBody(10 << 20), // 10 MB max
|
|
)
|
|
```
|
|
|
|
## Examples
|
|
|
|
See the [`examples/`](examples/) directory for runnable programs:
|
|
|
|
| Example | Description |
|
|
|---------|-------------|
|
|
| [`basic-client`](examples/basic-client/) | HTTP client with retry, timeout, logging, and response size limit |
|
|
| [`form-request`](examples/form-request/) | Form-encoded POST requests (OAuth, webhooks) |
|
|
| [`load-balancing`](examples/load-balancing/) | Multi-endpoint client with weighted balancing, circuit breaker, and health checks |
|
|
| [`server-basic`](examples/server-basic/) | Server with routing, groups, JSON helpers, health checks, and custom 404 |
|
|
| [`server-protected`](examples/server-protected/) | Production server with CORS, rate limiting, body limits, and timeouts |
|
|
| [`request-id-propagation`](examples/request-id-propagation/) | Request ID forwarding between server and client for distributed tracing |
|
|
|
|
## Requirements
|
|
|
|
Go 1.24+, stdlib only.
|