httpcall

package module
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Dec 20, 2025 License: MIT Imports: 15 Imported by: 0

README

httpcall

Go reference Zero dependencies Go Report Card

Go library for making typical non-streaming single-roundtrip HTTP API calls. Yes, really.

Status: Code used in production for years. Rate limiting stuff is new, though.

Why?

Normally, Go apps are supposed to avoid stupid dependencies like this one.

This library saves you from four sins, however:

  • poor networking behaviors due to laziness (lack of retries or no rate limiting — networking is an area where “good enough for now” is a far cry from “good”)
  • unhelpful logs (why on earth did that call fail and what did it even say?)
  • subtle vulnerabilities (did you forget to limit response length?)
  • hard to extract common HTTP configuration across multiple subsystems

...and, of course, some would find it appealing just to avoid pasting the same 50-line HTTP helper again.

Features

  • makes simple things simple and hard things possible
  • makes your calling code easy to read
  • makes your HTTP configuration composable and centralizable
  • makes error handling easy
  • retries on failures if you allow
  • handles typical HTTP rate limiting headers (and you can plug in your own)

Usage

Installation:

go get github.com/andreyvit/httpcall@latest

Basic usage:

var out SomeResponse
r := &httpcall.Request{
	CallID:    "ListWidgets",
	Method:    http.MethodGet,
	BaseURL:   "https://api.example.com",
	Path:      "/v1/widgets",
	QueryParams: url.Values{
		"limit": {"100"},
	},
	OutputPtr:   &out,
	MaxAttempts: 3,
}
err := r.Do()
if err != nil {
	return err
}
Composable configuration

The intended way to use httpcall is to build requests locally, then let the common code and outer environment configure them (HTTP client, logging, retry rules, auth, throttling, etc).

All lifecycle hooks are composable: instead of overwriting r.Started, r.Failed, etc, use r.OnStarted, r.OnFailed, r.OnFinished, r.OnShouldStart, and r.OnValidate to add behavior without clobbering whatever was configured before.

Example:

func (app *MyApp) ConfigureHTTPRequest(r *httpcall.Request) {
	if r.HTTPClient == nil {
		r.HTTPClient = app.APIClient
	}

	r.OnStarted(func(r *httpcall.Request) {
		log.Printf("%s: %s %s", r.CallID, r.Method, r.Curl())
	})

	r.OnFinished(func(r *httpcall.Request) {
		if r.Error == nil {
			log.Printf("%s -> HTTP %d [%d ms]", r.CallID, r.StatusCode(), r.Duration.Milliseconds())
		} else {
			log.Printf("%s -> %s", r.CallID, r.Error.ShortError())
		}
	})
}

You would typically define a subsystem-local configuration function which sets BaseURL, ParseErrorResponse, auth headers and the like, and calls system-wide configuration function:

func (svc *SomeService) ConfigureHTTPRequest(r *httpcall.Request) {
	r.BaseURL = "https://api.example.com/v1"
	r.ParseErrorResponse = parseSomeServiceErrorResponse
	svc.app.ConfigureHTTPRequest(r)
}

and then specific calls would be like:

func (svc *SomeService) listWhatever() ([]*Whatever, error) {
	var whatevers []*Whatever
	r := &httpcall.Request{
		CallID:  "ListWhatever",
		Method:  http.MethodGet,
		Path:    "/whatevers",
		QueryParams: url.Values{
			"limit": {"100"},
		},
		OutputPtr: &whatevers,
		MaxAttempts: 3,
	}
	svc.ConfigureHTTPRequest(r)
	err := r.Do()
	return whatevers, err
}
Building URLs

Use BaseURL and Path for convenience, or just provide the full URL in Path. Use QueryParams to set the query string:

r := &httpcall.Request{
	Method:  http.MethodGet,
	BaseURL: "https://api.example.com/api",
	Path:    "/v1/widgets",
	QueryParams: url.Values{
		"limit": {"100"},
	},
}
// -> https://api.example.com/api/v1/widgets?limit=100

If Path contains ://, it is treated as a full URL and BaseURL is ignored. If can still pass QueryParams.

Use PathParams to substitute values in the path with proper escaping:

r := &httpcall.Request{
	Method:  http.MethodGet,
	BaseURL: "https://api.example.com",
	Path:    "/v1/widgets/{id}",
	PathParams: map[string]string{
		"{id}": "hello/world",
	},
}
// -> https://api.example.com/v1/widgets/hello%252Fworld

Use FullURLOverride for REST-style links like next/prev:

  • If FullURLOverride contains ://, it fully overrides the URL (BaseURL, Path and QueryParams are ignored).
  • Otherwise, it must start with / and replaces only the URL path (scheme/host are taken from BaseURL/Path, and QueryParams are preserved).
Input

If RawRequestBody is non-nil, it is sent as-is and Input is ignored. Otherwise, Input is encoded to populate RawRequestBody and set a default Content-Type.

JSON input
r := &httpcall.Request{
	CallID:    "CreateWidget",
	Method:    http.MethodPost,
	BaseURL:   "https://api.example.com",
	Path:      "/v1/widgets",
	Input:     &CreateWidgetRequest{Name: "hello"},
	OutputPtr: &out,
}
Form input (url.Values)
r := &httpcall.Request{
	Method: http.MethodPost,
	Path:   "https://example.com/oauth/token",
	Input: url.Values{
		"grant_type": {"client_credentials"},
	},
}
Binary/raw input
r := &httpcall.Request{
	CallID:                 "CreateWidget",
	Method:                 http.MethodPost,
	BaseURL:                "https://api.example.com",
	Path:                   "/v1/widgets",
	RawRequestBody:         []byte{1, 2, 3},
	RequestBodyContentType: "application/octet-stream",
	OutputPtr:              &out,
}
Output

httpcall always reads the body into r.RawResponseBody (bounded by MaxResponseLength).

A successful 2xx response is then parsed by r.ParseResponse if set, or via built-in logic if not. Built-in logic handles OutputPtr set to nil (ignored), *[]byte (stores raw bytes output), or a pointer to JSON-compatible data (unmarshals JSON).

A failed (non-2xx) response is parsed by r.ParseErrorResponse if set.

JSON output
var out SomeResponse
err := (&httpcall.Request{
	Method:    http.MethodGet,
	Path:      "https://api.example.com/v1/widgets",
	OutputPtr: &out,
}).Do()
Raw bytes output
var body []byte
err := (&httpcall.Request{
	Method:    http.MethodGet,
	Path:      "https://example.com/file.bin",
	OutputPtr: &body,
}).Do()
Ignore output
err := (&httpcall.Request{
	Method: http.MethodPost,
	Path:   "https://example.com/v1/widgets/123/refresh",
}).Do()
Custom parsing
var text string
err := (&httpcall.Request{
	Method: http.MethodGet,
	Path:   "https://example.com/healthz",
	ParseResponse: func(r *httpcall.Request) error {
		text = string(r.RawResponseBody)
		return nil
	},
}).Do()
Parsing error responses

When the server responds with a non-2xx status code, httpcall returns *httpcall.Error.

If ParseErrorResponse is set, it runs right after the response body has been read and before retry-delay initialization and before the Failed hook. It can:

  • decode r.RawResponseBody;
  • populate r.Error.Type and r.Error.Message for better logs/errors;
  • adjust r.Error.IsRetriable and/or set r.Error.RetryDelay;
  • set r.Error.PrintResponseBody to false to suppress a sensitive or long body when logging the error;
  • attach application-defined error categories (see below);
  • suppress the error entirely by setting r.Error = nil (use sparingly).

Example:

var InvalidEmailCategory = &httpcall.ErrorCategory{Name: "invalid_email"}

func parseSomeAPIErrorResponse(r *httpcall.Request) {
	var resp struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	}
	if json.Unmarshal(r.RawResponseBody, &resp) == nil {
		r.Error.Type = resp.Code
		r.Error.Message = resp.Message
	}
	switch r.Error.Type {
	case "server_overloaded":
		r.Error.IsRetriable = true
		r.Error.RetryDelay = 10 * time.Second
	case "invalid_email":
		r.Error.IsRetriable = false
		r.Error.AddCategory(InvalidEmailCategory)
	}
}

r := &httpcall.Request{
	Method:             http.MethodGet,
	Path:               "https://api.example.com/v1/widgets",
	ParseErrorResponse:  parseSomeAPIErrorResponse,
}
err := r.Do()
Error categories

Error categories are a lightweight way to attach application-defined classification to an *httpcall.Error.

Key properties:

  • Categories are compared by identity (pointer), so you typically define them as package-level globals.
  • A category can be checked with errors.Is(err, MyCategory) (because *httpcall.Error implements a custom Is method).

Example:

var InvalidEmailCategory = &httpcall.ErrorCategory{Name: "invalid_email"}

err := r.Do()

if errors.Is(err, InvalidEmailCategory) {
	// Handle as a user / data problem.
}
Validating output

ValidateOutput runs after a successful response has been parsed and lets you fail the call with a domain-specific error.

Example:

var out struct {
	ID string `json:"id"`
}
err := (&httpcall.Request{
	Method:    http.MethodGet,
	Path:      "https://api.example.com/v1/widgets/123",
	OutputPtr: &out,
	ValidateOutput: func() error {
		if out.ID == "" {
			return fmt.Errorf("missing id in response")
		}
		return nil
	},
}).Do()
Error type: httpcall.Error

If Do() returns an error, it is always *httpcall.Error, so you can access all the details.

err := r.Do()
if err != nil {
	var e *httpcall.Error
	if errors.As(err, &e) {
		_ = e.StatusCode
		_ = e.RawResponseBody
		_ = e.IsNetwork
		_ = e.IsRetriable
		_ = e.RetryDelay
	}
}
External sleep/reschedule via RetryDelay

If you want httpcall to compute a backoff delay but not to sleep/retry by itself, set MaxAttempts=1 and use Error.RetryDelay:

r.MaxAttempts = 1
err := r.Do()

var e *httpcall.Error
if errors.As(err, &e) && e.IsRetriable && e.RetryDelay > 0 {
	time.Sleep(e.RetryDelay) // or reschedule a job instead of sleeping
}

Error.RetryDelay is initialized by Do() before calling the Failed hook, so hooks can safely read and adjust it.

Logging (including error responses)

*httpcall.Error is designed to be log-friendly:

  • e.ShortError() omits CallID and status code (useful when you already log those separately).
  • e.Error() includes CallID/status and, when PrintResponseBody is true, includes the raw response body inline.
  • e.RawResponseBody is always available if you prefer structured logging.

Example:

if err := r.Do(); err != nil {
	e := err.(*httpcall.Error)
	log.Printf("%s -> %s (retry=%v) curl=%s", r.CallID, e.Error(), e.RetryDelay, r.Curl())
}

Be mindful of secrets: if responses can contain credentials/PII, you may want to set e.PrintResponseBody = false in ParseErrorResponse or Failed.

Checking categories

If you attach categories (see above), you can branch on them:

if errors.Is(err, InvalidEmailCategory) {
	// ...
}
Custom final logs via OnFinished

OnFinished runs exactly once per Do() call and is often the cleanest place to emit logs because it has access to everything: status code, duration, curl, retry delay, and error response body.

r.OnFinished(func(r *httpcall.Request) {
	if r.Error == nil {
		log.Printf("%s -> HTTP %d [%d ms]", r.CallID, r.StatusCode(), r.Duration.Milliseconds())
		return
	}
	log.Printf("%s -> HTTP %d [%d ms] retry=%v err=%s",
		r.CallID,
		r.StatusCode(),
		r.Duration.Milliseconds(),
		r.Error.RetryDelay,
		r.Error.Error(),
	)
})
Retries and rate limiting

Set MaxAttempts to more than 1 to enable automatic retries. Set RetryDelay to adjust the delay between retries, or use OnFailed hook for custom retry logic.

Do() retries a failed request (r.Error != nil) if r.Error.IsRetriable is true (by default, this means network errors, HTTP 5xx and HTTP 429), and r.Attempts < r.MaxAttempts. By default, IsRetriable is set to true for network errors, HTTP 5xx on idempotent requests, and HTTP 429.

HTTP 429 (Too Many Requests) is retriable even for non-idempotent methods (POST/PUT/PATCH/DELETE), because the server is telling you it did not accept the request due to throttling. HTTP 5xx is not, because we do not know if the server started executing the request before failing.

You can adjust which requests are retried and the delays used by setting r.IsRetriable in a Failed hook.

Retry timeline:

  1. When an error response is encountered, r.ParseErrorResponse hook is called to populate r.Error details; it can set r.Error.RetryDelay among other fields. Note that if you set a non-zero retry delay at this stage, it will be used without any further adjustments.
  2. After each attempt finishes, httpcall computes r.RateLimitDelay using r.ComputeRateLimitDelay hook (defaults to httpcall.ComputeDefaultRateLimitDelay).
  3. If r.Error.RetryDelay is still zero, it will be initialized to r.RateLimitDelay or r.RetryDelay or httpcall.DefaultRetryDelay, whichever is non-zero. This is where rate limit delay gets applied.
  4. Then Failed hook runs, and it is free to adjust both r.Error.IsRetriable and r.Error.RetryDelay.
  5. If the error is non-retriable, or max attempts have been reached, or the resulting r.Error.RetryDelay is more than r.MaxAllowedDelay (defaults to httpcall.DefaultMaxAllowedDelay = 65s), Do() returns the error immediately without sleeping or retrying.
  6. Otherwise, Do() sleeps for r.Error.RetryDelay and retries.
Automatic rate limiting support

r.ComputeRateLimitDelay defaults to ComputeDefaultRateLimitDelay, which treats a response as rate-limited when either:

  • status is HTTP 429, or
  • RateLimit-Remaining / X-Ratelimit-Remaining is present and equals 0.

ComputeDefaultRateLimitDelay uses Retry-After or X-RateLimit-Reset* headers when available, and returns r.RateLimitFallbackDelay (default httpcall.DefaultRateLimitFallbackDelay = 1m) for 429 requests with no usable reset information.

You can override all of that logic by replacing r.ComputeRateLimitDelay with your own function.

After a delay is computed:

  • it is used by automatic retries logic by defaulting r.Error.RetryDelay to r.RateLimitDelay
  • you can read r.Error.RetryDelay in the returned error response to throttle failures
  • you can read r.RateLimitDelay to throttle after successful calls
Example: exponential backoff

You can implement your own backoff policy using OnFailed:

delays := []time.Duration{time.Second, 4 * time.Second, 16 * time.Second, time.Minute}

r.OnFailed(func(r *httpcall.Request) {
	next := delays[min(r.Attempts-1, len(delays)-1)]
	r.Error.RetryDelay = max(next, r.RateLimitDelay)
})
Rate limiting adjustments
  • Disable rate-limit delay computation: r.ComputeRateLimitDelay = httpcall.NoRateLimitDelay.
  • Replace it completely: set r.ComputeRateLimitDelay to your own function.
  • Tune rate-limit parsing: MinRateLimitDelay, RateLimitExtraBuffer (a clock skew buffer added to server-reported time), RateLimitFallbackDelay (a delay to use when server doesn't provide a usable one), MaxAllowedDelay.
Debugging with curl

r.Curl() formats the request as a runnable curl command. You can log this to make debugging easier.

Response size limit

Response body is limited to MaxResponseLength bytes (defaults to httpcall.DefaultMaxResponseLength). When exceeded, Do() fails with httpcall.ErrResponseTooLong (wrapped in an *httpcall.Error, of course).

License

Copyright 2023–2025, Andrey Tarantsov. Published under the terms of the MIT license.

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

Examples

Constants

View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
const DefaultRetryDelay time.Duration = 500 * time.Millisecond

DefaultRetryDelay is the delay between automatic retries when neither Error.RetryDelay nor Request.RetryDelay is set.

Variables

View Source
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

func ComputeDefaultRateLimitDelay(r *Request) time.Duration

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

func NoRateLimitDelay(_ *Request) time.Duration

NoRateLimitDelay is a ComputeRateLimitDelay implementation that disables rate-limit based delay computation.

It always returns 0.

func ShellQuote

func ShellQuote(source string) string

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

func (e *Error) Error() string

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

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

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

func (e *Error) IsUnprocessableEntity() bool

IsUnprocessableEntity reports whether StatusCode is HTTP 422.

func (*Error) ShortError

func (e *Error) ShortError() string

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

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the underlying cause, enabling errors.Is / errors.As.

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

func (r *Request) Clone() *Request

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

func (r *Request) Curl() string

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

func (r *Request) Do() error

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:

  1. Error.RetryDelay (if already set)
  2. RateLimitDelay (computed from response headers; see ComputeRateLimitDelay)
  3. Request.RetryDelay
  4. 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

func (r *Request) IsIdempotent() bool

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

func (r *Request) OnFailed(f func(r *Request))

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

func (r *Request) OnFinished(f func(r *Request))

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

func (r *Request) OnShouldStart(f func(r *Request) error)

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

func (r *Request) OnStarted(f func(r *Request))

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

func (r *Request) OnValidate(f func() error)

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

func (r *Request) SetHeader(key, value string)

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

func (r *Request) StatusCode() int

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

Jump to

Keyboard shortcuts

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