Table of contents
A retry loop looks like the simplest code you will write all week: try, and if it failed, wait and try again. The hard questions live in the gap between one attempt and the next. Was that outcome worth retrying at all? What has to be released before another attempt is safe? Can the request even be sent a second time? How long is the wait, and who has the final say on it?
The httpretrier package in nurago wraps any client with a Do(req) (*http.Response, error) method in retry orchestration. Take that gap decision by decision, in the order the code makes them.
import "github.com/tecnickcom/nurago/pkg/httpretrier"
r, err := httpretrier.New(client,
httpretrier.WithRetryIfFn(httpretrier.RetryIfForReadRequests),
httpretrier.WithAttempts(5),
)
resp, err := r.Do(req)
On sharing: an HTTPRetrier holds only immutable configuration after construction, so a single instance serves concurrent Do calls, each keeping its mutable state in a per-call value. Each concurrent call needs its own *http.Request, though, since a retry mutates the request’s body.
Decision one: was that outcome worth retrying?
Retry decisions go through a single pluggable function receiving both the response and the error, so policy can react to transport failures and HTTP status codes alike. The default is as narrow as it can defensibly be: retry only when err != nil, meaning only when no HTTP response arrived at all.
Two predefined policies widen it. RetryIfForWriteRequests, for state-changing requests, adds 429, 502, and 503 and nothing else, statuses that generally mean throttling or a gateway that could not reach the application. RetryIfForReadRequests adds a much longer list: 404, 408, 409, 423, 425, 429, 500, 502, 503, 504, 507.
The odd entries there are deliberate. Retrying a 404 or a 409 looks wrong until you consider read-after-write eventual consistency, where a resource created moments ago may not be visible yet and asking again costs an idempotent read very little. The reasoning sits in the source next to the policy. If it does not fit your semantics, a custom RetryIfFn replaces it wholesale, and RetryIfFnByHTTPMethod picks the read policy for GET and the write policy for everything else.
No policy can settle one thing. A transport error can arrive after the server has already processed the request, with only the response lost in transit, so even a conservative retry of a write can execute it twice. Retrying writes is reasonable when the writes are idempotent, or fenced at the application level. The policy function decides when to retry; it cannot tell you whether retrying is safe for your data.
Decision two: release what you hold
Before the next attempt may run, the current one has to be cleaned up. The response body of the failed attempt is closed, which is more than hygiene. An unclosed body keeps its connection out of the transport’s pool, so a retry loop that forgets it degrades the connection reuse it depends on. A close failure stops the loop and comes back as the error rather than being swallowed.
Ordering carries a subtle constraint. The retry-decision function runs before the body is closed, since a policy may want to inspect the body. That is why the RetryIfFn contract says it must not panic: a panic there would leak the open response. The OnRetryFn observability callback runs after the close, and its documentation says so.
Do also enforces the standard library’s response-XOR-error convention even when the wrapped client does not. A non-conforming client that returns both gets its response closed and dropped, so the caller of Do sees one or the other, and never both.
Decision three: can the request be sent again?
An http.Request body is a stream, and the first attempt consumed it. Replaying the request relies on Request.GetBody, the standard library’s own mechanism for recreating a body (it is set automatically by http.NewRequest for *bytes.Buffer, *bytes.Reader, and *strings.Reader bodies). When a retry is needed and the body cannot be recreated, the loop stops with ErrBodyNotReplayable instead of silently sending a truncated or empty request. Bodyless requests retry without restriction.
Reopening is lazy, immediately before the retry attempt runs, rather than eagerly when the retry is scheduled. The difference shows up on cancellation. A scheduled retry pre-empted by the context never reopens the body, so nothing is left dangling. The tests exercise that path directly.
Decision four: how long is the wait?
Delay arithmetic is delegated to nurago’s backoff package, whose schedule handles exponential growth, jitter strategies, and the overflow clamping that post covers. The defaults here: 4 total attempts, an initial 1-second delay, a factor of 2, a 100-millisecond additive jitter ceiling, and a 30-second cap on the computed delay.
Then the server gets a say. With WithRespectRetryAfter, a Retry-After header can lengthen the wait: delta-seconds or an HTTP date, with absent, malformed, and non-positive values ignored. It applies only when it exceeds the computed backoff delay, so it can stretch the schedule but never shorten it.
Jitter is still added on top of the server’s value, and added additively whatever the configured jitter strategy is. A full-jitter draw in [0, delay) could land below the server’s requested minimum, while an additive draw only lands at or above it. Fleets of clients often receive the same Retry-After value, and that jitter is what stops them re-synchronising on it.
That trust is capped, by default at 24 hours (WithMaxRetryAfter), so a hostile or misconfigured server cannot park a caller for an arbitrary time. A Retry-After wait can legitimately exceed WithMaxDelay, which bounds only the exponential schedule. The request context’s deadline is the outer bound on any wait.
Decision five: who gets told?
WithOnRetry registers a callback invoked before each scheduled retry with the 1-based number of the attempt that just failed, the computed delay, and the response (body already closed) or error that triggered it. It is an observability hook for logs and metrics, with one precise caveat in its contract: it counts scheduled retries, and a scheduled retry can still be pre-empted by cancellation before it runs, so the count can exceed the attempts that actually execute by one. When cancellation wins the race outright the callback is not invoked at all, which the test suite checks explicitly.
Decision six: when does it end?
Three ways out. The attempt succeeds, or the policy declines to retry, and the current response and error come back as they are. The attempts cap is reached, with the same result. Or the context ends: a context already done fails fast before the first attempt, and cancellation during a wait fires through the select guarding the retry timer, so a cancelled caller does not sit out the remainder of a long backoff delay.
The one race is documented instead of denied. If the context is cancelled at the same moment a retry timer fires, one further attempt may run with the cancelled request before Do returns the context error. That attempt then fails promptly under the dead context.
Every decision above has a default that works, and several have defaults that work right up until they do not: a RetryIfFn too wide for a non-idempotent write, a body with no GetBody behind it, a Retry-After honoured without a ceiling. The package’s position throughout is to pick the conservative option and name the one that widens it.