package httpkit import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "math/rand" "net/http" "time" ) const ( defaultTimeout = 30 * time.Second maxBackoff = 30 * time.Second ) // Config configures an httpkit Client. type Config struct { // Timeout for each HTTP request attempt. Default: 30s. // Ignored if HTTPClient is provided. Timeout time.Duration // MaxRetries is the number of retry attempts after the initial request. // 0 means no retries (single attempt). Default: 0. MaxRetries int // Headers are applied to every request. Per-request headers in Request // override these on key collision. Headers map[string]string // Logger for retry and error events. Default: slog.Default(). Logger *slog.Logger // HTTPClient allows injecting a pre-configured http.Client. // When set, Timeout is ignored. Use this to share connection pools // across multiple httpkit Clients. HTTPClient *http.Client } // Client is a configured HTTP client with retry and error classification. type Client struct { http *http.Client maxRetries int headers map[string]string logger *slog.Logger } // NewClient creates a new httpkit Client. func NewClient(cfg Config) *Client { httpClient := cfg.HTTPClient if httpClient == nil { timeout := cfg.Timeout if timeout == 0 { timeout = defaultTimeout } httpClient = &http.Client{Timeout: timeout} } logger := cfg.Logger if logger == nil { logger = slog.Default() } return &Client{ http: httpClient, maxRetries: cfg.MaxRetries, headers: cfg.Headers, logger: logger, } } // Request describes an HTTP request to execute. type Request struct { Method string URL string Body any // JSON-marshaled if non-nil Headers map[string]string // Per-request headers (override defaults) } // Do executes an HTTP request with automatic retry on retryable errors. // // On 2xx: returns (responseBody, nil). // On non-2xx after retries: returns (nil, *APIError). // On transport error after retries: returns (nil, error). // // The retry loop uses exponential backoff with full jitter and respects // context cancellation between attempts. func (c *Client) Do(ctx context.Context, req Request) ([]byte, error) { var lastErr error for attempt := 0; attempt <= c.maxRetries; attempt++ { if attempt > 0 { if !IsRetryable(lastErr) { return nil, lastErr } backoff := c.backoff(attempt) c.logger.Info("retrying request", "attempt", attempt, "max_retries", c.maxRetries, "url", req.URL, "backoff_ms", backoff.Milliseconds(), ) select { case <-time.After(backoff): case <-ctx.Done(): return nil, ctx.Err() } } body, err := c.doOnce(ctx, req) if err == nil { return body, nil } lastErr = err // Don't retry if parent context is done. if ctx.Err() != nil { return nil, lastErr } } if c.maxRetries > 0 { return nil, fmt.Errorf("failed after %d attempts: %w", c.maxRetries+1, lastErr) } return nil, lastErr } // doOnce executes a single HTTP request attempt. func (c *Client) doOnce(ctx context.Context, req Request) ([]byte, error) { var bodyReader io.Reader if req.Body != nil { data, err := json.Marshal(req.Body) if err != nil { return nil, fmt.Errorf("marshal request body: %w", err) } bodyReader = bytes.NewReader(data) } httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bodyReader) if err != nil { return nil, fmt.Errorf("create request: %w", err) } // Apply default headers first. for k, v := range c.headers { httpReq.Header.Set(k, v) } // Apply per-request headers (override defaults). for k, v := range req.Headers { httpReq.Header.Set(k, v) } // Auto-set Content-Type for JSON bodies. if req.Body != nil && httpReq.Header.Get("Content-Type") == "" { httpReq.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(httpReq) if err != nil { if errors.Is(err, context.DeadlineExceeded) || isTimeoutError(err) { return nil, fmt.Errorf("%w: %v", ErrTimeout, err) } return nil, err } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return body, nil } return nil, &APIError{ StatusCode: resp.StatusCode, Body: string(body), sentinel: ClassifyStatus(resp.StatusCode), } } // backoff returns the sleep duration for a retry attempt using // exponential backoff with full jitter. func (c *Client) backoff(attempt int) time.Duration { base := time.Second ceiling := min(base*time.Duration(1<