Production-grade HTTP client for Go with built-in resiliency patterns.
After building HTTP clients with resiliency patterns across multiple microservice projects, a recurring pattern emerged:
- The stdlib is not enough -
net/httpis powerful but ships no retry, circuit breaker, or rate limiting - Dependencies are a liability - Libraries like Resty pull in transitive dependencies that complicate security audits and grow the binary size
- Reinventing the wheel is costly - Every team ends up writing its own wrapper with subtle bugs in context handling, timeouts, and connection pooling
This library solves that: production-ready resiliency with zero dependencies.
| Mode | When to use it |
|---|---|
go get |
Projects that accept external dependencies |
Copy into pkg/rhttp |
Strict zero-deps policies, vendor everything |
The code is designed to work in both scenarios without modification.
- Zero dependencies - Only Go standard library
- Low overhead - The full middleware stack adds ~1 μs per request
- Middleware architecture - Composable, testable, extensible
- Fluent API - Resty-style request builder
- Resiliency patterns - Retry, circuit breaker, rate limiting, timeout
- Multiple backoff strategies - Constant, linear, exponential, Fibonacci, jitter variants
- Well tested - Race-clean suite; live coverage in the Codecov badge above
go get github.com/oswaldom-code/rhttpRequires Go 1.21+
package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/oswaldom-code/rhttp"
)
func main() {
// Create client with middleware
client := rhttp.New(
rhttp.WithMiddleware(
rhttp.Timeout(5*time.Second),
rhttp.Retry(rhttp.RetryConfig{MaxAttempts: 3}),
rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{
FailureThreshold: 5,
ResetTimeout: 30*time.Second,
}),
),
)
// Make request
req, _ := http.NewRequest("GET", "https://api.example.com/users", nil)
resp, err := client.Do(context.Background(), req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println("Status:", resp.StatusCode)
}client := rhttp.New()
// GET request with query params
resp, err := client.R().
SetHeader("Authorization", "Bearer token").
SetQueryParam("page", "1").
SetQueryParam("limit", "10").
Get("https://api.example.com/users")
// POST request with JSON body
resp, err := client.R().
SetAuthToken("my-token").
SetBodyJSON(map[string]string{
"name": "John",
"email": "john@example.com",
}).
Post("https://api.example.com/users")
// Path parameters
resp, err := client.R().
SetPathParam("org", "acme").
SetPathParam("repo", "api").
Get("https://api.github.com/repos/{org}/{repo}")client := rhttp.New(
rhttp.WithMiddleware(
rhttp.Timeout(5*time.Second),
),
)Respects existing context deadlines - uses the shorter of the two.
client := rhttp.New(
rhttp.WithMiddleware(
rhttp.Retry(rhttp.RetryConfig{
MaxAttempts: 3,
Backoff: rhttp.ExponentialBackoff(100*time.Millisecond, 10*time.Second),
IsRetryable: rhttp.DefaultIsRetryable, // 429, 502, 503, 504
RetryAllMethods: false, // Only retry idempotent methods by default
}),
),
)Built-in backoff strategies:
| Strategy | Description |
|---|---|
ConstantBackoff(d) |
Always wait d |
LinearBackoff(base, max) |
base * (attempt + 1) |
ExponentialBackoff(base, max) |
base * 2^attempt with ±20% jitter |
FibonacciBackoff(base, max) |
base * fib(attempt) |
ExponentialBackoffFullJitter(base, max) |
random(0, base * 2^attempt) |
ExponentialBackoffEqualJitter(base, max) |
base * 2^attempt / 2 + random(0, half) |
DecorrelatedJitterBackoff(base, max) |
AWS-style decorrelated jitter |
Composable with WithJitter(), WithMin(), WithMax(), and WithRetryAfter()
(honors the Retry-After header on 429/503 responses).
client := rhttp.New(
rhttp.WithMiddleware(
rhttp.CircuitBreaker(rhttp.CircuitBreakerConfig{
FailureThreshold: 5, // Open after 5 consecutive failures
ResetTimeout: 30*time.Second, // Try half-open after 30s
IsFailure: rhttp.DefaultIsFailure, // Errors + 5xx
}),
),
)State machine: Closed → Open → Half-Open → Closed/Open
Returns rhttp.ErrCircuitOpen when circuit is open.
// Token bucket: 100 requests/second, burst of 10
limiter := rhttp.NewTokenBucket(100, 10)
client := rhttp.New(
rhttp.WithMiddleware(
rhttp.RateLimit(rhttp.RateLimitConfig{
Limiter: limiter,
WaitOnLimit: true, // Block until token available
RespectRetryAfter: true, // Honor Retry-After header
}),
),
)client := rhttp.New(
rhttp.WithMiddleware(
rhttp.Logging(rhttp.LoggingConfig{
Logger: rhttp.LoggerFunc(func(e rhttp.LogEntry) {
log.Printf("%s %s %d %v", e.Method, e.URL, e.StatusCode, e.Duration)
}),
ShouldLog: func(req *http.Request, resp *http.Response, err error) bool {
return err != nil || resp.StatusCode >= 500 // Only log errors
},
}),
),
)client := rhttp.New(
rhttp.WithMiddleware(
rhttp.Metrics(rhttp.MetricsConfig{
Recorder: rhttp.MetricsRecorderFunc(func(e rhttp.MetricEvent) {
// Send to Prometheus, StatsD, etc.
myCounter.WithLabels(e.Method, e.Host, e.StatusCode).Inc()
myHistogram.Observe(e.Duration.Seconds())
}),
}),
),
)MetricEvent fields: Method, Host, Path, StatusCode, Duration, BytesSent, BytesReceived, Error, Success
Exporting a raw request path (/users/8f3a.../orders/2941) as a metrics label creates one time series per ID, which grows Prometheus memory without bound. To prevent this, Path is empty by default and is only populated when you provide a PathNormalizer that collapses high-cardinality segments to a template:
rhttp.Metrics(rhttp.MetricsConfig{
Recorder: recorder,
PathNormalizer: func(p string) string {
// /users/8f3a/orders/2941 -> /users/:id/orders/:id
return idSegment.ReplaceAllString(p, "/:id")
},
})To emit the raw path anyway (not recommended as a metrics label), use func(p string) string { return p }.
resp, err := client.Do(ctx, req)
if err != nil {
classified := rhttp.Classify(err)
switch classified.Kind {
case rhttp.ErrKindTimeout:
// Request timed out
case rhttp.ErrKindCancelled:
// Context was cancelled
case rhttp.ErrKindConnection:
// Connection refused, reset, etc.
case rhttp.ErrKindDNS:
// DNS resolution failed
case rhttp.ErrKindTLS:
// Certificate error
case rhttp.ErrKindTemporary:
// Temporary error, may resolve on retry
}
// Or use helpers
if rhttp.IsRetryable(err) {
// Safe to retry (timeout, connection, DNS, temporary)
}
}The first middleware in the list is the outermost: it runs first on the way in and last on the way out. Each subsequent middleware wraps the ones after it, and the transport sits at the center.
client := rhttp.New(
rhttp.WithMiddleware(
rhttp.Logging(...), // 1. Log request start
rhttp.Metrics(...), // 2. Start timing
rhttp.Timeout(...), // 3. Apply timeout
rhttp.RateLimit(...), // 4. Check rate limit
rhttp.Retry(...), // 5. Retry on failure
rhttp.CircuitBreaker(...), // 6. Check circuit per attempt
),
)Recommended order: Logging → Metrics → Timeout → RateLimit → Retry → CircuitBreaker
Where you put Timeout relative to Retry selects one of two semantics — both valid, but very different:
| Pattern | Order | Meaning |
|---|---|---|
| Total budget | Timeout → Retry |
The timeout covers all attempts and their backoffs combined. Once it expires, no further retries happen. |
| Per-attempt timeout | Retry → Timeout |
Each attempt gets its own fresh timeout; the total wall-clock time is roughly attempts × timeout plus backoffs. |
See the runnable ExampleRetry_totalBudget and ExampleRetry_perAttemptTimeout for both wirings.
| Order | Effect |
|---|---|
Retry → CircuitBreaker (retry outer) — recommended |
Each attempt consults the circuit; a tripped breaker short-circuits the remaining attempts. The circuit counts every attempt. |
CircuitBreaker → Retry (breaker outer) |
The circuit sees one fully-retried request as a single call; retries are not individually gated by the breaker. |
// Use custom transport
client := rhttp.New(
rhttp.WithTransport(&http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90*time.Second,
}),
)
// Or use optimized default
transport := rhttp.DefaultTransport() // HTTP/2 enabled, optimized poolTwo suites, measured 2026-07-25 on linux/amd64 (Intel Core i7-1255U, Go 1.24):
the in-repo microbenchmarks (make bench) measure client and middleware
overhead against a no-op transport, and a standalone comparison harness
(benchmarks/, make report) measures rhttp against Resty
v2.17.2, go-retryablehttp v0.7.8 and Heimdall v7.0.3 with equivalent
configuration (5s timeout, 3 attempts, exponential backoff 100ms-2s).
Minimum of 5 runs:
BenchmarkMiddlewareOverhead_Baseline-12 240 ns/op 656 B/op 4 allocs/op
BenchmarkMiddlewareOverhead_WithRetry-12 272 ns/op 656 B/op 4 allocs/op
BenchmarkMiddlewareOverhead_WithCircuitBreaker-12 266 ns/op 656 B/op 4 allocs/op
BenchmarkMiddlewareOverhead_AllMiddleware-12 1030 ns/op 1589 B/op 13 allocs/op
BenchmarkStdHttpClient_Baseline-12 256 ns/op 552 B/op 5 allocs/op
BenchmarkTokenBucket_TryAcquire-12 48 ns/op 0 B/op 0 allocs/op
BenchmarkBackoffStrategies/Exponential-12 8 ns/op 0 B/op 0 allocs/op
- Full middleware stack: ~1 μs and ~1.5 KB per request — negligible against network latency (0.5–500 ms)
- Rate limiter: 48 ns per check, zero allocations
- Backoff strategies: <10 ns, zero allocations
Wrapper overhead (no-op transport, timeout + 3-attempt retry configured everywhere, min of 5 runs):
| Client | ns/op | allocs/op | vs best |
|---|---|---|---|
| rhttp (Timeout+Retry) | 910 | 12 | 1.00x |
| rhttp (Timeout+Retry+CircuitBreaker) | 946 | 12 | 1.04x |
| net/http (Timeout only, no retry) | 1750 | 26 | 1.92x |
| go-retryablehttp | 1775 | 26 | 1.95x |
| Heimdall (retry) | 2555 | 32 | 2.81x |
| Resty (retry) | 5818 | 48 | 6.39x |
End-to-end (~1 KB JSON over loopback):
| Client | ns/op | allocs/op | vs best |
|---|---|---|---|
| go-retryablehttp | 58309 | 74 | 1.00x |
| net/http (Timeout only, no retry) | 60995 | 75 | 1.05x |
| Heimdall (retry) | 61009 | 80 | 1.05x |
| rhttp (Timeout+Retry) | 61923 | 76 | 1.06x |
| rhttp (Timeout+Retry+CircuitBreaker) | 62817 | 76 | 1.08x |
| Resty (retry) | 71696 | 96 | 1.23x |
Read the caveats before quoting these numbers:
- All clients are configured equivalently and fully consume and close each response body.
net/httpdoes not retry: it is the floor, not a symmetric competitor.- Heimdall runs without its Hystrix circuit breaker (retry only, for feature symmetry).
- Resty buffers the full response body by design.
- Loopback amplifies relative overhead: against a real network (0.5-500 ms per request) every client in the table performs the same for practical purposes.
Full methodology and reproduction steps: benchmarks/REPORT.md.
- No global state - Each client is independent
- Context-first - All operations respect context cancellation
- Fail fast - Explicit errors, no silent failures
- Composable - Mix and match middleware
- Testable - All components are mockable
- Zero dependencies - Only Go standard library
See pkg.go.dev for full API documentation.
# Install development tools
make install-toolsmake help # Show all available commands
make test # Run unit tests
make test-race # Run tests with race detector
make test-coverage # Generate coverage report
make bench # Run benchmarks
make lint # Run golangci-lint
make fmt # Format code
make vet # Run go vet
make check # Run all checks (fmt, vet, lint, test)
make docs # Serve documentation locally
make clean # Clean build artifactsThe project uses GitHub Actions for CI with:
- Tests on Go 1.21, 1.22, and 1.23
- Race detector enabled
- golangci-lint for code quality
- Coverage reporting
- Benchmark tracking on PRs
Contributions are welcome! Please ensure:
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Run checks:
make check - Commit changes:
git commit -m 'Add my feature' - Push:
git push origin feature/my-feature - Open a Pull Request
All PRs must pass CI checks before merging.
Status: Phase 1 complete. Phase 2 is the current focus.
- Middleware architecture - Composable, chained
http.RoundTripper - Functional options - Configuration via
WithXxx() - Optimized transport - HTTP/2, tuned connection pooling and timeouts
- Timeout middleware - Context-aware, respects shorter deadlines
- Retry middleware - Idempotency-safe with body replay
- Backoff strategies - Constant, linear, exponential, Fibonacci, jitter variants
- Circuit breaker - Closed/Open/Half-Open state machine
- Rate limiting - Token bucket + per-host limiter
- Logging middleware - Pluggable
Loggerinterface - Metrics middleware - Pluggable
MetricsRecorderinterface - Error classification - Timeout, connection, DNS, TLS, temporary
- Fluent API - Resty-style
RequestBuilder - Zero dependencies - Only Go standard library
- Circuit breaker per endpoint - Separate circuit state for each host/path
- Sliding window statistics - Time-based failure rate calculation
- Bulkhead pattern - Resource isolation per service
- Retry budget - Limit retries per time window
- Hedged requests - Send duplicate request if first is slow
- Adaptive timeout - Adjust timeout based on latency percentiles
- OpenTelemetry integration - Native tracing and metrics
- slog compatibility - Structured logging (Go 1.21+)
- Prometheus metrics - Out-of-the-box histograms and counters
- Distributed tracing - Automatic trace context propagation
- Health check endpoints - Readiness/liveness probes
- Auto marshaling - JSON, XML, Protocol Buffers, MessagePack
- OAuth2 support - Automatic token refresh
- Debug mode - Request/response dump, curl generation
- Response validation - JSON Schema, status assertions
- Multipart uploads - With progress callbacks
- Load balancing - Round-robin, weighted, least connections
- Service discovery - DNS SRV, Kubernetes, Consul
- Response caching - RFC 7234 compliant, pluggable backends
- Request coalescing - Single-flight for duplicate requests
- HTTP/3 support - QUIC protocol (optional)
- Connection warm-up - Pre-establish connections
- mTLS support - Mutual TLS authentication
- Certificate pinning - Enhanced security
- Secrets management - Vault integration
- Configuration hot-reload - Runtime tuning
- Chaos engineering - Fault injection for testing
Want to contribute? Check the issues labeled good first issue or help wanted.
MIT License - see LICENSE file.