httpx

package module
v2.6.0 Latest Latest
Warning

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

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

README

httpx

Go Reference Go version Test coverage Mutation OpenSSF Best Practices OpenSSF Scorecard

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/v2@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 (inspired by hashicorp/go-retryablehttp)
rt := httpx.NewRetryRoundTripper(http.DefaultTransport,
    httpx.WithRTMaxAttempts(4),
    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, inspired by go-retryablehttp)
rt := httpx.NewRetryRoundTripper(http.DefaultTransport,
    httpx.WithRTMaxAttempts(4),
    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 (factory invoked once per request, so each
// request gets its own independent backoff progression)
rt := httpx.NewRetryRoundTripper(http.DefaultTransport,
    httpx.WithBackoffFunc(func() httpx.Backoff {
        return 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),
)

// Pin a private / self-signed CA as the SOLE trust anchor (verification stays
// ON, TLS 1.2 minimum). The caller reads the PEM bytes (file, secret, env),
// keeping the helper I/O-free.
tr, err := httpx.CATransport(pemBytes)
client := &http.Client{Transport: tr}
// ...or compose the pinned transport with retry:
client = httpx.NewRetryRoundTripper(tr, httpx.WithRTMaxAttempts(3)).StandardClient()

// 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 and transient transport errors (timeouts, connection resets, DNS failures — see IsTransient); 4xx (non-429) and non-transient transport errors return immediately (functional options: WithMaxAttempts, WithBaseDelay, WithMaxBodyBytes, WithHeaders, WithLogger). Counts total attempts (a non-positive count clamps to 1).
  • RetryWithBackoff[T] — generic retry with jittered exponential backoff; when a transient error implements RetryAfterHint, its pre-capped duration replaces the backoff for the next wait (the exponential base keeps advancing)
  • RetryOnRateLimit — retry on *RateLimitError only (passes ctx to fn)
  • NewRetryRoundTripper — create a retrying http.RoundTripper (functional options: WithRTMaxAttempts, WithRTBaseDelay, WithRTMaxElapsedTime, WithBackoffFunc, WithCheckRetry, WithOnRetry, WithPrepareRetry, WithRetryNonIdempotent)
  • StandardClient() — returns *http.Client using the RetryRoundTripper
TLS transports
  • CATransport(pem) — build an *http.Transport (cloned from http.DefaultTransport, so pooling/timeouts/proxy are preserved) that pins the CA certificate(s) in pem as the sole trust anchors. Verification stays on (InsecureSkipVerify is never set) with a TLS 1.2 minimum. Returns the concrete, mutable transport so it composes with NewRetryRoundTripper.
  • ErrNoCertsInPEM — returned by CATransport when pem yields no certificates (a loud error instead of a silently-empty pool). The caller reads the PEM bytes, keeping the helper I/O-free.
  • CloneDefaultTransport() — a private clone of http.DefaultTransport (pooling/timeouts/HTTP2/proxy preserved) that is yours to mutate — set a per-attempt ResponseHeaderTimeout, tune MaxIdleConnsPerHost, or use it as the base of NewRetryRoundTripper — without reconfiguring every other client in the process. Errors when http.DefaultTransport has been replaced by a non-*http.Transport (nothing concrete to clone). The building block CATransport is assembled on.
Test helpers (certtest subpackage)

The github.com/cplieger/httpx/v2/certtest subpackage supplies throwaway self-signed CA material for tests — the companion to CATransport. It lives in a separate package so the certificate-generation code is never linked into a production binary (only the _test.go files that import it pull it in, exactly as the standard library ships net/http/httptest alongside net/http).

  • certtest.SelfSignedCA(tb) — a fresh, throwaway self-signed CA certificate, PEM-encoded ([]byte); feed it to CATransport or an x509.CertPool. A new key each call, so two certs are mutually untrusted (handy for asserting a pin is enforced).
  • certtest.WriteSelfSignedCA(tb) — the same certificate written to a ca.pem file (mode 0o600) under tb.TempDir(), returning the path — for code under test that reads its CA from a file path.
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)
  • WithBackoffFunc(func() Backoff) — supply a factory that is called per-request to produce a fresh Backoff instance (each request gets an independent backoff progression)
  • 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
  • RetryAfterHint is an interface (RetryAfterHint() time.Duration) an error implements to supply the next retry wait; RetryWithBackoff honors it when the error is transient and the duration is positive (the implementer must cap the value, since httpx applies no ceiling of its own here)
  • 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
  • ReadLimitedBody — read a body to a cap (closing it) with overflow detection, returning *ResponseTooLargeError instead of a silently truncated body
Conditional GET
  • Validators{ETag, LastModified} — the cache validators captured from a previous 200, replayed on the next request
  • ConditionalResult{Validators, Body, NotModified} — one conditional-request outcome
  • DoConditional(client, req, v, maxBodyBytes) — a single conditional attempt: sets If-None-Match / If-Modified-Since from v (empty fields unsent) and classifies the response — 304 (NotModified, body drained, zero Validators: keep the ones you sent), 200 (bounded body + the response's fresh validators), anything else an error (the CheckHTTPStatus mapping for >= 400, a plain non-transient error for a status that is neither content nor a revalidation). Deliberately single-shot so the caller owns retry and cache policy: wrap it in RetryWithBackoff (transient classification composes through the returned errors), rebuild the request per attempt, persist body and validators together, and send the zero Validators when the cached body is unusable so an empty cache can never be "revalidated" into a 304 with nothing to reuse.
Redirect Policies
  • DefaultRedirectPolicy — same-host-only redirect policy (used by NewClient). It also refuses a same-host https->http scheme downgrade and allows an http->https upgrade, which makes it equivalent to RedirectPolicyFunc(WithSameHost()).
  • RefuseAllRedirects — follows no redirect: returns http.ErrUseLastResponse, so the client surfaces the 3xx response itself (nil error) instead of following. The policy for a token-bearing client of an API that issues no redirects — Go forwards custom headers (X-Plex-Token, X-Api-Key) across redirects, so a hostile 302 would exfiltrate the credential.
  • DockerGitHubRedirectPolicy — optional example policy for docker.com/github.com
  • RedirectPolicyFunc — build a custom redirect allowlist (functional options: WithAllowedHosts, WithAllowedSuffixes, WithSameHost, WithAllowSchemeDowngrade, WithMaxHops)
    • WithSameHost additionally allows a redirect whose target host equals the original request's host (layered on any allowlisted hosts or suffixes); it is the building block for a same-origin policy.
    • WithAllowSchemeDowngrade(bool) permits an https->http downgrade redirect. The default false refuses it, so a custom auth header is never forwarded onto a cleartext hop; an http->https upgrade is always allowed. The downgrade is judged against the original request's scheme, and the guard applies to allowlisted and same-host targets alike.
Client Helpers
  • NewClient / Close — preconfigured HTTP client
Secret Redaction
  • RedactTransportError / RedactSecret / RedactSecretString — secret redaction (error- and string-level)
  • LogSafeError — reduce a URL-embedding transport *url.Error to its underlying cause (everything else passes through, errors.Is/As preserved). The same reduction httpx applies to every transport error it logs; equivalent to RedactTransportError(err, "", "").
Error Types
  • AuthError / RateLimitError / HTTPStatusError / StatusError
  • ResponseTooLargeError — returned by Retry when the response exceeds WithMaxBodyBytes (carries Limit; no body is returned)
  • 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, RetryOnRateLimit, and Drain use slog.Default() and cannot be overridden per-call.

Per-attempt "retrying" lines are logged at Debug — a retry that recovers is normal operation, not a degraded state. Only the terminal "retries exhausted" / "rate limit retries exhausted" lines are at Warn. Retry also emits a Warn "slow upstream response" when a single attempt's response takes longer than 10s (timed per attempt, so backoff sleeps are not counted as upstream latency). The RetryRoundTripper logs nothing itself — observe its retries through the WithOnRetry hook.

URL redaction in logs and errors

To avoid leaking credentials into logs (CWE-532, the class of go-retryablehttp CVE-2024-6104), Retry never logs or returns a raw request URL:

  • Every logged url attribute is redacted — the userinfo password is masked (like url.URL.Redacted) and query values are replaced with REDACTED (query values commonly carry api keys, tokens, and signatures — the same default .NET 9's IHttpClientFactory adopted). Query keys, scheme, host, and path are kept for debugging.
  • StatusError.Error() renders that same redacted URL, so the secret stays out of returned errors too; the raw StatusError.URL field remains available for programmatic use.
  • Transport errors (*url.Error, which embed the full URL) are reduced to their underlying cause before logging — the reduction is exported as LogSafeError so callers wrapping transport errors into their own messages can apply the same one.

The RoundTripper performs no URL logging of its own — wire any logging through its WithOnRetry hook, where redaction is the caller's responsibility.

Retry exhaustion

Retry and the RetryRoundTripper report exhaustion differently — match your error handling to the one you use:

  • Retry returns nil body and a wrapped error: retries exhausted after <elapsed>: <lastErr> (unwrap with errors.Is/errors.As). A response that overflows WithMaxBodyBytes returns *ResponseTooLargeError (no body).
  • RetryRoundTripper returns the last response with a nil error, even when that response is a retryable 5xx (e.g. a 503) — mirroring how a non-retried request behaves. A caller that checks only err != nil will treat an exhausted 503 as success, so inspect resp.StatusCode and close the body. (A budget abort via WithRTMaxElapsedTime or a BackoffStop does return an error.)

Timeouts and deadlines

httpx retries transient failures, not budget expiry, and that distinction drives how you should bound a retried call. IsTransient classifies context.DeadlineExceeded and context.Canceled as non-transient (checked first), while a transport-level net.Error timeout, a connection reset, a DNS error, and a 429/5xx are transient. So a context deadline means "the budget is exhausted, stop", and a transport timeout means "this attempt failed, try again".

  • Total budget: a context deadline. Pass a context.WithTimeout (or a caller-supplied deadline) as the single authoritative bound over the whole operation. Retry / RetryWithBackoff stop the moment ctx is done, and SleepCtx caps the backoff by it, so the deadline spans every attempt and every backoff sleep. On expiry the call ends; it is terminal, not retried.
  • Per-attempt bound. Where a per-attempt cap lives depends on the retry entry point, because that is what decides whether http.Client.Timeout is per-attempt or total:
    • With the one-shot Retry / RetryWithBackoff (the retry loop runs outside client.Do), an http.Client.Timeout bounds each attempt and fires as a net.Error timeout, so it is retried; a context.WithTimeout wrapped inside the retry fn is instead a context deadline and is terminal. Choose the stall behavior you want.
    • With NewRetryRoundTripper (the retry loop runs inside client.Do), http.Client.Timeout is NOT per-attempt: it caps the whole retry sequence, and because it is a context deadline a slow attempt that trips it aborts the remaining retries. For a per-attempt bound here, set a transport timeout such as ResponseHeaderTimeout on the base transport (it fires as a retryable net.Error); bound the total with the caller's context or WithRTMaxElapsedTime.
  • RoundTripper budget. NewRetryRoundTripper takes WithRTMaxElapsedTime as a hard total-time ceiling across retries (computed after any honored Retry-After). Pair it with a per-attempt transport timeout (e.g. ResponseHeaderTimeout); do not wrap the client in an http.Client.Timeout, which is a total cap in disguise and defeats the retries it sits above.

Recommended: give the operation a context deadline as its total budget (honored end-to-end, and it keeps a slow attempt from running unbounded), and add a per-attempt bound only when a single try needs its own cap. Through the one-shot Retry helper a bare http.Client.Timeout with no context deadline is fine for a simple call to a trusted or local endpoint (there it is per-attempt, so a retried call can run up to maxAttempts times its value); under NewRetryRoundTripper reach for ResponseHeaderTimeout plus a total from the context or WithRTMaxElapsedTime instead.

A per-attempt timeout that is itself retried (a stalled attempt abandoned and a fresh one tried within the remaining total budget, the gRPC per-try-timeout model) is not built into the retry primitives today. Approximate it by pairing a per-attempt bound (a context.WithTimeout inside the one-shot fn, or ResponseHeaderTimeout under the RoundTripper) with an outer context deadline or WithRTMaxElapsedTime for the total.

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.
Configurable Retry-After cap / per-call WithLogger on the generics A raisable cap would regress the fixed-60s DoS ceiling; a per-call logger on the positional generics forces an options refactor for no consumer demand.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

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, custom-CA TLS transports, 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 ErrNoCertsInPEM = errors.New("httpx: no PEM-encoded certificates found")

ErrNoCertsInPEM is returned by CATransport when the supplied PEM data contains no parseable certificates. A misconfigured or empty CA file therefore fails loudly rather than silently producing a transport that trusts nothing (which would reject every connection with an opaque error).

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.

Functions

func CATransport added in v2.1.0

func CATransport(pem []byte) (*http.Transport, error)

CATransport builds an *http.Transport that trusts ONLY the CA certificate(s) in pem for TLS verification (pinning). It is cloned from http.DefaultTransport (via CloneDefaultTransport), so it keeps the standard connection pooling, dial/keepalive timeouts, HTTP/2 negotiation, and proxy support (ProxyFromEnvironment). A FRESH TLS config is then installed — RootCAs from pem, a TLS 1.2 minimum, and verification always ENABLED (InsecureSkipVerify is not set). Any TLS settings already on http.DefaultTransport are intentionally NOT carried over, so the returned transport's trust posture cannot be weakened by a program that globally mutated the default transport's *tls.Config (e.g. set InsecureSkipVerify or an accept-all VerifyPeerCertificate hook).

The supplied CA(s) are the SOLE trust anchors: the transport rejects any host not chaining to them, including public-CA hosts. This is the right setup for a known self-hosted endpoint presenting a private or self-signed certificate (a Plex server, an internal API).

The returned *http.Transport is concrete and mutable, so callers may tune fields such as MaxIdleConnsPerHost or pass it as the base RoundTripper of NewRetryRoundTripper:

tr, err := httpx.CATransport(pem)
client := httpx.NewRetryRoundTripper(tr, httpx.WithRTMaxAttempts(3)).StandardClient()

It returns ErrNoCertsInPEM when pem yields no certificates. The caller owns reading the PEM bytes (from a file, a secret, an env var), which keeps this function I/O-free and lets the caller bound the read as it sees fit.

CATransport requires http.DefaultTransport to be the standard library's *http.Transport (the default). If your program has replaced it with a wrapping RoundTripper (for example request instrumentation), CATransport returns an error.

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 CloneDefaultTransport added in v2.5.0

func CloneDefaultTransport() (*http.Transport, error)

CloneDefaultTransport returns a private clone of http.DefaultTransport, keeping the standard connection pooling, dial/keepalive timeouts, HTTP/2 negotiation, and proxy support (ProxyFromEnvironment). The clone is the caller's to mutate — set a per-attempt ResponseHeaderTimeout, tune MaxIdleConnsPerHost, or pass it as the base RoundTripper of NewRetryRoundTripper — without the global footgun of mutating http.DefaultTransport itself, which would silently reconfigure every other client in the process.

It returns an error when http.DefaultTransport has been replaced by a non-*http.Transport RoundTripper (request instrumentation, a test stub): a wrapping RoundTripper offers no concrete transport to clone, and failing loudly beats silently dropping the wrapper's behavior. It is the building block CATransport is assembled on.

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 allows a redirect only to the same host as the original request, and refuses a same-host https->http scheme downgrade (which would forward a custom auth header onto a cleartext hop). A cross-host redirect is refused (Go forwards a custom header across a redirect, so it would leak) and an http->https upgrade is allowed. Equivalent to RedirectPolicyFunc(WithSameHost()); use RedirectPolicyFunc for a custom allowlist, a higher hop cap (WithMaxHops), or to permit downgrades (WithAllowSchemeDowngrade).

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 LogSafeError added in v2.5.0

func LogSafeError(err error) error

LogSafeError returns an error whose message is safe to log. A transport *url.Error embeds the full request URL (with any userinfo/query secrets), so it is reduced to its underlying cause. Nil returns nil; *StatusError already renders a redacted URL via Error(), so it (and everything else) passes through unchanged — preserving errors.Is/As chains for callers.

httpx applies this reduction to every transport error it logs or wraps; it is exported so a caller wrapping transport errors into its own messages can apply the same one (equivalent to RedactTransportError(err, "", "") — reach for that variant when a known secret must also be scrubbed from the text).

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 ReadLimitedBody added in v2.3.0

func ReadLimitedBody(body io.ReadCloser, limit int64) ([]byte, error)

ReadLimitedBody reads body up to limit bytes, always closes body, and returns the bytes read. It reads one byte past limit to detect an over-limit body and returns *ResponseTooLargeError (with nil bytes) rather than a silently truncated payload — a truncated body indistinguishable from a complete one is a corruption hazard. A limit of math.MaxInt64 means "effectively unlimited" and is guarded against probe-size overflow.

It is the read-all-with-overflow-detection companion to LimitedBody (which only caps the stream and leaves reading and overflow handling to the caller), and is the same cap+1 read Retry applies internally — exposed for callers that issue their own request and decode outside Retry but still want the fail-loud size bound. On any error the body is already closed.

func RedactSecret

func RedactSecret(err error, secret string) error

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

func RedactSecretString added in v2.3.0

func RedactSecretString(s, secret string) string

RedactSecretString replaces every occurrence of secret in s with "REDACTED" and returns the result. It is the string-level building block behind RedactSecret and RedactTransportError, exposed for callers that must redact a secret from a plain string — a captured HTTP response body destined for an error field or a log line — rather than from an error value. An empty secret is a no-op (s is returned unchanged), matching the error-shaped variants.

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. A redirect is followed only when its target host is allowed — an exact WithAllowedHosts entry, a WithAllowedSuffixes match, or (with WithSameHost) the original request's own host — and, unless WithAllowSchemeDowngrade is set, the redirect does not downgrade https->http. With no allowlist and no WithSameHost, all redirects are refused. The hop cap is WithMaxHops (default 5).

func RefuseAllRedirects added in v2.5.0

func RefuseAllRedirects(*http.Request, []*http.Request) error

RefuseAllRedirects is a CheckRedirect policy that follows NO redirect: it returns http.ErrUseLastResponse, so the client hands the caller the redirect response itself (status 3xx, body open, nil error) instead of the followed hop. It is the policy for a token-bearing client of an API that issues no redirects: Go's client forwards custom request headers (an X-Plex-Token, an X-Api-Key) across redirects — only Authorization, Cookie, and WWW-Authenticate are stripped, and only on a cross-domain hop — so a hostile 302 (MITM, DNS poisoning) would exfiltrate the credential to an attacker-chosen origin. With the hop refused, the credential never leaves the configured host and the unexpected 3xx surfaces to the caller's own status handling.

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 and on transient transport errors (timeouts, connection resets, DNS failures - see IsTransient). 4xx (non-429) and non-transient transport errors are returned immediately. Honors Retry-After (capped at RetryAfterCap).

Retry deliberately keeps its own retry loop rather than delegating to RetryRoundTripper.RoundTrip. It is a decorator over the same shared primitives (JitteredBackoff, SafeDouble, SleepCtx, ParseRetryAfter, IsTransient, Drain), not a thin wrapper over the RoundTripper cycle, because Retry carries behavior the transparent RoundTripper has no equivalent for and which must stay byte-for-byte stable for existing consumers:

  • []byte return with the body capped at cfg.maxBodyBytes (the RoundTripper hands back an *http.Response and never reads the body);
  • URL/secret redaction on every log "url" attr (redactURL) and every returned/wrapped error (LogSafeError, StatusError.Error()), the CWE-532 hardening the RoundTripper path does not perform;
  • rich per-attempt slog logging plus the "retries exhausted after %s: %w" wrapper, which the RoundTripper exposes only as an OnRetry hook;
  • classification of every 5xx (not just 502/503/504) as retryable and of any non-2xx (3xx included) as a permanent *StatusError. A 2xx response returns the body; Retry cannot surface a redirect, so 3xx is an error.

Routing Retry through RoundTrip would silently change one or more of these, so the loop is intentionally not merged.

Example
package main

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

	"github.com/cplieger/httpx/v2"
)

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 calls fn up to maxAttempts times (total, including the first call) when it returns a *RateLimitError. Non-rate-limit errors are returned immediately. maxAttempts is the TOTAL attempt count; a value below 1 is treated as 1, so fn is always called at least once. The context is passed to fn on each attempt.

func RetryWithBackoff

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

RetryWithBackoff calls fn up to maxAttempts times (total, including the first call) with jittered exponential backoff, returning the first success. Non-transient errors are returned immediately. maxAttempts is the TOTAL attempt count, not retries-beyond-first; a value below 1 is treated as 1, so fn is always called at least once (it never silently no-ops). 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/v2"
)

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 ConditionalResult added in v2.6.0

type ConditionalResult struct {
	// Validators are the fresh validators captured from a 200 response's ETag /
	// Last-Modified headers (either may be empty when the server sent none).
	// Zero on a 304: the caller keeps the validators it already holds.
	Validators Validators
	// Body is the full response body of a 200, bounded by the maxBodyBytes
	// given to DoConditional. Nil on a 304.
	Body []byte
	// NotModified reports a 304: the cached representation is still current.
	NotModified bool
}

ConditionalResult is one conditional-request outcome. Fields are ordered largest-alignment-first for govet fieldalignment.

func DoConditional added in v2.6.0

func DoConditional(client *http.Client, req *http.Request, v Validators, maxBodyBytes int64) (ConditionalResult, error)

DoConditional executes req as a single conditional request: it sets If-None-Match / If-Modified-Since from v (an empty field is not sent), performs the request on client, and classifies the response.

  • 304 -> NotModified=true (body drained and closed; Validators zero — keep the ones you sent).
  • 200 -> the bounded body plus the response's fresh validators. A body over maxBodyBytes fails loud with *ResponseTooLargeError rather than being silently truncated; maxBodyBytes <= 0 means DefaultMaxBodyBytes.
  • Anything else -> an error: the CheckHTTPStatus mapping for >= 400 (*AuthError, *RateLimitError — non-transient; *HTTPStatusError — transient for 502/503/504), or a plain non-transient error for a status that is neither usable content nor a revalidation (a 204, a 3xx from a redirect-refusing client). The body is always closed.

It is deliberately a SINGLE attempt so the caller owns the retry and cache policy: wrap it in RetryWithBackoff (transient classification composes through the returned errors), rebuild req per attempt, and decide app-side when a cached copy may be reused on failure (stale-on-error) and whether validators may be sent at all (send the zero Validators when the cached body is unusable, so an empty cache can never be "revalidated" into a 304 with nothing to reuse). Intended for GET (or HEAD, where Body stays empty).

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 already elapsed. The check is made BEFORE the returned interval is slept, so the caller's total time can overshoot MaxElapsedTime by up to one interval (the final returned wait). When a hard ceiling is required, use the RoundTripper's WithRTMaxElapsedTime, which aborts on elapsed+wait and never overshoots.

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 WithBackoffFunc

func WithBackoffFunc(f func() Backoff) RTOption

WithBackoffFunc sets a factory that returns a fresh custom backoff strategy for each request. When set, the round-tripper's base delay is ignored.

The factory is invoked once per RoundTrip, so every request drives its own independent Backoff instance. This is required for correctness under the documented shared-transport pattern (one StandardClient fanned across goroutines): a single long-lived Backoff would have its progression rewound and advanced concurrently by unrelated requests. Return a new instance (e.g. NewExponentialBackoff(...)) from the factory; the fresh instance needs no Reset.

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/502/503/504 responses.

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 WithRTMaxAttempts

func WithRTMaxAttempts(n int) RTOption

WithRTMaxAttempts sets the maximum number of attempts (TOTAL, including the initial request). Default: DefaultMaxAttempts (3). A value below 1 is treated as 1, so the request is always sent at least once. This counts total attempts, not retries-beyond-first.

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 RAW, UNCAPPED hint from the upstream's Retry-After header (populated via ParseRetryAfterResponse). The upstream controls this value; a hostile or misconfigured server can supply a very large duration (CWE-400 uncontrolled resource consumption). Callers that sleep on it directly MUST bound it first, e.g. min(err.RetryAfter, cap). RetryOnRateLimit already does this (caps at its maxWait argument). For a pre-capped value use ParseRetryAfter (bounded at RetryAfterCap = 60s).

func (*RateLimitError) Error

func (e *RateLimitError) Error() string

type RedirectOption

type RedirectOption func(*redirectCfg)

RedirectOption configures a redirect policy created by RedirectPolicyFunc.

func WithAllowSchemeDowngrade added in v2.2.0

func WithAllowSchemeDowngrade(allow bool) RedirectOption

WithAllowSchemeDowngrade permits a redirect that downgrades the scheme (https on the original request -> http on the target). The default (false) refuses such a downgrade so a credential carried in a custom request header (which Go forwards across a redirect, stripping only Authorization/Cookie) is never sent over a cleartext hop. A scheme upgrade (http->https) is always allowed regardless of this setting. The downgrade is judged against the ORIGINAL request's scheme (via[0]).

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.

func WithSameHost added in v2.2.0

func WithSameHost() RedirectOption

WithSameHost additionally allows a redirect whose target host equals the original request's host (ASCII case-insensitive, RFC 3986 §6.2.2.1), in addition to any WithAllowedHosts / WithAllowedSuffixes entries. It is the building block for a same-origin policy: combined with the default scheme-downgrade refusal (see WithAllowSchemeDowngrade), it follows a service's own same-host redirects (including an http->https upgrade) while refusing a cross-host hop that would forward a custom auth header to another origin. A policy built with only WithSameHost (no allowlisted hosts) permits exactly the same-host set.

type ResponseTooLargeError

type ResponseTooLargeError struct {
	Limit int64
}

ResponseTooLargeError is returned by Retry when the response body exceeds the configured maximum (WithMaxBodyBytes, default DefaultMaxBodyBytes). The body is not returned: a truncated payload indistinguishable from a complete one is a silent-corruption hazard, so Retry fails loud instead. Limit is the cap that was exceeded, mirroring the stdlib *http.MaxBytesError shape.

func (*ResponseTooLargeError) Error

func (e *ResponseTooLargeError) Error() string

type RetryAfterHint added in v2.2.0

type RetryAfterHint interface {
	RetryAfterHint() time.Duration
}

RetryAfterHint is implemented by errors that carry an explicit wait duration for the next retry, typically a parsed and capped Retry-After. When fn's returned error is transient AND implements this interface with a positive duration, RetryWithBackoff waits that duration before the next attempt instead of its jittered exponential backoff. The exponential base still advances, so a later transient error without a hint resumes the normal progression. The hint MUST already be capped by the implementer (e.g. via ParseRetryAfter); RetryWithBackoff sleeps on it verbatim and applies no ceiling of its own, so an uncapped value is an unbounded-wait hazard.

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.

Inspired by hashicorp/go-retryablehttp, but operates directly on stdlib *http.Request without a custom request type, and counts TOTAL attempts (WithRTMaxAttempts) rather than go-retryablehttp's retries-beyond-first.

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.

When retries are exhausted the final response is returned, not an error: if the last attempt produced a retryable response (e.g. a 503), RoundTrip returns that *http.Response with a nil error, exactly as a non-retried request would. The caller owns the returned body (must Close it) and MUST inspect resp.StatusCode - a nil error does not imply a 2xx response.

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().

The returned client sets no Client.Timeout. Bound every request with a context deadline (http.NewRequestWithContext) or configure the wrapped transport's own timeouts: without one, a stalled upstream blocks RoundTrip indefinitely, the retry loop never advances (it is suspended inside the transport), and because the RoundTripper logs nothing itself the stall is silent. A Client.Timeout is intentionally NOT set here because it would cap total time across all retries, conflicting with WithRTMaxElapsedTime.

The returned client sets no CheckRedirect: redirects follow the net/http default policy (up to 10 hops to any host; Authorization is stripped on cross-host redirects). This RoundTripper is a Transport and cannot restrict redirect targets from RoundTrip. Callers that need a redirect allowlist must set CheckRedirect on the returned client explicitly, e.g.:

c := rt.StandardClient()
c.CheckRedirect = httpx.DefaultRedirectPolicy // same-host only
// or httpx.RedirectPolicyFunc(httpx.WithAllowedSuffixes(".example.com"))

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.

type Validators added in v2.6.0

type Validators struct {
	// ETag is replayed as If-None-Match.
	ETag string
	// LastModified is replayed as If-Modified-Since.
	LastModified string
}

Validators carries the cache validators captured from a previous 200 response, replayed on the next conditional request so an unchanged resource is a cheap 304 instead of a re-download. Persist them alongside the cached body; the zero value sends no conditional headers (forcing a full 200).

Directories

Path Synopsis
Package certtest provides throwaway self-signed certificate material for tests.
Package certtest provides throwaway self-signed certificate material for tests.

Jump to

Keyboard shortcuts

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