httpx

package module
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 4, 2026 License: GPL-2.0, GPL-3.0 Imports: 15 Imported by: 0

README

httpx

CI Go Reference

Resilient outbound-HTTP toolkit for Go: retry, backoff, transient-error classification, and more.

A resilient outbound-HTTP toolkit for Go providing jittered exponential backoff, transient-error classification, Retry-After parsing, HTTP status mapping, secret redaction, body draining, a transparent retrying http.RoundTripper with body replay, and a configurable redirect allowlist. Zero dependencies beyond the Go standard library and pgregory.net/rapid (test only).

Install

go get github.com/cplieger/httpx@latest

Usage

// Simple GET with retry
body, err := httpx.Retry(ctx, http.DefaultClient, url,
    httpx.WithMaxAttempts(3),
    httpx.WithBaseDelay(time.Second),
)

// Generic retry with backoff
result, err := httpx.RetryWithBackoff(ctx, 3, time.Second, "fetch", func(ctx context.Context) (T, error) {
    return doWork(ctx)
})

// Transparent retrying RoundTripper (mirrors hashicorp/go-retryablehttp)
rt := httpx.NewRetryRoundTripper(http.DefaultTransport,
    httpx.WithMaxRetries(3),
    httpx.WithRTBaseDelay(time.Second),
    httpx.WithOnRetry(func(attempt int, req *http.Request, resp *http.Response, err error) {
        log.Printf("retry #%d for %s", attempt, req.URL)
    }),
    httpx.WithPrepareRetry(func(req *http.Request) error {
        req.Header.Set("Authorization", "Bearer "+freshToken())
        return nil
    }),
)
client := rt.StandardClient()

// Retry POST/PUT with body replay (opt-in, mirrors go-retryablehttp)
rt := httpx.NewRetryRoundTripper(http.DefaultTransport,
    httpx.WithMaxRetries(3),
    httpx.WithRetryNonIdempotent(true),
)
client := rt.StandardClient()
payload := []byte(`{"key":"value"}`)
req, _ := http.NewRequest("POST", url, bytes.NewReader(payload))
req.GetBody = func() (io.ReadCloser, error) {
    return io.NopCloser(bytes.NewReader(payload)), nil
}
resp, err := client.Do(req)

// PermanentError — signal "do not retry" (mirrors cenkalti/backoff)
if configErr != nil {
    return httpx.Permanent(configErr) // will not be retried
}

// Pluggable backoff strategy
rt := httpx.NewRetryRoundTripper(http.DefaultTransport,
    httpx.WithBackoff(httpx.NewExponentialBackoff(
        httpx.WithInitialInterval(500*time.Millisecond),
        httpx.WithMaxElapsedTime(30*time.Second),
    )),
)

// Custom redirect policy
policy := httpx.RedirectPolicyFunc(
    httpx.WithAllowedHosts("api.example.com"),
    httpx.WithAllowedSuffixes(".cdn.example.com"),
    httpx.WithMaxHops(3),
)

// Transient error classification
if httpx.IsTransient(err) { /* safe to retry */ }

// Limit response body size
rc := httpx.LimitedBody(resp, 1<<20) // 1 MB cap
defer rc.Close()

API

Retry
  • Retry — HTTP GET with exponential backoff on 429/5xx (functional options: WithMaxAttempts, WithBaseDelay, WithMaxBodyBytes, WithHeaders, WithLogger)
  • RetryWithBackoff[T] — generic retry with jittered exponential backoff
  • RetryOnRateLimit — retry on *RateLimitError only (passes ctx to fn)
  • NewRetryRoundTripper — create a retrying http.RoundTripper (functional options: WithMaxRetries, WithRTBaseDelay, WithRTMaxElapsedTime, WithBackoff, WithCheckRetry, WithOnRetry, WithPrepareRetry, WithRetryNonIdempotent)
  • StandardClient() — returns *http.Client using the RetryRoundTripper
Hooks & Policies
  • CheckRetry — pluggable retry policy: func(ctx, resp, err) (bool, error)
  • OnRetry — per-attempt callback for observability/metrics
  • PrepareRetry — mutate request before retry (e.g., re-sign tokens)
Backoff Strategy
  • Backoff — pluggable backoff interface: NextBackOff() time.Duration + Reset() (mirrors cenkalti/backoff)
  • NewExponentialBackoff — create jittered exponential backoff (functional options: WithInitialInterval, WithMaxElapsedTime)
  • BackoffStop — sentinel value to signal "stop retrying"
Error Control
  • Permanent(err) — wrap error to signal "do not retry" (mirrors cenkalti/backoff)
  • IsPermanent(err) — check if error is wrapped as permanent
  • PermanentError — the wrapper type (supports errors.Is/errors.As/Unwrap)
Classification & Parsing
  • IsTransient — classify errors as transient (retryable); respects PermanentError
  • CheckHTTPStatus — map HTTP status to typed errors
  • ParseRetryAfter / ParseRetryAfterResponse — parse Retry-After header
Backoff Primitives
  • JitteredBackoff — equal jitter [backoff/2, backoff]
  • SafeDouble / SleepCtx — overflow-safe doubling, context-aware sleep
Body Helpers
  • Drain / DrainClose — body drain for connection reuse (64 KB limit)
  • LimitedBody — wrap response body with a size cap
Redirect Policies
  • DefaultRedirectPolicy — same-host-only redirect policy (used by NewClient)
  • DockerGitHubRedirectPolicy — optional example policy for docker.com/github.com
  • RedirectPolicyFunc — build a custom redirect allowlist (functional options: WithAllowedHosts, WithAllowedSuffixes, WithMaxHops)
Client Helpers
  • NewClient / Close — preconfigured HTTP client
Secret Redaction
  • RedactTransportError / RedactSecret — secret redaction
Error Types
  • AuthError / RateLimitError / HTTPStatusError / StatusError
  • ErrRateLimited / ErrServerError — sentinel errors
  • PermanentError — do-not-retry sentinel wrapper

Logging

Retry logs via log/slog. Pass WithLogger to override the default logger for Retry calls. RetryWithBackoff and Drain use slog.Default() and cannot be overridden per-call.

Unsupported by Design (SKIP List)

The following features are intentionally not provided:

Feature Rationale
Circuit breaker Orthogonal pattern excluded by all comparables. Compose externally with sony/gobreaker.
Retry budget / token bucket None of the comparables implement it. Disproportionate complexity (~150 LOC + shared mutable state) for a focused library.
Multiple jitter strategies (full, decorrelated) Equal jitter is the recommended default per AWS Builders' Library. Full jitter risks near-zero delays.
ErrorHandler for exhaustion Current fmt.Errorf("retries exhausted: %w", lastErr) is sufficient. Callers unwrap.
Response body on error Adds API complexity (ownership of body close). Use RetryWithBackoff[T] with custom logic.
Idempotency key injection Application-level concern, not a retry library's responsibility.

License

GPL-3.0 — see LICENSE.

Documentation

Overview

Package httpx provides a resilient outbound-HTTP toolkit: transient-error classification, generic retry with jittered exponential backoff, Retry-After parsing, HTTP status mapping, secret redaction, body draining, and a configurable redirect allowlist.

Index

Examples

Constants

View Source
const (
	// DefaultBaseDelay is the production base for exponential-backoff retry.
	DefaultBaseDelay = time.Second
	// DefaultMaxAttempts caps Retry at three tries.
	DefaultMaxAttempts = 3
	// DefaultMaxBodyBytes caps response bodies at 10 MB.
	DefaultMaxBodyBytes int64 = 10 << 20
	// RetryAfterCap is the maximum Retry-After honor duration.
	RetryAfterCap = 60 * time.Second
)
View Source
const BackoffStop time.Duration = -1

BackoffStop signals that no more retries should be made.

Variables

View Source
var ErrRateLimited = errors.New("rate limited")

ErrRateLimited is a sentinel callers use with errors.Is to detect 429 responses.

View Source
var ErrServerError = errors.New("server error")

ErrServerError is a sentinel for upstream 5xx responses.

RedirectPolicy is a legacy alias for DockerGitHubRedirectPolicy, kept for backward compatibility. New code should use DefaultRedirectPolicy (same-host only, used by NewClient) or DockerGitHubRedirectPolicy explicitly.

Deprecated: Use DefaultRedirectPolicy or DockerGitHubRedirectPolicy directly.

Functions

func CheckHTTPStatus

func CheckHTTPStatus(resp *http.Response) error

CheckHTTPStatus maps HTTP error status codes to typed errors. Returns nil for 2xx/3xx. 401/403 → *AuthError, 429 → *RateLimitError, others ≥400 → *HTTPStatusError.

func Close

func Close(c *http.Client)

Close drains idle connections on the client's transport.

func DefaultRedirectPolicy

func DefaultRedirectPolicy(req *http.Request, via []*http.Request) error

DefaultRedirectPolicy is the default redirect policy: it denies cross-host redirects, allowing only redirects to the same host as the original request. For custom allowlists, use RedirectPolicyFunc.

func DockerGitHubRedirectPolicy

func DockerGitHubRedirectPolicy(req *http.Request, via []*http.Request) error

DockerGitHubRedirectPolicy is an OPTIONAL example redirect policy allowing docker.com and github.com hosts. Use it by assigning to Client.CheckRedirect or pass RedirectOption values to RedirectPolicyFunc for other allowlists.

func Drain

func Drain(body io.ReadCloser)

Drain reads and discards up to 64 KB of a response body to enable HTTP connection reuse.

func DrainClose

func DrainClose(rc io.ReadCloser)

DrainClose reads remaining bytes (up to drainLimit) from rc before closing it.

func IsPermanent

func IsPermanent(err error) bool

IsPermanent reports whether err (or any wrapped error) is a *PermanentError.

func IsTransient

func IsTransient(err error) bool

IsTransient returns true for errors likely caused by temporary server or network issues worth retrying. Auth, rate-limit, permanent, and context errors are never transient.

func JitteredBackoff

func JitteredBackoff(backoff time.Duration) time.Duration

JitteredBackoff returns a duration in [backoff/2, backoff] using the "equal jitter" strategy (per AWS Builders' Library). Full jitter and decorrelated jitter are intentionally not provided — equal jitter is the recommended default for HTTP retry as it avoids thundering herd while maintaining a minimum backoff floor.

func LimitedBody

func LimitedBody(resp *http.Response, limit int64) io.ReadCloser

LimitedBody wraps resp.Body with an io.LimitReader capped at limit bytes, preserving the original Close method.

func NewClient

func NewClient(timeout time.Duration) *http.Client

NewClient returns an *http.Client with the given timeout and the DefaultRedirectPolicy (same-host only). For custom redirect allowlists, configure CheckRedirect with RedirectPolicyFunc or assign DockerGitHubRedirectPolicy.

func ParseRetryAfter

func ParseRetryAfter(h string) time.Duration

ParseRetryAfter parses a Retry-After header value (delta-seconds or HTTP-date). Returns zero for missing/malformed values. Caps at RetryAfterCap for safety (prevents unbounded waits in retry loops). For raw uncapped values, use ParseRetryAfterResponse.

func ParseRetryAfterResponse

func ParseRetryAfterResponse(resp *http.Response) time.Duration

ParseRetryAfterResponse parses the Retry-After header from an *http.Response. Returns zero if absent or unparseable. Does NOT cap — preserves the raw duration so callers (e.g., CheckHTTPStatus) can make their own decisions. For capped values suitable for retry loops, use ParseRetryAfter.

func Permanent

func Permanent(err error) error

Permanent wraps err to indicate it should never be retried. Mirrors cenkalti/backoff.Permanent().

func RedactSecret

func RedactSecret(err error, secret string) error

RedactSecret replaces occurrences of secret in err's message with "REDACTED".

func RedactTransportError

func RedactTransportError(err error, prefix, secret string) error

RedactTransportError unwraps *url.Error and redacts the secret from the error message. Returns nil for nil input.

func RedirectPolicyFunc

func RedirectPolicyFunc(opts ...RedirectOption) func(*http.Request, []*http.Request) error

RedirectPolicyFunc returns a CheckRedirect function configured with the given options. With no options, all redirects are refused.

func Retry

func Retry(ctx context.Context, client *http.Client, reqURL string, opts ...Option) ([]byte, error)

Retry performs an HTTP GET with bounded exponential-backoff retry on 429 and 5xx responses. 4xx (non-429) and transport errors are returned immediately. Honors Retry-After (capped at RetryAfterCap).

Example
package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/cplieger/httpx"
)

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprint(w, "hello")
	}))
	defer srv.Close()

	body, err := httpx.Retry(context.Background(), srv.Client(), srv.URL,
		httpx.WithMaxAttempts(3),
		httpx.WithBaseDelay(100*time.Millisecond),
	)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(string(body))
}
Output:
hello

func RetryOnRateLimit

func RetryOnRateLimit(ctx context.Context, maxAttempts int, maxWait time.Duration, fn func(ctx context.Context) error) error

RetryOnRateLimit retries fn up to maxAttempts times when it returns a *RateLimitError. Non-rate-limit errors are returned immediately. The context is passed to fn on each attempt.

func RetryWithBackoff

func RetryWithBackoff[T any](ctx context.Context, maxRetries int, baseDelay time.Duration,
	label string, fn func(ctx context.Context) (T, error)) (T, error)

RetryWithBackoff retries fn up to maxRetries times with jittered exponential backoff. Non-transient errors are returned immediately. Logging uses slog.Default() and cannot be overridden per-call; control output via slog.SetDefault().

Example
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/cplieger/httpx"
)

func main() {
	attempts := 0
	result, err := httpx.RetryWithBackoff(context.Background(), 3, time.Millisecond, "example", func(_ context.Context) (string, error) {
		attempts++
		if attempts < 2 {
			return "", &httpx.HTTPStatusError{Code: 503}
		}
		return "done", nil
	})
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(result)
}
Output:
done

func SafeDouble

func SafeDouble(d time.Duration) time.Duration

SafeDouble doubles a duration, guarding against int64 overflow.

func SleepCtx

func SleepCtx(ctx context.Context, d time.Duration) error

SleepCtx sleeps for d or returns early on context cancellation.

Types

type AuthError

type AuthError struct{ Msg string }

AuthError indicates invalid or expired credentials.

func (*AuthError) Error

func (e *AuthError) Error() string

type Backoff

type Backoff interface {
	// NextBackOff returns the next wait duration, or BackoffStop to stop.
	NextBackOff() time.Duration
	// Reset restores the strategy to its initial state.
	Reset()
}

Backoff is a pluggable backoff strategy. NextBackOff returns the duration to wait before the next retry. Return BackoffStop to signal no more retries. Mirrors cenkalti/backoff.BackOff.

type CheckRetry

type CheckRetry func(ctx context.Context, resp *http.Response, err error) (bool, error)

CheckRetry is the signature for a retry policy function. It receives the context, the response (may be nil on transport error), and the error (nil on successful response). It returns whether to retry and an optional error to short-circuit with. Mirrors hashicorp/go-retryablehttp CheckRetry.

type ExpBackoffOption

type ExpBackoffOption func(*expBackoffCfg)

ExpBackoffOption configures an ExponentialBackoff.

func WithInitialInterval

func WithInitialInterval(d time.Duration) ExpBackoffOption

WithInitialInterval sets the first backoff duration. Default: DefaultBaseDelay.

func WithMaxElapsedTime

func WithMaxElapsedTime(d time.Duration) ExpBackoffOption

WithMaxElapsedTime caps total retry time for the backoff. Zero means no cap.

type ExponentialBackoff

type ExponentialBackoff struct {
	// contains filtered or unexported fields
}

ExponentialBackoff implements Backoff with jittered exponential backoff. This is the default strategy used throughout httpx.

func NewExponentialBackoff

func NewExponentialBackoff(opts ...ExpBackoffOption) *ExponentialBackoff

NewExponentialBackoff creates an ExponentialBackoff with functional options.

func (*ExponentialBackoff) NextBackOff

func (b *ExponentialBackoff) NextBackOff() time.Duration

NextBackOff returns the next jittered backoff duration, or BackoffStop if MaxElapsedTime has been exceeded.

func (*ExponentialBackoff) Reset

func (b *ExponentialBackoff) Reset()

Reset restores the backoff to its initial state.

type HTTPStatusError

type HTTPStatusError struct {
	Code int
}

HTTPStatusError represents a non-2xx HTTP response not covered by AuthError or RateLimitError. Implements the Transient interface for 502/503/504.

func (*HTTPStatusError) Error

func (e *HTTPStatusError) Error() string

func (*HTTPStatusError) IsClientError

func (e *HTTPStatusError) IsClientError() bool

IsClientError reports whether the status code is 4xx.

func (*HTTPStatusError) IsServerError

func (e *HTTPStatusError) IsServerError() bool

IsServerError reports whether the status code is 5xx.

func (*HTTPStatusError) IsTransient

func (e *HTTPStatusError) IsTransient() bool

IsTransient reports whether the status code is a retryable server failure (502/503/504).

type OnRetry

type OnRetry func(attempt int, req *http.Request, resp *http.Response, err error)

OnRetry is called before each retry attempt (not the initial request). attempt is 1-indexed (first retry = 1). resp may be nil on transport errors.

type Option

type Option func(*retryCfg)

Option configures a Retry call.

func WithBaseDelay

func WithBaseDelay(d time.Duration) Option

WithBaseDelay sets the initial backoff delay. Default: DefaultBaseDelay (1s).

func WithHeaders

func WithHeaders(fn func(*http.Request)) Option

WithHeaders sets a function that is called to set headers on each request.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger for retry diagnostics. Default: slog.Default().

func WithMaxAttempts

func WithMaxAttempts(n int) Option

WithMaxAttempts sets the maximum number of attempts (including the first). Default: DefaultMaxAttempts (3).

func WithMaxBodyBytes

func WithMaxBodyBytes(n int64) Option

WithMaxBodyBytes sets the maximum response body size to read. Default: DefaultMaxBodyBytes (10 MB).

type PermanentError

type PermanentError struct {
	Err error
}

PermanentError wraps an error to signal that it should NOT be retried, regardless of other retry policies. Mirrors cenkalti/backoff.PermanentError. Use Permanent(err) to wrap.

func (*PermanentError) Error

func (e *PermanentError) Error() string

func (*PermanentError) Is

func (e *PermanentError) Is(target error) bool

Is allows errors.Is matching against other PermanentErrors.

func (*PermanentError) Unwrap

func (e *PermanentError) Unwrap() error

type PrepareRetry

type PrepareRetry func(req *http.Request) error

PrepareRetry is called before each retry to allow request mutation (e.g., re-signing with a fresh token). Mirrors go-retryablehttp PrepareRetry.

type RTOption

type RTOption func(*rtCfg)

RTOption configures a RetryRoundTripper via NewRetryRoundTripper.

func WithBackoff

func WithBackoff(b Backoff) RTOption

WithBackoff sets a custom backoff strategy. When set, BaseDelay is ignored.

func WithCheckRetry

func WithCheckRetry(cr CheckRetry) RTOption

WithCheckRetry sets a custom retry policy. If nil, the default policy retries on transient transport errors and 429/5xx responses.

func WithMaxRetries

func WithMaxRetries(n int) RTOption

WithMaxRetries sets the maximum number of retries (not counting the initial request). Default: 2 (3 total attempts).

func WithOnRetry

func WithOnRetry(fn OnRetry) RTOption

WithOnRetry sets a hook called before each retry attempt for observability.

func WithPrepareRetry

func WithPrepareRetry(fn PrepareRetry) RTOption

WithPrepareRetry sets a hook called before each retry to mutate the request (e.g., refresh auth tokens).

func WithRTBaseDelay

func WithRTBaseDelay(d time.Duration) RTOption

WithRTBaseDelay sets the initial backoff delay for the round-tripper (used when no custom Backoff is provided). Default: DefaultBaseDelay (1s).

func WithRTMaxElapsedTime

func WithRTMaxElapsedTime(d time.Duration) RTOption

WithRTMaxElapsedTime caps total time spent retrying. Zero means no cap.

func WithRetryNonIdempotent

func WithRetryNonIdempotent(enable bool) RTOption

WithRetryNonIdempotent enables retry of non-idempotent methods (POST, PUT, PATCH, DELETE) when the request has a GetBody function for body replay.

type RateLimitError

type RateLimitError struct {
	Msg        string
	RetryAfter time.Duration
}

RateLimitError indicates a rate limit was exceeded. RetryAfter, when non-zero, is the hint from the upstream's Retry-After header.

func (*RateLimitError) Error

func (e *RateLimitError) Error() string

type RedirectOption

type RedirectOption func(*redirectCfg)

RedirectOption configures a redirect policy created by RedirectPolicyFunc.

func WithAllowedHosts

func WithAllowedHosts(hosts ...string) RedirectOption

WithAllowedHosts sets the exact hostnames allowed as redirect targets.

func WithAllowedSuffixes

func WithAllowedSuffixes(suffixes ...string) RedirectOption

WithAllowedSuffixes sets the domain suffixes allowed (e.g. ".docker.com").

func WithMaxHops

func WithMaxHops(n int) RedirectOption

WithMaxHops sets the maximum number of redirect hops. Default: 5.

type RetryRoundTripper

type RetryRoundTripper struct {
	// contains filtered or unexported fields
}

RetryRoundTripper implements http.RoundTripper with automatic retry.

By default, only idempotent methods (GET, HEAD, OPTIONS, TRACE) are retried. Use WithRetryNonIdempotent to also retry POST/PUT/PATCH/DELETE when the request has a GetBody function for body replay (mirrors go-retryablehttp).

Mirrors hashicorp/go-retryablehttp RoundTripper but operates directly on stdlib *http.Request without requiring a custom request type.

func NewRetryRoundTripper

func NewRetryRoundTripper(next http.RoundTripper, opts ...RTOption) *RetryRoundTripper

NewRetryRoundTripper creates a RetryRoundTripper wrapping next with the given options. If next is nil, http.DefaultTransport is used.

func (*RetryRoundTripper) RoundTrip

func (rt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper. It retries eligible requests on transient failures with jittered exponential backoff, honoring Retry-After. Per the http.RoundTripper contract, the caller's request is never mutated.

func (*RetryRoundTripper) StandardClient

func (rt *RetryRoundTripper) StandardClient() *http.Client

StandardClient returns an *http.Client using this RetryRoundTripper as its Transport. Mirrors hashicorp/go-retryablehttp StandardClient().

type StatusError

type StatusError struct {
	URL  string
	Code int
}

StatusError represents a non-2xx response with URL context. Used by Retry. Supports errors.Is matching against ErrRateLimited and ErrServerError.

func (*StatusError) Error

func (e *StatusError) Error() string

func (*StatusError) Is

func (e *StatusError) Is(target error) bool

Is reports whether this StatusError matches ErrRateLimited or ErrServerError.

type Transient

type Transient interface {
	IsTransient() bool
}

Transient is the interface for errors that can report whether they represent a transient (retryable) failure.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL