// 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/slack-q-1770281596/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) }