Documentation
¶
Overview ¶
Package httpcall takes boilerplate out of typical non-streaming single-roundtrip HTTP API calls.
This package is intentionally small and opinionated:
- One request → one response body (no streaming).
- Automatic JSON request/response handling for the common case.
- A safety response-size limit (to avoid accidentally downloading huge payloads).
- Built-in retry loop (network errors + HTTP 5xx) with pluggable policy.
- Rate-limit aware retry delays (HTTP 429 and common X-RateLimit-* headers).
The key design feature is composable configuration.
Instead of having every API client reinvent retries, logging, auth, rate limiting, etc, you build a Request close to the call site and then let the "outer environment" configure it by composing hooks with OnStarted/OnFailed/ OnFinished/OnShouldStart/OnValidate. This lets wrappers add behavior without clobbering existing configuration.
Index ¶
- Constants
- Variables
- func ComputeDefaultRateLimitDelay(r *Request) time.Duration
- func NoRateLimitDelay(_ *Request) time.Duration
- func ShellQuote(source string) string
- type BasicAuth
- type Error
- type ErrorCategory
- type Request
- func (r *Request) Clone() *Request
- func (r *Request) Curl() string
- func (r *Request) Do() error
- func (r *Request) Init()
- func (r *Request) IsIdempotent() bool
- func (r *Request) OnFailed(f func(r *Request))
- func (r *Request) OnFinished(f func(r *Request))
- func (r *Request) OnShouldStart(f func(r *Request) error)
- func (r *Request) OnStarted(f func(r *Request))
- func (r *Request) OnValidate(f func() error)
- func (r *Request) SetHeader(key, value string)
- func (r *Request) StatusCode() int
Examples ¶
Constants ¶
const DefaultMaxAllowedDelay = 65 * time.Second
DefaultMaxAllowedDelay is the default upper bound for retry sleeps.
If a computed retry delay exceeds this value, Do() returns the error without sleeping/retrying. This avoids requests getting stuck behind very long retry windows (for example, 15-minute rate limits) when the caller would rather handle the situation explicitly.
const DefaultMaxResponseLength int64 = 100 * 1024 * 1024
DefaultMaxResponseLength is the default maximum number of bytes read from the response body.
It exists as a safety net: most API calls should not download huge payloads. Override per call via Request.MaxResponseLength.
The current value is 100 MB, but it can be adjusted in later versions. If you rely on accepting huge respones, set an explicit value.
const DefaultMinRateLimitDelay = time.Second
DefaultMinRateLimitDelay is the default minimum delay returned by ComputeDefaultRateLimitDelay.
This is a safety net against returning zero/negative delays when a server's rate-limit reset time is already in the past (clock skew, stale headers, etc).
const DefaultRateLimitExtraBuffer = time.Second
DefaultRateLimitExtraBuffer is added to header-derived delays returned by ComputeDefaultRateLimitDelay.
This is a pragmatic clock-skew / timing buffer: even if a server says "retry in 30 seconds", it's often safer to wait a bit longer to avoid immediately hitting the limit again.
Note: a Request's RateLimitExtraBuffer uses 0 to mean "use the default". To effectively disable the buffer, set Request.RateLimitExtraBuffer to 1ns.
const DefaultRateLimitFallbackDelay = time.Minute
DefaultRateLimitFallbackDelay is used by ComputeDefaultRateLimitDelay when a response appears to be rate-limited (HTTP 429 or remaining==0), but none of the supported headers provide a concrete retry time.
This is intentionally conservative: without a reset time, retrying too aggressively often causes tight 429 loops.
const DefaultRetryDelay time.Duration = 500 * time.Millisecond
DefaultRetryDelay is the delay between automatic retries when neither Error.RetryDelay nor Request.RetryDelay is set.
Variables ¶
var ErrResponseTooLong = errors.New("response too long")
ErrResponseTooLong is returned (wrapped in *Error) when reading the response body exceeds Request.MaxResponseLength / DefaultMaxResponseLength.
Functions ¶
func ComputeDefaultRateLimitDelay ¶
ComputeDefaultRateLimitDelay computes a retry delay from common rate-limit headers on r.HTTPResponse.
Supported signals:
- HTTP 429 (Too Many Requests)
- X-Ratelimit-Remaining / RateLimit-Remaining == 0
- Retry-After (seconds or HTTP date)
- X-Ratelimit-Reset (unix seconds, unix milliseconds, or delta seconds)
- X-Ratelimit-Reset-After (delta seconds)
- RateLimit-Remaining / RateLimit-Reset (IETF draft; treated as remaining==0 and delta seconds)
The returned delay is at least Request.MinRateLimitDelay (or DefaultMinRateLimitDelay if unset) and includes Request.RateLimitExtraBuffer (or DefaultRateLimitExtraBuffer if unset). If the call appears to be rate-limited but no supported headers provide a concrete retry time, the returned delay falls back to Request.RateLimitFallbackDelay (or DefaultRateLimitFallbackDelay if unset).
If the response is not considered rate-limited, the result is 0.
func NoRateLimitDelay ¶
NoRateLimitDelay is a ComputeRateLimitDelay implementation that disables rate-limit based delay computation.
It always returns 0.
func ShellQuote ¶
ShellQuote quotes a string for use as a single shell argument.
It prefers single quotes when possible, and falls back to double quotes with backslash escaping when the string contains single quotes.
Types ¶
type BasicAuth ¶
type BasicAuth struct {
// Username is the HTTP Basic username.
Username string
// Password is the HTTP Basic password.
Password string
}
BasicAuth holds credentials for HTTP Basic authentication.
type Error ¶
type Error struct {
// CallID is copied from Request.CallID.
CallID string
// IsNetwork is true for errors that occurred before a valid HTTP response was
// received (DNS, timeouts, connection resets, etc), and for some parsing
// failures that heuristically look like "we didn't get the expected API
// response at all".
IsNetwork bool
// IsRetriable controls retry behavior in Do().
// It is set by httpcall (network errors and HTTP 5xx) but can be overridden
// by hooks.
IsRetriable bool
// RetryDelay optionally overrides the delay before the next retry attempt.
//
// In Do(), RetryDelay is initialized before calling Request.Failed (if it was
// still zero), so Failed hooks can read and adjust it. The default
// initialization order is:
//
// 1. Request.RateLimitDelay (computed from headers)
// 2. Request.RetryDelay
// 3. DefaultRetryDelay
RetryDelay time.Duration
// StatusCode is the HTTP status code, or 0 if a response was never received.
StatusCode int
// Type is an optional machine-friendly classification (often an API error code).
Type string
// Path is an optional locator for where an error occurred (for example, a JSON
// path inside a response).
Path string
// Message is an optional human-friendly summary.
Message string
// RawResponseBody contains the response body read by httpcall, if any.
RawResponseBody []byte
// PrintResponseBody controls whether Error() includes RawResponseBody inline.
// This is useful for logs, but should be used with care if responses might
// contain secrets.
PrintResponseBody bool
// Cause is the underlying error (network failure, JSON parsing error, a
// validation error, etc).
Cause error
// contains filtered or unexported fields
}
Error represents a failed Request.
Do() returns *Error. The underlying cause is available in Cause and via errors.Unwrap / errors.Is / errors.As.
The intent is to make logging/observability easy: Error stores the request identity, status code (if any), the raw response body, and flags used by retry logic.
func (*Error) AddCategory ¶
func (e *Error) AddCategory(cat *ErrorCategory) *Error
AddCategory attaches a category to the error and returns e.
func (*Error) Error ¶
Error formats the error with request identity (CallID/status) when available.
If PrintResponseBody is true, Error may include the response body in the message. See ShortError for a more compact variant.
func (*Error) Is ¶
Is implements a custom errors.Is behavior: comparing an Error against an *ErrorCategory checks category membership.
func (*Error) IsInCategory ¶
func (e *Error) IsInCategory(cat *ErrorCategory) bool
IsInCategory reports whether this error has the specified category attached.
func (*Error) IsUnprocessableEntity ¶
IsUnprocessableEntity reports whether StatusCode is HTTP 422.
func (*Error) ShortError ¶
ShortError formats the error without including request identity (CallID and status code).
This is useful in logs where the call identity is already included elsewhere (for example, when logging "CallID -> ShortError()").
type ErrorCategory ¶
type ErrorCategory struct {
// Name is an arbitrary identifier for the category.
//
// Categories are intended for business-level classification (as opposed to
// transport-level classification like "network error" or "HTTP 500").
Name string
}
ErrorCategory is an application-defined classification that can be attached to an Error.
Categories are intended for business-level routing/handling ("unsupported event", "invalid credentials", "rate limited"), rather than transport-level classification.
A category is comparable by identity (pointer): you typically define package globals and check them with errors.Is / Error.IsInCategory.
func (*ErrorCategory) Error ¶
func (err *ErrorCategory) Error() string
Error returns the category name (so categories can be treated as errors).
type Request ¶
type Request struct {
// Context is used for request cancellation and for sleeps between retries.
// If nil, context.Background() is used.
Context context.Context
// CallID is an optional human-friendly identifier used in errors/logging.
CallID string
// Method is required unless HTTPRequest is provided.
Method string
// BaseURL is joined with Path when Path is not an absolute URL. Example:
// "https://api.example.com".
BaseURL string
// Path is either a URL path (when used with BaseURL) or an absolute URL
// when it contains "://".
Path string
// PathParams provides placeholder substitutions in Path. Each key is
// replaced with the URL-path-escaped value via strings.ReplaceAll.
//
// This is intentionally "dumb" (no templating), but extremely effective in
// practice and helps avoid subtle double-encoding bugs.
PathParams map[string]string
// QueryParams are encoded and appended to the final URL.
QueryParams url.Values
// FullURLOverride is for APIs using REST-style links.
//
// If it contains "://", it is treated as an absolute URL and overrides
// BaseURL, Path and QueryParams entirely.
//
// Otherwise it must start with "/" and replaces only the URL path.
FullURLOverride string
// Input is marshaled into the request body unless RawRequestBody is
// provided.
//
// If Input is url.Values, it is form-encoded. Otherwise it is
// JSON-marshaled.
Input any
// RawRequestBody, when non-nil, is used as-is. When nil and Input is
// provided, it is computed from Input.
RawRequestBody []byte
// RequestBodyContentType is applied as the "Content-Type" header.
//
// When Input is marshaled automatically, it defaults to JSON or
// form-encoding content types.
RequestBodyContentType string
// Headers are applied to the request. They are copied into HTTPRequest and
// then r.Headers is set to the final request headers map.
Headers http.Header
// BasicAuth sets the HTTP Basic Authorization header (unless overwritten by
// Headers).
BasicAuth BasicAuth
// HTTPRequest can be provided to bypass URL/body construction. If provided,
// Method and Path are derived from it unless explicitly set.
HTTPRequest *http.Request
// OutputPtr controls response parsing:
//
// - nil: body is read but ignored on 2xx
// - *[]byte: raw response body is copied as-is
// - any other pointer: body is unmarshaled as JSON
OutputPtr any
// MaxResponseLength limits response body size for this request. If 0,
// DefaultMaxResponseLength is used. If negative, response size is
// unlimited.
MaxResponseLength int64
// HTTPClient is used to perform the request. If nil, http.DefaultClient is
// used.
HTTPClient *http.Client
// MaxAttempts controls retry behavior. 1 or 0 means "no retries".
MaxAttempts int
// RetryDelay is used between retries when Error.RetryDelay is not set. If
// 0, DefaultRetryDelay is used.
RetryDelay time.Duration
// LastRetryDelay records the most recent delay actually used between
// attempts.
LastRetryDelay time.Duration
// ComputeRateLimitDelay computes a retry delay based on rate-limit response
// headers.
//
// This hook is intentionally NOT composable: setting it overrides all
// previous behavior. Set it to a no-op function that returns 0 to disable
// rate-limit delay handling entirely -- you can use [NoRateLimitDelay] as
// such a function.
//
// If nil, ComputeDefaultRateLimitDelay is used.
ComputeRateLimitDelay func(r *Request) time.Duration
// MinRateLimitDelay controls the minimum delay returned by
// ComputeDefaultRateLimitDelay for rate-limited responses.
//
// If zero, DefaultMinRateLimitDelay is used.
MinRateLimitDelay time.Duration
// RateLimitExtraBuffer is added to header-derived delays returned by
// ComputeDefaultRateLimitDelay.
//
// This is a pragmatic clock-skew / timing buffer. Since 0 means "use the
// default" (DefaultRateLimitExtraBuffer), set this to 1ns to effectively
// disable the buffer.
RateLimitExtraBuffer time.Duration
// RateLimitFallbackDelay is used by ComputeDefaultRateLimitDelay when a
// response appears to be rate-limited but doesn't provide a usable reset
// time via headers.
//
// Why not just use MinRateLimitDelay? MinRateLimitDelay is a *floor* for
// computed, i.e. server-provided delays. When the call is clearly
// rate-limited but the server doesn't provide a concrete retry time,
// blindly retrying after MinRateLimitDelay (often 1s) results in us
// hammering the server in a tight loop. RateLimitFallbackDelay provides a
// conservative backoff for the "no usable headers" case.
//
// Since 0 means "use the default" (DefaultRateLimitFallbackDelay), set this
// to 1ns to effectively disable the fallback (i.e. make it not exceed
// MinRateLimitDelay). Note that MinRateLimitDelay still applies as a floor.
RateLimitFallbackDelay time.Duration
// RateLimitDelay is the computed rate-limit delay from the last attempt.
// It is set right after the HTTP round-trip completes (after doOnce, before
// Failed hooks and retry decisions).
RateLimitDelay time.Duration
// MaxAllowedDelay is an upper bound for retry sleeps.
//
// If a computed delay exceeds this value, Do() returns the error without
// sleeping/retrying.
//
// If zero, DefaultMaxAllowedDelay is used.
MaxAllowedDelay time.Duration
// ShouldStart is called right before each attempt. Returning an error
// aborts the call and Do returns an *Error wrapping it.
ShouldStart func(r *Request) error
// Started is called at the beginning of each attempt, after ShouldStart.
Started func(r *Request)
// ValidateOutput runs after a successful (2xx) response is parsed. If it
// returns an error, the request fails with IsRetriable=false.
ValidateOutput func() error
// ParseResponse overrides response parsing for 2xx responses. It can
// populate OutputPtr (or ignore it) as it likes.
ParseResponse func(r *Request) error
// ParseErrorResponse can inspect/transform Error for non-2xx responses. It
// may set r.Error fields, or even set r.Error=nil to suppress the error.
ParseErrorResponse func(r *Request)
// Failed is called after an attempt fails (r.Error != nil), before retry
// logic.
Failed func(r *Request)
// Finished is called once after all attempts (success or final failure).
Finished func(r *Request)
// UserObject and UserData are reserved for callers/frameworks to attach
// arbitrary data for logging/metrics/correlation, etc.
UserObject any
UserData map[string]any
// Attempts is the number of attempts performed by the most recent Do().
Attempts int
// HTTPResponse is set after the HTTP round-trip succeeds.
HTTPResponse *http.Response
// RawResponseBody is the raw body read from HTTPResponse.Body (bounded by
// MaxResponseLength).
RawResponseBody []byte
// Error is the last error produced by Do(), if any.
Error *Error
// Duration measures the time spent inside a single attempt.
Duration time.Duration
// contains filtered or unexported fields
}
Request describes a single HTTP API call.
Typical usage:
r := &httpcall.Request{
CallID: "ListWidgets",
Method: http.MethodGet,
BaseURL: "https://api.example.com",
Path: "/v1/widgets",
OutputPtr: &out,
MaxAttempts: 3,
}
configure(r)
err := r.Do()
Init() freezes derived state (it builds HTTPRequest and marshals Input). If you want to reuse a request as a template, use Clone() and modify the clone before calling Do().
Example ¶
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/andreyvit/httpcall"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"answer":42}`))
}))
defer srv.Close()
var resp struct {
Answer int `json:"answer"`
}
err := (&httpcall.Request{
Context: context.Background(),
CallID: "Answer",
Method: http.MethodGet,
Path: srv.URL,
OutputPtr: &resp,
MaxAttempts: 1,
}).Do()
fmt.Println(err == nil, resp.Answer)
}
Output: true 42
Example (ComposableConfiguration) ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/andreyvit/httpcall"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
var log []string
var resp struct {
OK bool `json:"ok"`
}
r := &httpcall.Request{
CallID: "Composed",
Method: http.MethodGet,
Path: srv.URL,
OutputPtr: &resp,
MaxAttempts: 1,
}
r.OnStarted(func(r *httpcall.Request) { log = append(log, "started-1") })
r.OnStarted(func(r *httpcall.Request) { log = append(log, "started-2") })
r.OnFinished(func(r *httpcall.Request) { log = append(log, "finished") })
_ = r.Do()
fmt.Println(resp.OK)
fmt.Println(strings.Join(log, ","))
}
Output: true started-1,started-2,finished
func (*Request) Clone ¶
Clone makes a shallow copy of the request and clones Headers/QueryParams maps.
This is primarily meant for "template request" patterns where some parts are shared and then specialized per call.
func (*Request) Curl ¶
Curl returns a runnable `curl` command that mirrors this request.
This is primarily meant for logging/debugging production issues.
Includes request method, headers, body and URL. Uses ShellQuote for safe shell escaping.Headers are emitted in a stable (sorted) order to make logs diffable.
Curl calls Init(), so it reflects the prepared HTTPRequest (including any marshaled body).
func (*Request) Do ¶
Do executes the HTTP request with optional retries.
Retries happen only when Error.IsRetriable is true and Attempts < MaxAttempts.
By default, httpcall marks these as retriable:
- network errors (no response received)
- HTTP 5xx responses
- HTTP 429 (Too Many Requests)
Delay selection:
- Error.RetryDelay (if already set)
- RateLimitDelay (computed from response headers; see ComputeRateLimitDelay)
- Request.RetryDelay
- DefaultRetryDelay
Before sleeping, Do enforces MaxAllowedDelay (default: DefaultMaxAllowedDelay). If the selected delay exceeds this value, Do returns the error without sleeping/retrying.
The returned error, if any, is always *Error.
func (*Request) Init ¶
func (r *Request) Init()
Init prepares the request for execution.
It is automatically called by Do(), Curl(), and IsIdempotent().
Init is idempotent: subsequent calls are no-ops. That also means that if you modify request fields after Init has run, the changes will not be reflected in the prepared HTTPRequest or request body.
Panics:
- if neither HTTPRequest nor Method is set
- if neither HTTPRequest nor (BaseURL/Path) is set
- if Method is GET/HEAD and a body is specified
- if FullURLOverride is a relative path not starting with "/"
func (*Request) IsIdempotent ¶
IsIdempotent reports whether the HTTP method is considered idempotent by this package (GET or HEAD).
This could be used by outer layers to disallow mutations in read-only mode.
func (*Request) OnFailed ¶
OnFailed composes a new Failed hook on top of the existing one.
Order: The new hook runs first, then the previous hook.
This order is useful when the "inner" call site wants to run logic before a broader framework handler runs (for example, to modify Error.IsRetriable or to wrap the error cause).
func (*Request) OnFinished ¶
OnFinished composes a new Finished hook on top of the existing one.
Order: The new hook runs first, then the previous hook.
func (*Request) OnShouldStart ¶
OnShouldStart composes a new ShouldStart hook on top of the existing one.
Order: The previous hook runs first; if it returns an error, the new hook is not run.
func (*Request) OnStarted ¶
OnStarted composes a new Started hook on top of the existing one.
Order: The previous hook runs first, then the new hook.
func (*Request) OnValidate ¶
OnValidate composes a new ValidateOutput hook on top of the existing one.
Order: The previous hook runs first; if it returns an error, the new hook is not run.
func (*Request) SetHeader ¶
SetHeader sets a header in r.Headers, creating the header map if needed.
Prefer this helper when building requests so callers don't need to remember to initialize Headers.
func (*Request) StatusCode ¶
StatusCode returns the HTTP status code from the last response, or 0 if the request hasn't received a response yet (e.g. network error before headers).