package httpretry
import (
"math/rand"
"time"
)
type Policy struct {
Base time.Duration
Max time.Duration
MaxAttempts int
}
func DefaultPolicy() Policy {
return Policy{
Base: 100 * time.Millisecond,
Max: 5 * time.Second,
MaxAttempts: 5,
}
}
// Delay returns a full-jitter backoff duration for the given attempt (0-based).
func (p Policy) Delay(attempt int) time.Duration {
ceiling := p.Base << uint(attempt)
if ceiling <= 0 || ceiling > p.Max {
ceiling = p.Max
}
return time.Duration(rand.Int63n(int64(ceiling)))
}
package httpretry
import (
"context"
"errors"
"fmt"
"time"
)
type retryableError struct{ err error }
func (e *retryableError) Error() string { return e.err.Error() }
func (e *retryableError) Unwrap() error { return e.err }
func Retryable(err error) error {
if err == nil {
return nil
}
return &retryableError{err: err}
}
func isRetryable(err error) bool {
var re *retryableError
return errors.As(err, &re)
}
func Do(ctx context.Context, p Policy, fn func() error) error {
var last error
for attempt := 0; attempt < p.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
last = fn()
if last == nil {
return nil
}
if !isRetryable(last) {
return last
}
if attempt == p.MaxAttempts-1 {
break
}
select {
case <-time.After(p.Delay(attempt)):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("exhausted %d attempts: %w", p.MaxAttempts, last)
}
package httpretry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
var retryableStatus = map[int]bool{
429: true, 500: true, 502: true, 503: true, 504: true,
}
type Client struct {
HTTP *http.Client
Policy Policy
}
func (c *Client) FetchJSON(ctx context.Context, url string, out interface{}) error {
return Do(ctx, c.Policy, func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return Retryable(err) // connection reset, timeout, etc.
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
io.Copy(io.Discard, resp.Body)
err := fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
if retryableStatus[resp.StatusCode] {
return Retryable(err)
}
return err
}
return json.NewDecoder(resp.Body).Decode(out)
})
}
This snippet shows a small, self-contained retry layer for HTTP requests in Go, built around the idea that transient failures (dropped connections, 429s, brief 5xx blips) should be retried with progressively longer, randomized waits rather than a fixed sleep. The core pattern is exponential backoff with full jitter: each attempt waits a random duration in [0, base * 2^attempt], capped at a maximum. Randomizing the delay spreads out retries from many clients so they don't reconverge into a synchronized "thundering herd" that hammers a recovering service in lockstep.
In backoff.go, Policy holds the tunables — Base, Max, and MaxAttempts — and Delay computes the wait for a given attempt. It shifts Base left by the attempt number to get the exponential ceiling, clamps that to Max to avoid runaway waits, then returns rand.Int63n(ceiling) for full jitter. Guarding against overflow via the ceiling <= 0 check matters because shifting a time.Duration by a large attempt count can wrap to a negative value.
retry.go contains the driver. Do loops up to MaxAttempts, invoking the supplied fn and inspecting its error. The retryableError sentinel lets callers signal "this is worth retrying" while other errors abort immediately — a deliberate choice so that permanent failures (a 400, a validation error) don't waste attempts. Between tries it blocks on a select over time.After(delay) and ctx.Done(), so a cancelled or timed-out context aborts the wait promptly instead of sleeping through it. The last error is preserved and returned once attempts are exhausted.
client.go ties it together in FetchJSON. It builds a request bound to the caller's ctx, and inside the retry closure classifies the outcome: network errors and status codes in the retryable set (429, 500, 502, 503, 504) are wrapped as retryableError, while a 4xx like 404 returns a plain error that stops the loop. Note that the response body is drained and closed on the non-2xx path to avoid leaking connections from the pool.
The main trade-off is latency versus success rate: retries can multiply tail latency, so Max and MaxAttempts should be tuned against an overall deadline carried by ctx. This approach fits idempotent GETs and safe writes; non-idempotent operations need idempotency keys before retrying blindly.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
Share this code
Here's the card — post it anywhere.