292 lines
7.7 KiB
Go
292 lines
7.7 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
|
|
// - Circuit breaker protection for cascading failure prevention
|
|
// - 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,
|
|
// })
|
|
//
|
|
// // Create a client with circuit breaker
|
|
// client := httpclient.New(httpclient.Config{
|
|
// Timeout: 10 * time.Second,
|
|
// MaxRetries: 3,
|
|
// CircuitBreaker: httpclient.NewCircuitBreaker(httpclient.CircuitBreakerConfig{}),
|
|
// })
|
|
//
|
|
// // 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/slack5-1770606136/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
|
|
|
|
// CircuitBreaker provides circuit breaker protection (optional).
|
|
// When set, requests are blocked with ErrCircuitOpen if the circuit is open.
|
|
CircuitBreaker *CircuitBreaker
|
|
}
|
|
|
|
// Client wraps http.Client to provide retry logic and request ID propagation.
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
logger *slog.Logger
|
|
config Config
|
|
circuitBreaker *CircuitBreaker
|
|
}
|
|
|
|
// 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,
|
|
circuitBreaker: config.CircuitBreaker,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// - Circuit breaker open (returns ErrCircuitOpen immediately)
|
|
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
|
const (
|
|
initialDelay = 100 * time.Millisecond
|
|
maxDelay = 2 * time.Second
|
|
)
|
|
|
|
// Check circuit breaker if configured
|
|
if c.circuitBreaker != nil {
|
|
if err := c.circuitBreaker.Allow(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// 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
|
|
c.recordSuccess()
|
|
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()
|
|
}
|
|
}
|
|
|
|
c.recordFailure()
|
|
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, lastErr)
|
|
}
|
|
|
|
// recordSuccess records a successful request to the circuit breaker.
|
|
func (c *Client) recordSuccess() {
|
|
if c.circuitBreaker != nil {
|
|
c.circuitBreaker.RecordSuccess()
|
|
}
|
|
}
|
|
|
|
// recordFailure records a failed request to the circuit breaker.
|
|
func (c *Client) recordFailure() {
|
|
if c.circuitBreaker != nil {
|
|
c.circuitBreaker.RecordFailure()
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|