// Package httpkit provides a shared HTTP client with retry, timeout, and error // classification for outbound API calls. // // All outbound HTTP in this codebase should use httpkit.Client instead of // raw http.Client or http.DefaultClient. This ensures consistent timeout, // retry, body lifecycle, and error classification. // // Basic usage: // // client := httpkit.NewClient(httpkit.Config{ // Timeout: 30 * time.Second, // MaxRetries: 3, // Headers: map[string]string{"Authorization": "Bearer " + apiKey}, // }) // // body, err := client.Do(ctx, httpkit.Request{ // Method: http.MethodPost, // URL: baseURL + "/chat/completions", // Body: chatReq, // }) // if errors.Is(err, httpkit.ErrRateLimit) { ... } package httpkit import ( "errors" "fmt" "net/http" ) // Sentinel errors classify HTTP failures into retryable categories. // Use errors.Is to check against these from any wrapped error. var ( ErrTimeout = errors.New("httpkit: timeout") ErrRateLimit = errors.New("httpkit: rate limited") ErrServerError = errors.New("httpkit: server error") ErrUnauthorized = errors.New("httpkit: unauthorized") ErrForbidden = errors.New("httpkit: forbidden") ErrBadRequest = errors.New("httpkit: bad request") ErrNotFound = errors.New("httpkit: not found") ) // APIError represents a non-2xx HTTP response. // Unwrap returns the classified sentinel error for use with errors.Is. type APIError struct { StatusCode int Body string sentinel error } func (e *APIError) Error() string { body := e.Body if len(body) > 200 { body = body[:200] + "..." } return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body) } // Unwrap returns the classified sentinel error, enabling errors.Is checks // like errors.Is(err, httpkit.ErrRateLimit). func (e *APIError) Unwrap() error { return e.sentinel } // ClassifyStatus maps an HTTP status code to a sentinel error. // Returns nil for unrecognized or successful status codes. func ClassifyStatus(code int) error { switch { case code == http.StatusUnauthorized: return ErrUnauthorized case code == http.StatusForbidden: return ErrForbidden case code == http.StatusNotFound: return ErrNotFound case code == http.StatusTooManyRequests: return ErrRateLimit case code >= 400 && code < 500: return ErrBadRequest case code >= 500: return ErrServerError default: return nil } } // IsRetryable reports whether err indicates a condition that may succeed on retry. // Retryable conditions: rate limits (429), server errors (5xx), and timeouts. func IsRetryable(err error) bool { return errors.Is(err, ErrRateLimit) || errors.Is(err, ErrServerError) || errors.Is(err, ErrTimeout) }