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.
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.