Aleksey Shakhmatov b07d487e63 Drive circuit breaker state transitions via internal/clock
The Open->HalfOpen promotion used time.Now/time.Since directly, forcing tests
to use real time.Sleep and diverging from the project's clock convention. Add
an unexported withClock option (default clock.System) and replace the real
sleeps in tests with mock-clock Advance, making the transitions deterministic
and the package faster.
2026-05-23 13:47:26 +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%