Aleksey Shakhmatov 01478be0dc Replace balancer panic with deferred error; test HealthChecker
A malformed endpoint URL panicked inside Transport, crashing the host app
(often at startup from external config). Capture the parse error and surface
it from the transport on first use instead. Add the previously untested
HealthChecker coverage (initial probe, recovery, Stop termination, unknown
endpoint), raising balancer coverage from ~41% to ~87%. Default the health
probe path to /healthz to match this library's own server.
2026-05-23 13:47:33 +03:00
2026-03-20 10:35:38 +00:00
2026-03-22 22:20:42 +03:00
2026-03-20 10:35:38 +00:00

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

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

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:

// Just retry, nothing else
transport := retry.Transport(retry.WithMaxAttempts(3))
httpClient := &http.Client{
    Transport: transport(http.DefaultTransport),
}
// 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

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:

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:

client := httpx.New(
    httpx.WithMaxResponseBody(10 << 20), // 10 MB max
)

Examples

See the examples/ directory for runnable programs:

Example Description
basic-client HTTP client with retry, timeout, logging, and response size limit
form-request Form-encoded POST requests (OAuth, webhooks)
load-balancing Multi-endpoint client with weighted balancing, circuit breaker, and health checks
server-basic Server with routing, groups, JSON helpers, health checks, and custom 404
server-protected Production server with CORS, rate limiting, body limits, and timeouts
request-id-propagation Request ID forwarding between server and client for distributed tracing

Requirements

Go 1.24+, stdlib only.

Description
No description provided
Readme MIT 223 KiB
Languages
Go 100%