tree-test-1770066850/pkg/httpclient/client.go
jordan fb95612712
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-02 21:14:11 +00:00

254 lines
6.5 KiB
Go

// Package httpclient provides a robust HTTP client with automatic retries and exponential backoff.
//
// This package wraps the standard http.Client to provide:
// - Automatic retries with exponential backoff
// - Request ID and trace ID propagation
// - Configurable timeouts
//
// Usage:
//
// // Create a client with default settings
// client := httpclient.New(httpclient.Config{
// Timeout: 10 * time.Second,
// MaxRetries: 3,
// })
//
// // Make requests
// resp, err := client.Do(req)
//
// // Or use convenience methods
// resp, err := httpclient.Get(ctx, "https://api.example.com/users")
package httpclient
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"git.threesix.ai/jordan/tree-test-1770066850/pkg/httpcontext"
)
// Config holds configuration for the HTTP client.
type Config struct {
// Timeout for individual HTTP requests (default: 10s)
Timeout time.Duration
// MaxRetries for failed requests (default: 3)
MaxRetries int
// Logger for structured logging (optional, defaults to slog.Default())
Logger *slog.Logger
}
// Client wraps http.Client to provide retry logic and request ID propagation.
type Client struct {
httpClient *http.Client
logger *slog.Logger
config Config
}
// New creates a new robust HTTP client.
func New(config Config) *Client {
if config.Timeout == 0 {
config.Timeout = 10 * time.Second
}
if config.MaxRetries == 0 {
config.MaxRetries = 3
}
if config.Logger == nil {
config.Logger = slog.Default()
}
return &Client{
httpClient: &http.Client{
Timeout: config.Timeout,
},
logger: config.Logger,
config: config,
}
}
// Do executes an HTTP request with exponential backoff retry logic.
//
// Retries on transient errors:
// - HTTP 5xx server errors
// - HTTP 429 Too Many Requests
// - Connection errors (timeout, connection refused)
//
// Does NOT retry on:
// - HTTP 4xx client errors (except 429)
// - Context cancellation or deadline exceeded
func (c *Client) Do(req *http.Request) (*http.Response, error) {
const (
initialDelay = 100 * time.Millisecond
maxDelay = 2 * time.Second
)
// Propagate request ID if present in context
if requestID, ok := httpcontext.GetRequestID(req.Context()); ok && requestID != "" {
if req.Header.Get("X-Request-ID") == "" {
req.Header.Set("X-Request-ID", requestID)
}
}
// Propagate trace ID if present in context
if traceID, ok := httpcontext.GetTraceID(req.Context()); ok && traceID != "" {
if req.Header.Get("X-Trace-ID") == "" {
req.Header.Set("X-Trace-ID", traceID)
}
}
// Clone request body for retries (critical: POST/PUT bodies get exhausted)
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read request body: %w", err)
}
_ = req.Body.Close()
}
var lastErr error
maxRetries := c.config.MaxRetries
ctx := req.Context()
for attempt := 0; attempt <= maxRetries; attempt++ {
// Check if context is already cancelled
if err := ctx.Err(); err != nil {
return nil, err
}
// Reset body for each attempt
if bodyBytes != nil {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
// Execute the request
resp, err := c.httpClient.Do(req)
// Network error
if err != nil {
lastErr = err
if !isRetryableError(err, nil) {
return nil, lastErr
}
// Continue to retry
} else {
// HTTP 429 - retry
if resp.StatusCode == http.StatusTooManyRequests {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
// Continue to retry
} else if resp.StatusCode >= 400 && resp.StatusCode < 500 {
// Other HTTP 4xx - return immediately (not transient)
return resp, nil
} else if resp.StatusCode >= 500 {
// HTTP 5xx - retry
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
// Continue to retry
} else {
// HTTP 2xx/3xx - success
return resp, nil
}
}
// Don't retry if we've exhausted attempts
if attempt >= maxRetries {
break
}
// Calculate exponential backoff delay using bit-shift
delay := initialDelay << attempt
if delay > maxDelay {
delay = maxDelay
}
c.logger.Debug("retrying http request",
"attempt", attempt+1,
"max_retries", maxRetries,
"delay_ms", delay.Milliseconds(),
"url", req.URL.String(),
"error", lastErr)
// Wait with context awareness
select {
case <-time.After(delay):
// Continue to next retry
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, lastErr)
}
// isRetryableError determines if an error or response should trigger a retry.
func isRetryableError(err error, resp *http.Response) bool {
// Network/connection errors are retryable
if err != nil {
// Don't retry on context cancellation
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// Retry on all other errors (connection refused, timeout, etc.)
return true
}
// HTTP 5xx errors and 429 are retryable
if resp != nil {
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return true
}
}
return false
}
// -----------------------------------------------------------------------------
// Convenience methods using a default client
// -----------------------------------------------------------------------------
// Default is a pre-configured client with 30s timeout and 3 retries.
var Default = New(Config{
Timeout: 30 * time.Second,
MaxRetries: 3,
})
// Do performs an HTTP request with retry logic using the default client.
func Do(req *http.Request) (*http.Response, error) {
return Default.Do(req)
}
// Get performs a GET request with the default client.
func Get(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return Default.Do(req)
}
// Post performs a POST request with the default client.
func Post(ctx context.Context, url, contentType string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
return Default.Do(req)
}
// JSONPost performs a POST request with JSON content type.
func JSONPost(ctx context.Context, url string, body io.Reader) (*http.Response, error) {
return Post(ctx, url, "application/json", body)
}