Documentation
¶
Overview ¶
Package httpx provides a resilient outbound-HTTP toolkit: transient-error classification, generic typed retry (Do) and a bounded-bytes GET (GetBytes) over one jittered-exponential-backoff loop, a transparent retrying RoundTripper, Retry-After parsing, HTTP status mapping, secret redaction, body draining, custom-CA TLS transports, and a configurable redirect allowlist.
The package deliberately keeps these concerns together: they compose into a single net/http.Client, whose configuration surface (Transport, CheckRedirect, per-request contexts) spans exactly this set. This overview maps the surface; the README carries usage examples, the v2 migration table, and the timeout model.
Retry doors ¶
Three entry points with different ownership contracts (who builds the request, who owns the body, who sees the response). They share one option vocabulary and the same equal-jitter backoff progression; passing an option to the wrong door does not compile.
- Do: retry a typed operation fn; you build requests, you keep the typed result. Options: Option and DoOption (WithLabel, WithRateLimitRetry, WithRateLimitOnly).
- GetBytes: bounded-bytes GET with redacted diagnostics; the door owns the request, the body cap, and the close. Options: Option and GetOption (WithHeaders, WithMaxBodyBytes).
- NewRetryRoundTripper: a transparent retrying RetryRoundTripper beneath any client; configured by the TransportConfig struct (zero value ready), with CheckRetry, OnRetry, and PrepareRetry hooks and opt-in body replay.
Shared loop options (Option): WithMaxAttempts, WithBaseDelay, WithLogger.
Clients ¶
NewClient (timeout + same-host redirect policy preinstalled), NewRetryClient (retry transport + REQUIRED explicit redirect policy), ContextWithDefaultTimeout (request-deadline helper).
Classification and error control ¶
IsTransient decides retryability; extend it for your own error types via the Transient and RetryAfterHint interfaces. Permanent (and PermanentError, IsPermanent) marks an error non-retryable. CheckHTTPStatus maps response codes to typed errors: AuthError, RateLimitError, StatusError, HTTPStatusError, ResponseTooLargeError, and the ErrRateLimited and ErrServerError sentinels.
Retry-After ¶
ParseRetryAfter and ParseRetryAfterResponse parse the header with a 60-second cap (RetryAfterCap). RateLimitError.RetryAfter carries the RAW uncapped hint; callers sleeping on it must bound it (the rate-limit retry modes do).
Redirect policies ¶
A CheckRedirect policy is required knowledge the caller supplies: DefaultRedirectPolicy (same host), RefuseAllRedirects, DockerGitHubRedirectPolicy, or a custom allowlist built with RedirectPolicyFunc and RedirectOption (WithAllowedHosts, WithAllowedSuffixes, WithSameHost, WithMaxHops, WithAllowSchemeDowngrade).
TLS transports ¶
CATransport pins PEM CA certificate(s) as the sole trust anchors on a cloned default transport (ErrNoCertsInPEM on empty input); CloneDefaultTransport yields a private mutable transport clone. The certtest subpackage generates throwaway CA material for tests.
Secret redaction ¶
RedactSecret, RedactSecretString, RedactTransportError, and LogSafeError keep credentials out of logs and returned errors. GetBytes never logs or returns a raw URL.
Body helpers ¶
Drain, DrainClose, LimitedBody, ReadLimitedBody.
Conditional GET ¶
DoConditional with Validators and ConditionalResult implements ETag/Last-Modified revalidation over one bounded read.
Backoff primitives ¶
JitteredBackoff, SafeDouble, and SleepCtx are the exported building blocks the doors are made of, for callers composing their own loops.
Index ¶
- Constants
- Variables
- func CATransport(pem []byte) (*http.Transport, error)
- func CheckHTTPStatus(resp *http.Response) error
- func CloneDefaultTransport() (*http.Transport, error)
- func ContextWithDefaultTimeout(ctx context.Context, def time.Duration) (context.Context, context.CancelFunc)
- func DefaultRedirectPolicy(req *http.Request, via []*http.Request) error
- func Do[T any](ctx context.Context, fn func(ctx context.Context) (T, error), opts ...DoOption) (T, error)
- func DockerGitHubRedirectPolicy(req *http.Request, via []*http.Request) error
- func Drain(body io.ReadCloser)
- func DrainClose(rc io.ReadCloser)
- func GetBytes(ctx context.Context, client *http.Client, reqURL string, opts ...GetOption) ([]byte, error)
- func IsPermanent(err error) bool
- func IsTransient(err error) bool
- func JitteredBackoff(backoff time.Duration) time.Duration
- func LimitedBody(resp *http.Response, limit int64) io.ReadCloser
- func LogSafeError(err error) error
- func NewClient(timeout time.Duration) *http.Client
- func NewRetryClient(base http.RoundTripper, policy CheckRedirect, cfg TransportConfig) *http.Client
- func ParseRetryAfter(h string) time.Duration
- func ParseRetryAfterResponse(resp *http.Response) time.Duration
- func Permanent(err error) error
- func ReadLimitedBody(body io.ReadCloser, limit int64) ([]byte, error)
- func RedactSecret(err error, secret string) error
- func RedactSecretString(s, secret string) string
- func RedactTransportError(err error, prefix, secret string) error
- func RedirectPolicyFunc(opts ...RedirectOption) func(*http.Request, []*http.Request) error
- func RefuseAllRedirects(*http.Request, []*http.Request) error
- func SafeDouble(d time.Duration) time.Duration
- func SleepCtx(ctx context.Context, d time.Duration) error
- type AuthError
- type CheckRedirect
- type CheckRetry
- type ConditionalResult
- type DoOption
- type GetOption
- type HTTPStatusError
- type OnRetry
- type Option
- type PermanentError
- type PrepareRetry
- type RateLimitError
- type RedirectOption
- type ResponseTooLargeError
- type RetryAfterHint
- type RetryRoundTripper
- type StatusError
- type Transient
- type TransportConfig
- type Validators
Examples ¶
Constants ¶
const ( // DefaultBaseDelay is the production base for exponential-backoff retry. DefaultBaseDelay = time.Second // DefaultMaxAttempts caps the retry doors at three total attempts. 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 )
Variables ¶
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).
var ErrRateLimited = errors.New("rate limited")
ErrRateLimited is a sentinel callers use with errors.Is to detect 429 responses.
var ErrServerError = errors.New("server error")
ErrServerError is a sentinel for upstream 5xx responses.
Functions ¶
func CATransport ¶
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.NewRetryClient(tr, httpx.DefaultRedirectPolicy, httpx.TransportConfig{MaxAttempts: 3})
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 ¶
CheckHTTPStatus maps HTTP error status codes to typed errors. Returns nil for 2xx/3xx. 401/403 → *AuthError, 429 → *RateLimitError, others ≥400 → *HTTPStatusError.
func CloneDefaultTransport ¶
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 ContextWithDefaultTimeout ¶ added in v3.1.0
func ContextWithDefaultTimeout(ctx context.Context, def time.Duration) (context.Context, context.CancelFunc)
ContextWithDefaultTimeout bounds ctx by def ONLY when ctx carries no deadline; a deadline the caller already set is the authoritative budget and is never undercut (or extended). A def <= 0 means "no default" and leaves ctx unbounded. The returned cancel is always non-nil and must be called (a no-op on the passthrough paths), matching context.WithTimeout's contract.
It is the per-request timeout rule for an API client: per-attempt bounds live on the transport (ResponseHeaderTimeout), the total budget is the caller's ctx, and this default applies only when the caller brought no budget of its own. Extracted from the identical requestContext helpers in the plexapi and arrapi client libraries.
func DefaultRedirectPolicy ¶
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. It delegates to RedirectPolicyFunc(WithSameHost()), with one addition: a call with an empty via chain (which net/http never produces — via always carries at least the original request) is allowed rather than refused. Use RedirectPolicyFunc for a custom allowlist, a higher hop cap (WithMaxHops), or to permit downgrades (WithAllowSchemeDowngrade).
func Do ¶
func Do[T any](ctx context.Context, fn func(ctx context.Context) (T, error), opts ...DoOption) (T, error)
Do calls fn up to WithMaxAttempts times (total, including the first call) with jittered exponential backoff, returning the first success. Non-retryable errors are returned immediately. By default the retryable set is IsTransient (a *RateLimitError is deliberately NOT transient; a generic operation must not blindly re-fire a rate-limited call) and a transient error carrying a positive RetryAfterHint waits that hint instead of the backoff, the exponential base still advancing. WithRateLimitRetry and WithRateLimitOnly opt into rate-limit retry per their docs.
Under the default and WithRateLimitRetry modes a context canceled after a failed attempt returns ctx.Err(); under WithRateLimitOnly the final attempt's error wins (the v2 RetryOnRateLimit contract). Logging goes to WithLogger (default slog.Default()): per-attempt lines at Debug, the terminal exhaustion at Warn.
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/cplieger/httpx/v3"
)
func main() {
attempts := 0
result, err := httpx.Do(context.Background(), func(_ context.Context) (string, error) {
attempts++
if attempts < 2 {
return "", &httpx.HTTPStatusError{Code: 503}
}
return "done", nil
}, httpx.WithMaxAttempts(3), httpx.WithBaseDelay(time.Millisecond), httpx.WithLabel("example"))
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(result)
}
Output: done
func DockerGitHubRedirectPolicy ¶
DockerGitHubRedirectPolicy is an OPTIONAL example redirect policy allowing docker.com and github.com hosts. Like every shipped policy it refuses an https->http scheme downgrade (judged against the original request's scheme, see WithAllowSchemeDowngrade), so a custom auth header never rides a cleartext hop even to an allowlisted host. 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 GetBytes ¶
func GetBytes(ctx context.Context, client *http.Client, reqURL string, opts ...GetOption) ([]byte, error)
GetBytes 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). The response body is read to WithMaxBodyBytes and returned; an over-limit body fails loud with *ResponseTooLargeError (no body). Every logged url attribute and every returned error is redacted (see the package's URL redaction docs).
GetBytes deliberately keeps its own retry loop rather than delegating to RetryRoundTripper.RoundTrip. It is a decorator over the same shared primitives (resolveWait, JitteredBackoff, SafeDouble, SleepCtx, ParseRetryAfter, IsTransient, Drain), not a thin wrapper over the RoundTripper cycle, because GetBytes 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 WithMaxBodyBytes (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; GetBytes cannot surface a redirect, so 3xx is an error.
Routing GetBytes 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/v3"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, "hello")
}))
defer srv.Close()
body, err := httpx.GetBytes(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 IsPermanent ¶
IsPermanent reports whether err (or any wrapped error) is a *PermanentError.
func IsTransient ¶
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 ¶
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 ¶
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 ¶
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 NewRetryClient ¶
func NewRetryClient(base http.RoundTripper, policy CheckRedirect, cfg TransportConfig) *http.Client
NewRetryClient returns an *http.Client whose Transport is a RetryRoundTripper over base (nil base means http.DefaultTransport) and whose CheckRedirect is policy. It is the one-call form of the composition plexapi-style consumers assemble by hand: retry transport plus an explicit redirect policy.
policy is REQUIRED and must be non-nil: NewRetryClient panics on a nil policy, because a nil CheckRedirect silently means net/http's default follow-anywhere behavior (up to 10 hops to any host, custom auth headers forwarded) — exactly the unsafe omission this constructor exists to prevent. Pass DefaultRedirectPolicy (same-host), RefuseAllRedirects, or a RedirectPolicyFunc allowlist.
The returned client sets no Client.Timeout: a Client.Timeout above a retrying transport caps the WHOLE retry sequence and defeats the retries beneath it. Note that neither MaxElapsedTime nor the request context can interrupt a stalled in-flight attempt from between attempts: bound single attempts on the base transport (e.g. ResponseHeaderTimeout on a CloneDefaultTransport()) and bound the total with a context deadline (http.NewRequestWithContext) or TransportConfig.MaxElapsedTime.
func ParseRetryAfter ¶
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 ¶
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 ¶
Permanent wraps err to indicate it should never be retried. Mirrors cenkalti/backoff.Permanent().
func ReadLimitedBody ¶
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 GetBytes applies internally — exposed for callers that issue their own request and decode outside GetBytes but still want the fail-loud size bound. On any error the body is already closed.
func RedactSecret ¶
RedactSecret replaces occurrences of secret in err's message with "REDACTED".
func RedactSecretString ¶
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 ¶
RedactTransportError unwraps *url.Error and redacts the secret from the error message. Returns nil for nil input.
func RedirectPolicyFunc ¶
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 ¶
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 SafeDouble ¶
SafeDouble doubles a duration, guarding against int64 overflow.
Types ¶
type AuthError ¶
type AuthError struct{ Msg string }
AuthError indicates invalid or expired credentials.
type CheckRedirect ¶
CheckRedirect is the http.Client.CheckRedirect function shape. It is a type alias, so values are assignable in both directions; every shipped policy (DefaultRedirectPolicy, RefuseAllRedirects, DockerGitHubRedirectPolicy, and anything built with RedirectPolicyFunc) is a CheckRedirect.
type CheckRetry ¶
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 ¶
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 ¶
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 Do (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 DoOption ¶
type DoOption interface {
// contains filtered or unexported methods
}
DoOption configures Do. Options constructed as Option values apply to both loop doors; Do-only options (WithLabel, WithRateLimitRetry, WithRateLimitOnly) implement only this interface, so passing one to GetBytes is a compile error.
func WithLabel ¶
WithLabel sets the operation label used in Do's log lines ("<label> failed, retrying", "<label> retries exhausted"). Default: "operation".
func WithRateLimitOnly ¶
WithRateLimitOnly makes Do retry ONLY *RateLimitError (matched through wrapped errors); every other error, including transient transport errors, is returned immediately. It absorbs v2's RetryOnRateLimit: the wait semantics match WithRateLimitRetry, the terminal Warn is "rate limit retries exhausted", and, matching the v2 contract, the FINAL attempt's error wins even under an already-canceled context (cancellation is observed in the always-positive inter-attempt sleep instead). Mutually exclusive with WithRateLimitRetry.
func WithRateLimitRetry ¶
WithRateLimitRetry makes Do additionally treat *RateLimitError as retryable, alongside the transient set. The wait before a rate-limit retry is min(err.RetryAfter, maxWait) when the error carries a positive hint, else maxWait; a non-positive maxWait falls back to RetryAfterCap, so the wait is always positive and a canceled context is observed before every retry. Transient errors keep their jittered-backoff waits (with the RetryAfterHint override), and the exponential base advances on every retry. Mutually exclusive with WithRateLimitOnly (Do returns a configuration error when both are supplied).
type GetOption ¶
type GetOption interface {
// contains filtered or unexported methods
}
GetOption configures GetBytes. GetBytes-only options (WithHeaders, WithMaxBodyBytes) implement only this interface, so passing one to Do is a compile error.
func WithHeaders ¶
WithHeaders sets a function that is called to set headers on each request.
func WithMaxBodyBytes ¶
WithMaxBodyBytes sets the maximum response body size to read. Default: DefaultMaxBodyBytes (10 MB). A non-positive value falls back to the default.
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 ¶
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 ¶
Option is a retry option accepted by BOTH loop doors, Do and GetBytes: WithMaxAttempts, WithBaseDelay, and WithLogger construct Option values.
func WithBaseDelay ¶
WithBaseDelay sets the initial backoff delay. Default: DefaultBaseDelay (1s). A non-positive value falls back to the default.
func WithLogger ¶
WithLogger sets the logger for retry diagnostics. Default: slog.Default(). A nil logger falls back to the default.
func WithMaxAttempts ¶
WithMaxAttempts sets the maximum number of attempts (TOTAL, including the first call). Default: DefaultMaxAttempts (3). A value below 1 is treated as 1, so the operation always runs at least once (never a silent no-op).
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 ¶
PrepareRetry is called before each retry to allow request mutation (e.g., re-signing with a fresh token). Mirrors go-retryablehttp PrepareRetry.
type RateLimitError ¶
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). Do's rate-limit modes (WithRateLimitRetry, WithRateLimitOnly) already do this (they cap at their 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 ¶
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 ¶
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 GetBytes 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 GetBytes 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 ¶
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, Do 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); Do 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 TransportConfig.RetryNonIdempotent 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 (TransportConfig.MaxAttempts) rather than go-retryablehttp's retries-beyond-first.
func NewRetryRoundTripper ¶
func NewRetryRoundTripper(next http.RoundTripper, cfg TransportConfig) *RetryRoundTripper
NewRetryRoundTripper creates a RetryRoundTripper wrapping next with the given configuration. If next is nil, http.DefaultTransport is used. TransportConfig{} gives the defaults (see its field docs).
func (*RetryRoundTripper) RoundTrip ¶
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.
type StatusError ¶
StatusError represents a non-2xx response with URL context. Used by GetBytes. 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 TransportConfig ¶
type TransportConfig struct {
// CheckRetry overrides the retry policy. nil means the default policy:
// transient transport errors plus 429/502/503/504 responses (deliberately
// narrower than GetBytes, which retries every 5xx; supply a custom policy
// to broaden the set when an upstream returns transient 500s).
CheckRetry CheckRetry
// OnRetry is a hook called before each retry attempt, the transport's only
// observability seam (it logs nothing itself).
OnRetry OnRetry
// PrepareRetry is called before each retry to mutate the cloned request
// (e.g. refresh an auth token).
PrepareRetry PrepareRetry
// MaxAttempts is the TOTAL attempt count including the initial request.
// Zero means unset and takes DefaultMaxAttempts (3); a NEGATIVE value
// means exactly one attempt (the "try once" configuration — v2 expressed
// it as WithRTMaxAttempts(0), but a zero struct field cannot distinguish
// absent from zero, so v3 moves try-once to negatives).
MaxAttempts int
// BaseDelay is the initial backoff delay; non-positive means
// DefaultBaseDelay (1s). Waits are equal-jitter with overflow-safe
// doubling, and a retryable response's Retry-After header (capped at
// RetryAfterCap) overrides the computed wait.
BaseDelay time.Duration
// MaxElapsedTime is a hard ceiling on total time across retries,
// including any honored Retry-After: when now+wait would meet or pass it,
// the round-tripper aborts with an error instead of sleeping. It is
// checked BETWEEN attempts and cannot interrupt a stalled in-flight
// attempt; bound single attempts on the base transport (e.g.
// ResponseHeaderTimeout) and the whole call with the request context.
// Zero means no ceiling.
MaxElapsedTime time.Duration
// RetryNonIdempotent enables retry of non-idempotent methods (POST, PUT,
// PATCH, DELETE) when the request has a GetBody function for body replay.
RetryNonIdempotent bool
}
TransportConfig configures a RetryRoundTripper (and, through NewRetryClient, the retrying client). The zero value is ready to use and behaves exactly like an unconfigured v2 round-tripper: three total attempts, one-second base delay, the default retry policy, no hooks, no elapsed ceiling, no non-idempotent replay.
type Validators ¶
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).