httpclient

package
v10.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: AGPL-3.0 Imports: 37 Imported by: 0

Documentation

Overview

Package httpclient constructs HTTP clients with optional OpenTelemetry tracing instrumentation, resilience middleware, and response caching.

Clients are built with functional options:

client, err := httpclient.NewHTTPClient(
	httpclient.WithTimeout(5*time.Second),
	httpclient.WithTracing(true),
)

Options are applied in order, so a later one overrides an earlier one. An environment-loaded Config expresses itself as Options via Config.Options, so a config-driven client is built the same way, and individual settings can still be overridden after it:

client, err := httpclient.NewHTTPClient(append(cfg.Options(), httpclient.WithTracing(true))...)

The error reports only that the client could not be instrumented — the metrics provider refused an instrument. Nothing else here can fail.

The exchange

A client is not the whole job. Every service-to-service caller then writes the same layer on top of one — marshal, build the request, send it, check the status, read the body, close it, unmarshal — and writes it slightly differently. Exchange is that layer, once:

claim, err := httpclient.Exchange[ClaimResponse](ctx, client, http.MethodPost, url, request)

It is generic over the response type. A nil request body sends nothing at all rather than an encoded null, and NoContent is the response type for a reply whose body is not read. Anything outside 2xx is a *StatusError carrying the status, the request path, and a bounded prefix of the body.

That bound is the detail everyone forgets, and the reason it is here rather than at each call site. An error body goes into a log line, and a four-megabyte HTML error page from a proxy is how that becomes an incident of its own — so the limit is on the read and not merely on the string, and the cut lands on a rune boundary. WithErrorBodyLimit moves it; zero keeps the status and none of the body. A body that is not text at all — a CBOR problem document, a gzip stream from a confused gateway — is reported as its size and media type rather than run through a string, because mojibake in a log line fails at a UTF-8 column one layer away from anything that explains it.

The encoding is a choice, and JSON is only its default

Encoding is client-side, through the encoding package's client seam — not its ServerEncoderDecoder, which is about writing responses. Every content type that package implements is a peer here, named with WithContentType, which sets the request's Content-Type, the Accept it asks for, and the codec the reply is decoded with:

doc, err := httpclient.Exchange[Manifest](ctx, client, http.MethodGet, url, nil,
	httpclient.WithContentType(encoding.ContentTypeCBOR),
)

Unnamed, it is DefaultContentType, which is JSON. That is a default and not a rule — it reflects what the overwhelming majority of these calls speak, and nothing in an exchange is written in terms of JSON specifically. A content type the encoding package does not implement is an error rather than a fall back to JSON, which is the answer encoding.ParseContentType gives and for the same reason: silently standing in for JSON turns a typo into a request some server answers wrongly, with nothing to say so.

The reply is decoded with the codec the caller named, whatever the response's Content-Type says. A server that answers JSON while labeling it text/plain is common, and reading the response header instead would refuse exactly the case that leniency exists for.

The body is marshaled to bytes rather than streamed, so the request carries a GetBody and the retry transport below can replay it.

BaseURLClient binds a client to one service's root, so a call site names a path:

leader, err := httpclient.NewBaseURLClient(client, "https://leader.internal/api")
claim, err := httpclient.Exchange[ClaimResponse](ctx, leader, http.MethodPost, "/v1/claim", request)

It is the other half every consumer rebuilds, and the joining is url.URL.JoinPath's rather than string concatenation, which is where the doubled slash and the missing one come from.

The exchange adds no resilience

Retrying, breaking, limiting, and caching belong to the transports the client was built with, and are finished by the time the exchange reads a status. A helper that retried on top of them would give a client configured with WithRetryPolicy two nested loops and its caller no way to predict how many requests one call makes.

What the error does carry is the classification those transports already use. A *StatusError whose status DefaultRetryClassification calls terminal matches retry.ErrUnretryable under errors.Is, so a caller wrapping a whole operation in a retry.Policy of its own stops on a 400 and keeps trying a 429 — the same rule, read from an error instead of from a response, rather than a second copy of it free to drift.

Resilience

Retry, circuit breaking, rate limiting, and response caching are http.RoundTripper middlewares, composed at construction rather than at every call site:

client := httpclient.NewHTTPClient(
	append(cfg.Options(),
		httpclient.WithRetryPolicy(policy),
		httpclient.WithCircuitBreaker(breaker),
		httpclient.WithRateLimit(limiter),
		httpclient.WithHTTPCache(store),
	)...,
)

Each is off unless named. A client built without them behaves exactly as it did before they existed, and the collaborators come from the packages that own them — retry.Policy, circuitbreaking.CircuitBreaker, ratelimiting.RateLimiter, cache.Cache — so this package configures none of them and picks no defaults on their behalf.

They are also not resolved from the injector. RegisterHTTPClient takes them as options like everything else, because a RateLimiter or a CircuitBreaker in a container is far more often the one guarding the service's own inbound API, and silently repurposing it to throttle outbound calls would be a surprise nobody asked for. The same holds for a cache, doubly so: a registered cache.Cache is the service's own, and quietly filling it with third-party HTTP responses would evict what it was built to hold.

Response caching

The resilience transports protect an origin from failure, not from repetition. WithHTTPCache adds the fourth middleware, over any cache.Cache — memory for a per-process cache, redis for a fleet-wide one:

client, err := httpclient.NewHTTPClient(
	httpclient.WithHTTPCache(store, httpclient.WithCacheTTL(5*time.Minute)),
)

The policy is RFC 9111 read narrowly. GET and HEAD only; Cache-Control and Expires decide freshness; ETag and Last-Modified drive revalidation, and a 304 refreshes the stored entry rather than replacing it. Freshness is judged against a clock.Clock, so expiry is assertable in a test rather than slept through.

The explicit TTL is the reason most callers want this at all. JWKS documents, .well-known metadata, and catalog endpoints are routinely served with no freshness headers, and the hand-rolled TTL map that grows in front of them has no revalidation, no Vary, and no size bound. WithCacheTTL is consulted last, so naming one cannot make this client hold a response longer than the origin permitted — it only fills the silence.

Two bounds are worth knowing. Bodies above WithMaxCacheableBody are returned in full and not stored, so one large document cannot evict everything else. And one variant per URL is retained: an entry records the request-header values named by the response's Vary, and a request whose values differ is a miss rather than a wrong answer.

Retention and freshness are separate knobs on purpose. The cache's own default expiry decides how long an entry is kept; the headers above decide when it must be revalidated. Keeping an entry past its freshness is what makes a 304 possible, so a store that expires entries the moment they go stale gives up the cheaper half of this.

What is never cached

A response to a request bearing Authorization, unless WithCacheAuthorized says otherwise — and even then the credential becomes part of the cache key, so two callers holding different tokens get different entries rather than each other's. A shared Redis serving one tenant's response to another is the failure this is designed against, not a corner case.

Also never stored: responses marked no-store or private, responses setting cookies, responses that Vary: *, and any request already running its own conditional exchange — an If-None-Match or a Range the caller set is a precondition this transport has no business answering from a stored copy.

Request signing

WithRequestSigning stamps an HMAC signature over every outgoing request body, so a first-party callee can prove the call came from a holder of the shared key:

client, err := httpclient.NewHTTPClient(
	httpclient.WithRequestSigning(signer),
	httpclient.WithRetryPolicy(policy),
)

The signer comes from cryptography/requestsigning, whose keys are resolved through secrets rather than captured at construction, so a rotation reaches the wire without a restart. The inbound counterpart is requestsigning/http's middleware, over the same scheme — one configuration governs both directions.

Every signed request's body is buffered whole, because a MAC over it cannot be computed any other way. A client that streams large uploads should not sign them.

The nesting is fixed

Outermost to innermost: observability, response cache, circuit breaker, retry, rate limit, request signing, tracing, base transport. Option order does not change it, because only one arrangement of these layers is right and a caller who got it wrong would hold a client that looks protected and is not.

The cache is outermost because a hit is not a request. It reaches no wire, so it must not report an outcome to a circuit breaker or spend a token from a budget that counts requests the origin actually saw. A miss or a revalidation passes through all three resilience layers exactly as an uncached request would.

The breaker is next so an open circuit rejects before the retry loop is entered — failing fast once, rather than three times with backoff in between. It therefore judges a host on final outcomes, after retrying has already absorbed the transients.

The rate limiter is innermost so every attempt the retry loop makes spends a token. A provider's documented budget counts requests on the wire, not the caller's intentions, so a retry storm has to be charged against it. The other arrangement — one token per logical call — would let a retrying client burst straight past the budget it was configured to respect.

Signing is below even the limiter, for both of the reasons above read the other way round. Below retry, because a signature carries a timestamp and the receiver rejects a stale one: an attempt that reused the first attempt's signature would arrive outside the tolerance after a long backoff, which is a failure that shows up only in the requests already having a bad time. Below the limiter, because signing costs a key resolution and an HMAC over the whole body, and a request that never leaves should pay for neither.

Tracing sits below all of them, so each attempt is its own client span instead of one span spread over a loop.

What retrying will and will not do

Only idempotent methods are retried, and only when the body can be replayed. Both are properties a RoundTripper can check but not create: it cannot tell a request that never arrived from a response that never came back, so it will not repeat a POST on a guess. WithRetryMethods opts POST in for callers that pair it with idempotency/http, whose transport sends one key across every attempt so the server can recognize the repeat.

By default 5xx, 408, and 429 are retried, and every other 4xx is reported to the policy as retry.Unretryable, so the loop stops on the first one instead of spending its attempts re-asking a question the server has already answered. Retry-After is honored, capped by WithMaxRetryAfter; a server asking for longer than the cap gets its response handed back rather than a retry that ignores what it asked for.

Classification is a default, not a rule

Two decisions above are stated in terms of status codes, and status codes are the part of HTTP that services agree on least. Both are overridable, and both default to the registry's reading:

WithRetryPolicy(policy, WithRetryClassifier(fn))     // is this worth another attempt?
WithCircuitBreaker(breaker, WithOutcomeClassifier(fn)) // what did this say about the host?

They are separate questions and a single answer would serve neither. A 429 is worth retrying but says nothing about whether the host is healthy; a 400 is neither; a 503 is both. Delegate to DefaultRetryClassification and DefaultOutcome for whatever a classifier does not have an opinion about, rather than restating the rules it means to keep.

The outcome classifier is three-valued — success, failure, ignored — because a request can fail without the host having done anything wrong. A request this client's own limiter refused is the built-in case: it never reached the wire, so counting it either way would be a lie, and counting it as a failure would let ordinary throttling trip a circuit against a host that is perfectly well.

Observability

The resilience layers report through the standard pillars, supplied with WithLogger, WithTracerProvider, and WithMetricsProvider, or with WithPillars for all three. Absent, each resolves to its noop and the client records nowhere.

Metrics, all prefixed httpclient_ and attributed by host and method — never by URL, which is unbounded:

retry_attempts        attempts beyond the first
retries_exhausted     loops that retried and still gave up, by final status
retry_after_seconds   Retry-After delays actually honored
circuit_rejections    requests refused by an open circuit
circuit_outcomes      how each completed request was classified
rate_limited          requests the local limiter refused
cache_outcomes        how the response cache answered, by cache.outcome
signing_failures      requests that could not be signed, and so never sent

The cache.outcome values partition every request that reaches the cache: hit (answered without a wire request), revalidated (a 304 confirmed the stored copy), miss (the origin answered in full), and uncacheable (a request the cache took no part in). A cache that cannot be reached is counted as a miss and logged at debug — the origin is still there, so an unreachable store should cost hit rate and nothing else.

Two log lines are worth knowing about. A request refused by an open circuit is logged where it happens, because it produces no response, no attempt, and no span of its own — without that line, a client that has stopped talking to a dependency altogether looks exactly like one with nothing to say. And a loop that exhausted its attempts is logged with the error it gave up on, because RoundTrip deliberately discards that error in favor of the last response: the right answer for the caller, and one that would otherwise make a request that burned four attempts indistinguishable from one that succeeded immediately.

The outermost layer opens a span covering the logical request, which is what gives the per-attempt spans below the retry loop a parent, and what puts a breaker or limiter rejection into a trace at all. It follows WithTracerProvider rather than WithTracing: the two describe different things, and there is no reason to configure a tracer provider and want the resilience layers left out of it.

When attempts run out the caller gets the last response, not an error. A 503 that survived three tries is still the server's answer, and code that reads the status does not need a second way to find it.

One thing to set deliberately: http.Client.Timeout bounds the whole loop, retries and backoff included, because it becomes the request context's deadline before the transport ever runs. A client that retries wants WithTimeout raised to cover the attempts it is being asked to make.

Index

Examples

Constants

View Source
const DefaultContentType = encoding.ContentTypeJSON

DefaultContentType is the encoding an exchange uses when WithContentType names none.

It is JSON because that is what almost every service-to-service call in front of this package speaks, and for no stronger reason than that. It is a default and not a rule: every content type the encoding package implements is a peer here, reachable by naming it, and nothing in an exchange is written in terms of JSON specifically.

View Source
const DefaultErrorBodyLimit = 512

DefaultErrorBodyLimit is how much of a refused response's body a StatusError keeps when no other bound is named.

512 bytes is enough for every error shape a service designs on purpose — a JSON problem document, a validation list, a plain sentence — and short enough that the shapes nobody designed, an HTML error page from a load balancer or a stack trace from a framework's debug mode, cost one log line rather than a log budget.

View Source
const DefaultMaxCacheableBody int64 = 1 << 20

DefaultMaxCacheableBody is the largest response body stored by default. Anything above it is returned to the caller and not written, so one oversized document cannot evict everything a cache was configured to hold.

View Source
const DefaultMaxRetryAfter = 30 * time.Second

DefaultMaxRetryAfter is how long a Retry-After header may park a request before this transport stops honoring it and hands the response back instead.

Variables

This section is empty.

Functions

func DefaultRetryClassification

func DefaultRetryClassification(resp *http.Response) error

DefaultRetryClassification is the classifier WithRetryPolicy installs when none is named.

Anything below 400 is accepted. A 5xx is retried. A 4xx is the server saying the request itself is wrong, and repeating a wrong request cannot make it right — so it ends the loop, with the two exceptions that are about timing rather than about the request: 408 and 429.

func Exchange added in v10.1.0

func Exchange[Out any](ctx context.Context, doer Doer, method, url string, in any, opts ...ExchangeOption) (Out, error)

Exchange performs one encoded exchange over doer: encode in, send it to url, check the status, and decode the response into Out.

claim, err := httpclient.Exchange[ClaimResponse](ctx, client, http.MethodPost, url, request)

The encoding is a choice, and JSON is only its default

WithContentType names the encoding, over any content type the encoding package implements — JSON, XML, TOML, YAML, CBOR, Ecoji. Unnamed, it is DefaultContentType, which is JSON because that is what the overwhelming majority of these calls speak, not because this package treats JSON as the encoding and the rest as exceptions.

A content type this package cannot speak is an error rather than a fallback, the same answer encoding.ParseContentType gives, and for the same reason: silently standing in for JSON would turn a typo into a request that reaches a real server and is misunderstood by it.

doc, err := httpclient.Exchange[Manifest](ctx, client, http.MethodGet, url, nil,
	httpclient.WithContentType(encoding.ContentTypeCBOR),
)

The exchange itself

A nil in sends no body at all — not an encoded null — and sets no Content-Type. Anything else is marshaled, and the bytes are held rather than streamed, so the request carries a GetBody and a retrying client can replay it.

Every request asks for its own content type back in Accept, and the reply is decoded with that same codec whatever the response's Content-Type says. That leniency is deliberate: a server that answers JSON while labeling it text/plain is common, and reading the response header instead would refuse precisely the case the leniency exists for. The codec is the caller's statement of what it expects, not a guess at what arrived.

A status outside 2xx is a *StatusError carrying the status, the request path, and a bounded prefix of the body — see WithErrorBodyLimit. Out is the zero value for every error this returns, including that one: a failed exchange decodes nothing, so there is no half-populated value to mistake for an answer.

It adds no resilience of its own

Retrying, circuit breaking, rate limiting, and caching belong to the transports doer was built with, and are already finished by the time this reads a status. A helper that retried on top of them would give a client configured with WithRetryPolicy two nested loops and a caller no way to predict how many requests one call makes.

What the error does carry is the classification those transports already use. A *StatusError whose status DefaultRetryClassification calls terminal matches retry.ErrUnretryable, so a caller wrapping a whole operation — several exchanges, or an exchange plus the work around it — in a retry.Policy of its own stops on a 400 and keeps trying a 429, without restating the rule.

Example

The exchange every service-to-service caller was writing by hand: marshal, send, check the status, unmarshal. Named no content type, it speaks DefaultContentType.

package main

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

	"github.com/primandproper/platform-go/v10/httpclient"
)

// claimRequest and claimResponse stand in for the typed bodies a service-to-
// service caller already has.
type claimRequest struct {
	Worker string `json:"worker"`
}

type claimResponse struct {
	ID    string `json:"id"`
	Count int    `json:"count"`
}

func main() {
	server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) {
		res.Header().Set("Content-Type", "application/json")
		fmt.Fprint(res, `{"id":"claim-7","count":3}`)
	}))
	defer server.Close()

	client, err := httpclient.NewHTTPClient()
	if err != nil {
		panic(err)
	}

	claim, err := httpclient.Exchange[claimResponse](
		context.Background(),
		client,
		http.MethodPost,
		server.URL+"/v1/claim",
		&claimRequest{Worker: "worker-1"},
	)
	if err != nil {
		panic(err)
	}

	fmt.Println(claim.ID, claim.Count)

}
Output:
claim-7 3

func NewHTTPClient

func NewHTTPClient(opts ...Option) (*http.Client, error)

NewHTTPClient provides an HTTP client. With no options it returns a client with the package defaults; pass Config.Options to drive it from an environment-loaded Config.

The error reports that the client could not be instrumented — the metrics provider refused an instrument the resilience layers record to. It is returned rather than swallowed because a client that silently records nowhere is indistinguishable from one that is simply idle, and the difference only becomes interesting during the incident where the dashboard is empty.

Example (Resilience)

A provider integration composes its resilience once, at construction, instead of at every call site.

package main

import (
	"context"
	"fmt"
	"time"

	circuitbreakingcfg "github.com/primandproper/platform-go/v10/circuitbreaking/config"
	"github.com/primandproper/platform-go/v10/httpclient"
	"github.com/primandproper/platform-go/v10/observability"
	"github.com/primandproper/platform-go/v10/ratelimiting"

	retrycfg "github.com/primandproper/platform-go/v10/retry/config"
)

func main() {
	ctx := context.Background()

	policy, err := retrycfg.NewExponentialBackoffPolicy(retrycfg.Config{
		MaxAttempts:  4,
		InitialDelay: 100 * time.Millisecond,
		MaxDelay:     2 * time.Second,
		UseJitter:    true,
	}, retrycfg.WithName("payments"))
	if err != nil {
		panic(err)
	}

	breaker, err := circuitbreakingcfg.NewCircuitBreaker(ctx, &circuitbreakingcfg.Config{
		Name:                   "payments",
		ErrorRate:              50,
		MinimumSampleThreshold: 20,
	})
	if err != nil {
		panic(err)
	}

	// The provider documents 10 requests per second; the burst absorbs the
	// bunching that a retrying client produces.
	limiter, err := ratelimiting.NewInMemoryRateLimiter(10, 20)
	if err != nil {
		panic(err)
	}
	defer limiter.Close()

	// Whatever the service already built. Absent, every resilience layer below
	// resolves to its noop and the client records nowhere.
	pillars := &observability.Pillars{}

	client, err := httpclient.NewHTTPClient(
		httpclient.WithTimeout(30*time.Second), // room for the whole retry loop, not one attempt
		httpclient.WithTracing(true),
		httpclient.WithRetryPolicy(policy),
		httpclient.WithCircuitBreaker(breaker),
		httpclient.WithRateLimit(limiter),
		httpclient.WithPillars(pillars),
	)
	if err != nil {
		panic(err)
	}

	// Outermost to innermost the client is now: observability, breaker, retry,
	// rate limit, tracing, transport. Every package that builds its client
	// through httpclient gets the same arrangement without writing any of it.
	fmt.Println(client.Timeout)

}
Output:
30s

func RegisterHTTPClient

func RegisterHTTPClient(i do.Injector, opts ...Option)

RegisterHTTPClient registers an *http.Client with the injector, built from the injector's *Config. Any opts are applied after the Config and so override it.

Observability comes from the injector's pillars when it has any. A container that registers none still wires up — every pillar resolves to its noop — but one whose registered provider fails to build reports that rather than degrading to a client that looks instrumented and records nowhere.

The resilience and cache collaborators are deliberately not resolved here. A RateLimiter or CircuitBreaker in a container is far more often the one guarding the service's own inbound API, and silently repurposing it to throttle outbound calls would be a surprise nobody asked for. A registered cache.Cache is the service's own for the same reason, and filling it with third-party HTTP responses would evict what it was built to hold. They pass as options like everything else.

Types

type BaseURLClient added in v10.1.0

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

BaseURLClient binds a Doer to one service's root, so a call site names a path and not a URL:

leader, err := httpclient.NewBaseURLClient(client, "https://leader.internal/api")
claim, err := httpclient.Exchange[ClaimResponse](ctx, leader, http.MethodPost, "/v1/claim", request)

It is a Doer itself, so it goes anywhere one does and composes with nothing else: the client it wraps keeps every transport it was built with, and this adds no behavior beyond resolving the URL.

The joining is url.URL.JoinPath's, which is the reason to have this at all. Concatenating a configured base and a literal path is where the double slash and the missing slash come from, and both produce a request that reaches a real server and gets a 404 — the failure mode that looks like the service is broken rather than like the URL is.

A request whose URL is already absolute passes through untouched, so a caller with one endpoint elsewhere does not need a second client.

func NewBaseURLClient added in v10.1.0

func NewBaseURLClient(doer Doer, baseURL string) (*BaseURLClient, error)

NewBaseURLClient binds doer to baseURL.

baseURL must be absolute — scheme and host — and must carry no query or fragment. A base with a query is rejected rather than merged, because there is no reading of "the base says ?v=2 and the path says ?v=3" that is not a surprise to somebody, and a caller who wants a parameter on every request has a clearer place to put it than a URL.

Its path is a prefix, not a document: "https://host/api" and "https://host/api/" both resolve "/v1/claim" to "https://host/api/v1/claim", which is the one thing string concatenation and url.URL.ResolveReference each get wrong in a different direction.

Example

The other half every consumer rebuilds: a base URL, so call sites name a path.

package main

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

	"github.com/primandproper/platform-go/v10/httpclient"
)

type claimResponse struct {
	ID    string `json:"id"`
	Count int    `json:"count"`
}

func main() {
	server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
		res.Header().Set("Content-Type", "application/json")
		fmt.Fprintf(res, `{"id":%q,"count":1}`, req.URL.Path)
	}))
	defer server.Close()

	client, err := httpclient.NewHTTPClient()
	if err != nil {
		panic(err)
	}

	// The trailing slash on the base and the leading slash on the path are both
	// fine, in any combination — which is the whole reason not to concatenate.
	leader, err := httpclient.NewBaseURLClient(client, server.URL+"/api/")
	if err != nil {
		panic(err)
	}

	claim, err := httpclient.Exchange[claimResponse](context.Background(), leader, http.MethodGet, "/v1/claim", nil)
	if err != nil {
		panic(err)
	}

	fmt.Println(claim.ID)

}
Output:
/api/v1/claim

func (*BaseURLClient) BaseURL added in v10.1.0

func (c *BaseURLClient) BaseURL() string

BaseURL reports the root this client resolves against.

func (*BaseURLClient) Do added in v10.1.0

func (c *BaseURLClient) Do(req *http.Request) (*http.Response, error)

Do resolves a relative request URL against the base and sends it.

The request is cloned rather than rewritten, so a caller that built one and kept a reference — to read its headers in a test, to send it again elsewhere — still holds the request it built.

type BreakerOption

type BreakerOption func(*breakerTransport)

BreakerOption tunes the transport WithCircuitBreaker and WithKeyedCircuitBreaker install.

func WithOutcomeClassifier

func WithOutcomeClassifier(classifier OutcomeClassifier) BreakerOption

WithOutcomeClassifier replaces the rule deciding what a finished request says about the health of the host it was sent to.

The default, DefaultOutcome, counts transport errors and 5xx responses against the host and nothing else. That is the right reading of a standard HTTP API and the wrong reading of a great many real ones: a service that answers 200 with an error document, or 400 for its own overload, or 503 for a tenant that is merely out of quota, will either trip a circuit that should have stayed closed or hold one closed that should have opened.

A classifier that only wants to reclassify one thing should delegate the rest to DefaultOutcome rather than restating it:

httpclient.WithOutcomeClassifier(func(resp *http.Response, err error) httpclient.Outcome {
	if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
		return httpclient.OutcomeIgnored
	}

	return httpclient.DefaultOutcome(resp, err)
})

A nil classifier is ignored.

Example

A service whose status codes do not mean what the registry says they mean is the normal case, not the exceptional one. Both classification decisions are overridable, and both compose with the default rather than replacing it.

package main

import (
	"fmt"
	"net/http"

	"github.com/primandproper/platform-go/v10/httpclient"
)

func main() {
	// This provider reports its own overload as 400 with a header, and answers
	// 503 for tenants that are merely out of quota. Taking either at face value
	// would trip a circuit against a host that is working perfectly well.
	classifier := func(resp *http.Response, err error) httpclient.Outcome {
		if resp != nil {
			switch {
			case resp.StatusCode == http.StatusBadRequest && resp.Header.Get("X-Overloaded") != "":
				return httpclient.OutcomeFailure
			case resp.StatusCode == http.StatusServiceUnavailable && resp.Header.Get("X-Quota-Exceeded") != "":
				return httpclient.OutcomeIgnored
			}
		}

		return httpclient.DefaultOutcome(resp, err)
	}

	overloaded := &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{"X-Overloaded": {"1"}}}
	outOfQuota := &http.Response{StatusCode: http.StatusServiceUnavailable, Header: http.Header{"X-Quota-Exceeded": {"1"}}}
	genuine := &http.Response{StatusCode: http.StatusServiceUnavailable, Header: http.Header{}}

	fmt.Println(httpclient.DefaultOutcome(overloaded, nil), classifier(overloaded, nil))
	fmt.Println(httpclient.DefaultOutcome(outOfQuota, nil), classifier(outOfQuota, nil))
	fmt.Println(httpclient.DefaultOutcome(genuine, nil), classifier(genuine, nil))

}
Output:
success failure
failure ignored
failure failure

type CacheOption

type CacheOption func(*cacheTransport)

CacheOption tunes the transport WithHTTPCache installs.

func WithCacheAuthorized

func WithCacheAuthorized(enabled bool) CacheOption

WithCacheAuthorized permits caching responses to requests bearing an Authorization header, which is refused by default.

Read the default as the safety property it is: a cache/redis shared across a fleet, keyed on method and URL alone, will serve one tenant's response to another tenant's identical request. That is not a corner case, it is the first thing that happens.

Opting in does not relax the key — it extends it. The credential becomes part of the cache key, so two callers holding different tokens get different entries rather than each other's. What opting in asserts is narrower than it looks, and worth checking before doing it: that the response really is a function of the credential and nothing outside the request, and that a credential's hash sitting in the cache's keyspace is acceptable where that cache lives.

Responses marked private or no-store are still refused, as are responses setting cookies, whether or not this is on.

func WithCacheClock

func WithCacheClock(c clock.Clock) CacheOption

WithCacheClock replaces the clock freshness is judged against, which is what makes expiry assertable in a test without sleeping. A nil clock leaves the wall clock in place.

func WithCacheTTL

func WithCacheTTL(ttl time.Duration) CacheOption

WithCacheTTL states how long a response stays fresh when the origin said nothing about it.

It is the reason most callers reach for a cache at all: JWKS documents, .well-known metadata, and catalog endpoints are frequently served with no Cache-Control and no Expires, and the only party who knows how stale a copy may safely be is the caller. It is consulted last — any statement the origin made about its own resource wins, so naming a TTL cannot make this client hold a response longer than the origin permitted.

The TTL counts from receipt. Without it, a response carrying no freshness information is stored only if it carries a validator, and then only so a later request can be revalidated cheaply. A non-positive duration is ignored.

func WithMaxCacheableBody

func WithMaxCacheableBody(maxBody int64) CacheOption

WithMaxCacheableBody caps the size of a stored body, defaulting to DefaultMaxCacheableBody.

A response above the cap is returned to the caller in full and simply not written. The cap exists because a cache has a bound — cache/memory's is a byte budget, and a Redis has a machine — and one large response admitted into it evicts every small one that was earning its keep. A non-positive value leaves the default in place.

type CachedResponse

type CachedResponse struct {
	// OriginTime is the response's Date header, or the instant it was received
	// when it sent none. Freshness derived from a max-age counts from here
	// rather than from arrival: a response that spent four minutes in somebody
	// else's cache before reaching this one has four fewer minutes to live, and
	// the Age this transport reports on a hit is measured from it.
	OriginTime time.Time

	// FreshUntil is the instant the entry stops being servable without asking
	// the origin. The zero value means it was never fresh — stored only because
	// it carries a validator, and therefore useful for revalidation and nothing
	// else.
	FreshUntil time.Time

	// Header is the stored response's header, minus the hop-by-hop fields that
	// describe one connection rather than one response.
	Header http.Header

	// Vary records the request-header values this entry was stored against, one
	// per field name the response's Vary header listed. A request whose values
	// differ is a miss: the entry answers a question this request did not ask.
	Vary map[string]string

	// Body is the complete response body. Responses whose bodies exceed the
	// configured cap are not stored at all, so this is never partial.
	Body []byte

	// StatusCode is the stored response's status.
	StatusCode int
}

CachedResponse is a stored response, and the type the cache handed to WithHTTPCache is parameterized on:

store, err := memory.NewInMemoryCache[httpclient.CachedResponse](time.Hour)

It is exported for that reason alone. Nothing here is meant to be read or written by hand — the transport owns every field — but a cache is a typed collaborator and a caller cannot build one without naming what it holds.

It encodes through whatever Codec the cache was built with. The default CBOR codec carries every field; a custom codec has to as well, so a fixed-format one written for this type must not drop the ones that look like metadata. FreshUntil and OriginTime in particular are what make an entry safe to serve rather than merely present.

type Config

type Config struct {
	Timeout             time.Duration `env:"TIMEOUT"                 json:"timeout,omitempty"             yaml:"timeout,omitempty"`
	MaxIdleConns        int           `env:"MAX_IDLE_CONNS"          json:"maxIdleConns,omitempty"        yaml:"maxIdleConns,omitempty"`
	MaxIdleConnsPerHost int           `env:"MAX_IDLE_CONNS_PER_HOST" json:"maxIdleConnsPerHost,omitempty" yaml:"maxIdleConnsPerHost,omitempty"`
	EnableTracing       bool          `env:"ENABLE_TRACING"          json:"enableTracing,omitempty"       yaml:"enableTracing,omitempty"`
}

Config configures an HTTP client.

func (*Config) EnsureDefaults

func (cfg *Config) EnsureDefaults()

EnsureDefaults sets default values for zero fields.

func (*Config) Options

func (cfg *Config) Options() []Option

Options expresses the config as the equivalent list of Options, which is how a Config reaches NewHTTPClient:

client, err := httpclient.NewHTTPClient(cfg.Options()...)

Callers can append further Options to override individual settings. Zero-valued numeric fields yield Options that leave the package defaults in place, matching EnsureDefaults; EnableTracing is applied as given. A nil Config yields no Options.

func (*Config) ValidateWithContext

func (cfg *Config) ValidateWithContext(ctx context.Context) error

ValidateWithContext validates the config.

type Doer added in v10.1.0

type Doer interface {
	Do(req *http.Request) (*http.Response, error)
}

Doer sends a prepared request and returns the response. *http.Client is the implementation callers pass, and the one the rest of this package builds.

It is an interface rather than *http.Client for two reasons, both about what can be substituted for a client: BaseURLClient wraps one so a call site can name a path instead of a URL, and a test can answer an exchange without standing up a server. Neither is a place to reimplement a client — a Doer that is not, eventually, an *http.Client built by this package has none of the retrying, breaking, or limiting that everything below assumes has already happened.

type ExchangeOption added in v10.1.0

type ExchangeOption func(*exchangeConfig)

ExchangeOption customizes a single exchange. Options are applied in order, so a later one overrides an earlier one.

It is per call rather than per client because what it governs is per call: the headers this request needs, the encoding this endpoint speaks, and how much of its error body is worth keeping. Everything durable about a client — its timeout, its transports, its observability — is an Option on NewHTTPClient, and nothing here is a second way to set any of it.

func WithContentType added in v10.1.0

func WithContentType(contentType encoding.ContentType) ExchangeOption

WithContentType selects the encoding an exchange speaks: the request body's Content-Type, the Accept it asks for, and the codec the response is decoded with.

doc, err := httpclient.Exchange[Manifest](ctx, client, http.MethodGet, url, nil,
	httpclient.WithContentType(encoding.ContentTypeCBOR),
)

Any content type the encoding package implements is accepted. One it does not is encoding.ErrUnsupportedContentType from the exchange, not a quiet fall back to DefaultContentType — unlike WithHeader and WithErrorBodyLimit, which ignore an argument that cannot mean anything. The difference is what the mistake costs: an ignored empty header name sends the request the caller meant, while a content type silently replaced by JSON sends a request some server will answer, wrongly, and nothing will say so.

It sets both directions, which is what a service speaking one encoding wants. A caller that has to send one encoding and accept another sets the odd half with WithHeader, which is applied after these and wins.

Example

Nothing about an exchange is written in terms of JSON. A service that speaks CBOR — smaller on the wire than JSON, and readable outside Go — is one option away, and so is every other encoding the encoding package implements.

package main

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

	"github.com/primandproper/platform-go/v10/encoding"
	"github.com/primandproper/platform-go/v10/httpclient"
)

// claimRequest and claimResponse stand in for the typed bodies a service-to-
// service caller already has.
type claimRequest struct {
	Worker string `json:"worker"`
}

type claimResponse struct {
	ID    string `json:"id"`
	Count int    `json:"count"`
}

func main() {
	cbor := encoding.NewClientEncoder(encoding.ContentTypeCBOR)

	server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
		// One option set both directions: what the request body is, and what the
		// caller will accept back.
		fmt.Println(req.Header.Get("Content-Type"), req.Header.Get("Accept"))

		raw, err := cbor.Marshal(req.Context(), &claimResponse{ID: "claim-7", Count: 3})
		if err != nil {
			panic(err)
		}

		res.Header().Set("Content-Type", encoding.ContentTypeCBOR.String())
		_, _ = res.Write(raw)
	}))
	defer server.Close()

	client, err := httpclient.NewHTTPClient()
	if err != nil {
		panic(err)
	}

	claim, err := httpclient.Exchange[claimResponse](
		context.Background(),
		client,
		http.MethodPost,
		server.URL+"/v1/claim",
		&claimRequest{Worker: "worker-1"},
		httpclient.WithContentType(encoding.ContentTypeCBOR),
	)
	if err != nil {
		panic(err)
	}

	fmt.Println(claim.ID, claim.Count)

}
Output:
application/cbor application/cbor
claim-7 3

func WithErrorBodyLimit added in v10.1.0

func WithErrorBodyLimit(bytes int) ExchangeOption

WithErrorBodyLimit bounds how many bytes of a refused response's body a StatusError keeps, and how many are read from the wire at all.

A negative limit leaves DefaultErrorBodyLimit in place. Zero is a real answer, and the one to give an endpoint whose failures are known to be worthless or sensitive: the body is neither read nor kept, and the error reports the status alone.

func WithHeader added in v10.1.0

func WithHeader(name, value string) ExchangeOption

WithHeader sets a request header on the exchange, replacing any value the exchange would have set itself.

It is the seam for the per-request headers an API asks for and a transport cannot know about: an Idempotency-Key, a tenant, a request identifier, a vendor media type in place of the Accept and Content-Type this package fills in. Credentials that hold for every call belong further down — in a transport wrapped by WithTransport, or in WithRequestSigning — rather than repeated at every call site, where the one that gets forgotten is the one that matters.

An empty name is ignored.

type NoContent added in v10.1.0

type NoContent struct{}

NoContent is the response type for an exchange whose reply body is not read at all:

_, err := httpclient.Exchange[httpclient.NoContent](ctx, client, http.MethodDelete, url, nil)

It exists because "decode the body into Out" and "there is no body" are different requests, and the alternative — inferring one from an empty body — would turn a server that answered 200 with nothing into a silent zero value for every caller who did expect a body.

The body is closed but not read, so a server that sent one anyway costs this exchange its connection and nothing else.

type Option

type Option func(*clientConfig)

Option customizes the HTTP client returned by NewHTTPClient. Options are applied in order, so a later Option overrides an earlier one.

func WithCircuitBreaker

func WithCircuitBreaker(breaker circuitbreaking.CircuitBreaker, opts ...BreakerOption) Option

WithCircuitBreaker fails requests fast once breaker has tripped, so one dead dependency stops tying up connections and timeouts.

The breaker sees a request's final outcome, after any retrying. By default transport errors and 5xx responses count as failures, a request this client's own limiter refused counts as nothing at all, and everything else counts as a success — pass WithOutcomeClassifier to say otherwise, which most real APIs eventually require.

One breaker is shared across every host the client talks to, which is the right shape when the client belongs to a single integration. Use WithKeyedCircuitBreaker for a client that fans out. A nil breaker is ignored.

func WithHTTPCache

func WithHTTPCache(store cache.Cache[CachedResponse], opts ...CacheOption) Option

WithHTTPCache answers repeated GETs and HEADs of a stable resource from store instead of from the origin.

It is the layer above the resilience three, and that placement is the whole design: a hit makes no wire request, so it consults no circuit and spends no rate-limit token. A miss or a revalidation passes through all three exactly as an uncached request would.

The policy is RFC 9111 read narrowly. Cache-Control and Expires decide freshness; ETag and Last-Modified drive revalidation, and a 304 refreshes the stored entry rather than replacing it. What the RFC leaves to a cache's discretion, this errs toward not caching: no-store, private, Set-Cookie, Vary: *, and an Authorization header without WithCacheAuthorized all mean the response is not stored.

Most origins worth caching send no freshness headers at all, which is why callers hand-roll TTL maps in front of them. WithCacheTTL is the supported version of that, and it loses to anything the origin actually said:

client, err := httpclient.NewHTTPClient(
    httpclient.WithHTTPCache(store, httpclient.WithCacheTTL(5*time.Minute)),
)

The cache's own default expiry governs how long an entry is retained; the freshness above governs when it must be revalidated. Retaining an entry past its freshness is what makes a 304 possible, so a store configured to expire entries the moment they go stale gives up the cheaper half of this.

A nil cache is ignored.

Example

The identity provider's JWKS document changes a few times a year and is read on every token verification. It is also served with no caching headers whatsoever, which is the usual reason a TTL map grows in front of one.

package main

import (
	"fmt"
	"time"

	"github.com/primandproper/platform-go/v10/cache/memory"
	"github.com/primandproper/platform-go/v10/httpclient"
	"github.com/primandproper/platform-go/v10/ratelimiting"
)

func main() {
	// Per-process, because a JWKS is small, public, and read constantly. A
	// cache/redis store would share the entries across the fleet instead; the
	// option is the same either way. The hour is retention, not freshness — an
	// entry kept past its freshness is one a 304 can confirm without a body.
	store, err := memory.NewInMemoryCache[httpclient.CachedResponse](time.Hour)
	if err != nil {
		panic(err)
	}

	defer func() {
		if closeErr := store.Close(); closeErr != nil {
			panic(closeErr)
		}
	}()

	limiter, err := ratelimiting.NewInMemoryRateLimiter(10, 20)
	if err != nil {
		panic(err)
	}
	defer limiter.Close()

	client, err := httpclient.NewHTTPClient(
		httpclient.WithHTTPCache(store,
			// Consulted only where the origin said nothing. An origin that
			// sends max-age still governs its own resource.
			httpclient.WithCacheTTL(5*time.Minute),
			httpclient.WithMaxCacheableBody(256<<10),
		),
		// The cache sits above this, and above a circuit breaker if one is
		// named. A hit spends no token and reports no outcome, because it
		// never became a request.
		httpclient.WithRateLimit(limiter),
	)
	if err != nil {
		panic(err)
	}

	fmt.Println(client.Timeout)

}
Output:
10s

func WithKeyedCircuitBreaker

func WithKeyedCircuitBreaker(breakers partitioned.KeyedCircuitBreaker, opts ...BreakerOption) Option

WithKeyedCircuitBreaker breaks per host rather than per client, keyed by the request URL's host and port.

Hosts registered with the KeyedCircuitBreaker get their own breaker; the rest share its global one, so a client that talks to one critical dependency and several incidental ones can isolate the dependency without enumerating the world. A nil KeyedCircuitBreaker is ignored, as is a key that resolves to no breaker at all.

The outcome rule is the same one WithCircuitBreaker documents, and WithOutcomeClassifier overrides it the same way — for every host at once, since one classifier serves the whole client.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger sets the logger the resilience layers write to. Absent, they log nowhere.

func WithMaxIdleConns

func WithMaxIdleConns(n int) Option

WithMaxIdleConns sets the transport's maximum number of idle connections across all hosts. A non-positive value leaves the default in place. It has no effect alongside WithTransport.

func WithMaxIdleConnsPerHost

func WithMaxIdleConnsPerHost(n int) Option

WithMaxIdleConnsPerHost sets the transport's maximum number of idle connections per host. A non-positive value leaves the default in place. It has no effect alongside WithTransport.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider sets the metrics provider the resilience layers record to: retries taken and exhausted, Retry-After waits honored, circuit rejections and outcomes, and requests the local limiter refused. Absent, they record nowhere.

func WithPillars

func WithPillars(p *observability.Pillars) Option

WithPillars attaches a logger, tracer provider, and metrics provider in one go, for the common case where a caller has already built them together. A nil Pillars attaches nothing.

It is applied in order with the individual options, so a caller can hand over its pillars and then override one of them.

func WithRateLimit

func WithRateLimit(limiter ratelimiting.RateLimiter) Option

WithRateLimit spends a token from limiter, keyed by the request URL's host and port, before each request reaches the wire.

It is the layer closest to the network, so every attempt a retry loop makes pays for itself — which is the point, since a provider's documented budget counts requests, not the caller's intentions. A refused request fails with ratelimiting.ErrRateLimited; when a retry policy is also installed, its backoff is what waits for the bucket to refill. A nil limiter is ignored.

func WithRequestSigning

func WithRequestSigning(signer requestsigning.Signer) Option

WithRequestSigning stamps a signature over every outgoing request body, so the far side can prove the call came from a holder of the shared key.

It is the outbound half of what requestsigning/http's middleware does inbound, over the same requestsigning.Signer — so a first-party caller and the service it calls can be configured from one scheme and one key source.

keys, err := requestsigning.NewSecretKeySource(secretSource, "SIGNING_KEY", "SIGNING_KEY_PREVIOUS")
signer, err := requestsigning.NewSigner(keys)

client, err := httpclient.NewHTTPClient(
	httpclient.WithRequestSigning(signer),
	httpclient.WithRetryPolicy(policy),
)

Where it sits

Below the retry loop, so each attempt is signed afresh. A signature carries a timestamp and the receiver rejects a stale one; a retry that fired after thirty seconds of backoff still carrying the first attempt's timestamp would arrive outside the tolerance and be refused — a failure that appears only under the conditions that caused the retry in the first place.

Below the rate limiter too, so a request the local limiter refused is never signed at all. Signing costs a key resolution and an HMAC over the whole body, and spending either on a request that will not be sent is waste.

The body

Every signed request's body is buffered whole, because a MAC over it cannot be computed any other way, and the buffered copy is what gets sent. A client that streams large uploads should not sign them.

A nil signer is ignored.

func WithRetryPolicy

func WithRetryPolicy(policy retry.Policy, opts ...RetryOption) Option

WithRetryPolicy retries failed requests through policy.

Only idempotent methods are retried, and only when the request body can be replayed — see WithRetryMethods for both caveats. By default a response is retried when it is a 5xx, a 408, or a 429; every other 4xx is reported to policy as retry.Unretryable, so the loop stops on the first one instead of spending its attempts re-asking a question already answered. Pass WithRetryClassifier for a service whose status codes mean something else. Retry-After is honored up to DefaultMaxRetryAfter.

When the attempts run out the caller gets the last response, not an error: a 503 that survived three tries is still the server's answer, and code reading the status does not have to learn a second way to find it.

The client's overall timeout bounds the whole loop, retries included, because http.Client applies it to the request context before the transport ever runs. A client that retries wants that budget raised to match. A nil policy is ignored.

func WithTimeout

func WithTimeout(timeout time.Duration) Option

WithTimeout sets the client's overall request timeout, which also bounds the dial. A non-positive duration leaves the default (defaultTimeout) in place.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider sets the tracer provider used both for the span the resilience layers open around the logical request and, when WithTracing is on, for the per-attempt spans below it.

Absent, both trace nowhere — including the per-attempt spans, which until now silently fell back to the OpenTelemetry global rather than to the provider the service configured.

func WithTracing

func WithTracing(enabled bool) Option

WithTracing toggles wrapping the transport in OpenTelemetry instrumentation, which emits one client span per attempt. Tracing is off by default.

It does not govern the span the resilience layers open around the logical request; that one follows WithTracerProvider. The two answer different questions — how did this attempt go, versus what did this call cost in attempts and rejections — and a caller who has configured a tracer provider has no reason to want the second one suppressed.

func WithTransport

func WithTransport(transport http.RoundTripper) Option

WithTransport uses the given RoundTripper as the client's base transport rather than building one, which is the seam for stubbing responses in tests or layering custom middleware. The connection-pool options are ignored when it is set; tracing, if enabled, still wraps it. A nil RoundTripper is ignored.

type Outcome

type Outcome int

Outcome is what one completed exchange taught us about the host it was sent to. It is a third state wider than a bool on purpose: a request can fail without the host having done anything wrong, and recording that as either a failure or a success is a lie in both directions.

const (
	// OutcomeSuccess reports that the host answered, and answered acceptably.
	// The breaker counts it toward closing a half-open circuit.
	OutcomeSuccess Outcome = iota

	// OutcomeFailure reports that the host is unwell, and that continuing to
	// ask is the thing worth stopping.
	OutcomeFailure

	// OutcomeIgnored reports that this exchange says nothing about the host
	// either way, so the breaker records neither result. It is what a request
	// that never reached the wire deserves.
	OutcomeIgnored
)

func DefaultOutcome

func DefaultOutcome(resp *http.Response, err error) Outcome

DefaultOutcome is the classifier installed when none is named.

A transport error counts against the host, and so does a 5xx: both say the dependency is unwell, which is the thing a breaker is for. A 4xx does not — it says this particular request was wrong, which is the caller's problem and no reason to cut off every other caller of the same host.

A request this client's own limiter refused is ignored outright. It never reached the host, so it is evidence about the local budget and none at all about the dependency's health; counting it would let ordinary throttling trip a circuit against a host that is perfectly well — and then keep it tripped, since the refusals continue whether or not the host recovers.

func (Outcome) String

func (o Outcome) String() string

String renders an Outcome for logs, spans, and metric attributes.

type OutcomeClassifier

type OutcomeClassifier func(resp *http.Response, err error) Outcome

OutcomeClassifier decides what a finished request says about the health of the host it was addressed to. It is consulted once per request, after any retrying, and its answer is the only thing the circuit breaker is told.

Unlike RetryClassifier it receives the transport error as well as the response, because a dial failure or a reset is exactly the kind of evidence a breaker exists to accumulate — there, the absence of a response is the finding. When err is non-nil, resp is nil.

Override it with WithOutcomeClassifier when a host's idea of a status code differs from the standard's — which is most hosts. Delegating to DefaultOutcome for everything a classifier does not have an opinion about keeps the rest of the behavior described here intact.

type RetryClassifier

type RetryClassifier func(resp *http.Response) error

RetryClassifier decides whether a response is worth another attempt, and answers in the retry package's own vocabulary:

  • nil accepts the response and ends the loop successfully.
  • retry.Unretryable(err) ends the loop immediately, without spending the attempts that remain.
  • any other error asks for another attempt, subject to the policy.

The error a classifier returns is what the retry.Policy sees, so it is worth making descriptive; it is not what the caller of the HTTP client gets back, since a request that produced a response returns that response either way.

It is asked only about responses. A transport error — a dial failure, a reset, a timeout — is always retryable, because it means no answer arrived at all and that is the failure retrying exists for.

Override it with WithRetryClassifier. A classifier that wants the standard rules for everything but one endpoint's quirk should call DefaultRetryClassification for the cases it does not special-case.

type RetryOption

type RetryOption func(*retryTransport)

RetryOption tunes the transport WithRetryPolicy installs.

func WithMaxRetryAfter

func WithMaxRetryAfter(maxRetryAfter time.Duration) RetryOption

WithMaxRetryAfter caps how long a Retry-After header may delay an attempt. A response asking for longer is returned to the caller rather than retried. A non-positive duration leaves DefaultMaxRetryAfter in place.

func WithRetryClassifier

func WithRetryClassifier(classifier RetryClassifier) RetryOption

WithRetryClassifier replaces the rule deciding whether a response is worth another attempt.

The default, DefaultRetryClassification, retries 5xx, 408, and 429 and stops on every other 4xx. That is what the status registry says those codes mean; it is not always what a given service means by them. An API that reports its own overload as 400, or returns 200 with a failure document, or uses 409 for a condition that clears on its own, needs the rule stated in its terms rather than the registry's.

The classifier answers in the retry package's vocabulary — nil to accept, retry.Unretryable to stop, any other error to try again — and should delegate to DefaultRetryClassification for whatever it does not have an opinion about:

httpclient.WithRetryPolicy(policy, httpclient.WithRetryClassifier(
	func(resp *http.Response) error {
		if resp.StatusCode == http.StatusConflict {
			return platformerrors.New("lock still held")
		}

		return httpclient.DefaultRetryClassification(resp)
	},
))

Retry-After is still honored for whatever the classifier asks to retry, and the method and body-replay rules still apply — a classifier widens which responses are worth another attempt, not which requests may be repeated. A nil classifier is ignored.

func WithRetryMethods

func WithRetryMethods(methods ...string) RetryOption

WithRetryMethods replaces the set of methods eligible for retry, which defaults to the idempotent ones.

Adding POST is the common reason to reach for this, and it is safe exactly when the request carries an idempotency key — pair it with idempotency/http's transport, which sends one key across every attempt, so the server can recognize the repeat. Without that, a retried POST is a second charge, not a second try. An empty list leaves the default in place.

type StatusError added in v10.1.0

type StatusError struct {
	// Method is the request method, so a log line says what was attempted and
	// not only where.
	Method string

	// Path is the request's path. Not the full URL: the host is the caller's
	// own configuration and adds nothing to a message about the response, and a
	// query string is exactly where a token or a customer identifier ends up —
	// which is a poor thing to put in a string destined for a log.
	Path string

	// Status is the status line as the server sent it, "404 Not Found".
	Status string

	// Body is the response body, whitespace-trimmed and cut to at most
	// WithErrorBodyLimit bytes on a rune boundary. Truncated says whether the
	// cut happened; Binary says whether there was a body that is not here.
	Body string

	// ContentType is the response's Content-Type header as the server sent it,
	// which may be empty. It is what makes a Binary error legible: the number of
	// bytes alone does not say whether the server answered CBOR or a proxy
	// answered a gzip stream.
	ContentType string

	// StatusCode is the response status code.
	StatusCode int

	// BodySize is how many bytes of the body were read — at most
	// WithErrorBodyLimit, plus the one byte used to detect truncation. It is not
	// the size of the body the server sent, which is the point: nothing here
	// reads far enough to know that.
	BodySize int

	// Truncated reports whether the server's body was longer than the limit, so
	// a reader knows the message ends because the bound was reached rather than
	// because the server had nothing more to say.
	Truncated bool

	// Binary reports that the server sent a body which is not text, so Body is
	// empty and BodySize and ContentType are all that is kept of it.
	//
	// An exchange over CBOR is the case this exists for. Its error bodies are
	// bytes, and a bounded prefix of them run through a string is mojibake in a
	// log line — the sort that arrives at a UTF-8 column or a JSON log encoder
	// and fails there, one layer away from anything that explains why.
	Binary bool
}

StatusError is a response an exchange would not accept: a status outside 2xx.

It carries what an operator reading the log line actually needs — which request, what the server said, and what the server said about it — and it carries the status code so a caller can branch on a 404 without parsing a string.

Match it with errors.As. It also matches retry.ErrUnretryable under errors.Is for the statuses DefaultRetryClassification calls terminal, which is what lets a caller's own retry loop stop on a 400 and keep trying a 429 without writing that rule a second time.

Example

A refused status is an error carrying what an operator needs and no more of the body than a log line can afford.

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/primandproper/platform-go/v10/httpclient"
	"github.com/primandproper/platform-go/v10/retry"
)

// claimRequest and claimResponse stand in for the typed bodies a service-to-
// service caller already has.
type claimRequest struct {
	Worker string `json:"worker"`
}

type claimResponse struct {
	ID    string `json:"id"`
	Count int    `json:"count"`
}

func main() {
	// The proxy's four-megabyte HTML error page, which is the reason the limit
	// is on the read rather than on the string.
	server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) {
		res.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(res, "worker-1 is not registered for this area"+strings.Repeat(".", 4<<20))
	}))
	defer server.Close()

	client, err := httpclient.NewHTTPClient()
	if err != nil {
		panic(err)
	}

	_, err = httpclient.Exchange[claimResponse](
		context.Background(),
		client,
		http.MethodPost,
		server.URL+"/v1/claim",
		&claimRequest{Worker: "worker-1"},
		httpclient.WithErrorBodyLimit(40),
	)

	var status *httpclient.StatusError
	if errors.As(err, &status) {
		fmt.Println(status.StatusCode, status.Path, status.Truncated)
		fmt.Println(status.Body)
	}

	// A 400 is the server saying the request itself is wrong, so a caller's own
	// retry loop stops on it — the same rule the retry transport applies to a
	// response, read here from the error.
	fmt.Println(errors.Is(err, retry.ErrUnretryable))

}
Output:
400 /v1/claim true
worker-1 is not registered for this area
true

func (*StatusError) Error added in v10.1.0

func (e *StatusError) Error() string

func (*StatusError) Is added in v10.1.0

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

Is reports retry.ErrUnretryable for a status another attempt cannot improve.

It answers rather than wraps, because retry.ErrUnretryable is not what caused this error — it is a fact about it, and one that depends on the status code the caller can read for itself. The rule is terminalStatus, the same one DefaultRetryClassification hands the retry transport, so an outer loop and an inner one cannot come to different conclusions about the same response.

Jump to

Keyboard shortcuts

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