Gamma

A composable resilience layer for Go HTTP clients. Retry, timeout, custom backoff, per-request overrides — each one a middleware you plug into a standard *http.Client.

github.com/Mehul-Kumar-27/gamma

1. Overview #

Gamma wraps http.RoundTripper, the same extension point the standard library gives you. That means:

client.Do(req) └─► gamma middleware chain ├─► Timeout (optional, outermost = overall cap) ├─► your-own-middlewares (logging, tracing, auth) ├─► Retry (loop + backoff + per-attempt timeout) └─► http.DefaultTransport (actual HTTP call)

Current scope. This page documents the features that are actually shipped: middleware core, retry, backoff, timeout, per-request overrides, and custom middleware support. Circuit breaker, hedging, rate limiting, and attempt hooks are on the roadmap but not yet released.

2. Install & Quick Start #

go get github.com/Mehul-Kumar-27/gamma

Requires Go 1.22+.

package main

import (
    "fmt"
    "log"
    "time"

    "github.com/Mehul-Kumar-27/gamma"
)

func main() {
    client := gamma.NewGamma(
        gamma.Use(gamma.Retry(
            gamma.RetryMaxAttempts(3),
            gamma.RetryPerAttemptTimeout(5*time.Second),
        )),
    )

    resp, err := client.Get("https://httpbin.org/status/200")
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    fmt.Println("Status:", resp.StatusCode)
}

That's the whole thing: build a client with middleware, use it like a normal *http.Client.

3. Core Concepts #

3.1 The Middleware type #

type Middleware func(http.RoundTripper) http.RoundTripper

A Middleware takes a RoundTripper and returns a new one with added behaviour. Every feature in gamma — retry, timeout, overrides — is just a function matching this signature. Because the type is so small, you can freely write your own and drop them into the chain alongside the built-ins.

3.2 Chain and middleware order #

func Chain(middlewares ...Middleware) Middleware

Chain composes middlewares into one. The first argument is the outermost wrapper: it runs first on the way in and last on the way out. This matches the standard "onion" model.

// request flow:  logging → rateLimit → retry → base
// response flow: base → retry → rateLimit → logging
chain := gamma.Chain(logging, rateLimit, retry)
rt    := chain(http.DefaultTransport)

You rarely call Chain directly — NewGamma / NewTransport call it for you based on the order of Use() options. But the semantics matter because placement changes meaning. The classic example: a Timeout outside retry caps the whole request; a Timeout inside retry caps each individual attempt. Same middleware, different outcome.

3.3 NewGamma vs NewTransport #

func NewGamma(opts ...Option) *http.Client func NewTransport(opts ...Option) http.RoundTripper
Use NewGamma when…Use NewTransport when…
you just want a ready-to-use *http.Client you already have an *http.Client and want to wire gamma into it
you want the client-level Timeout, Jar, redirect policy to be handled by the standard library defaults you want to set those yourself (cookie jar, redirect limits, custom CheckRedirect)
You're writing application code You're writing a library or framework that accepts a client from its caller
// Most code:
client := gamma.NewGamma(gamma.Use(gamma.Retry()))

// Full control:
rt := gamma.NewTransport(gamma.Use(gamma.Retry()))
client := &http.Client{
    Transport: rt,
    Timeout:   30 * time.Second,
    Jar:       myJar,
}

3.4 Options #

OptionPurpose
Use(m Middleware) Append a middleware. Order matters — first Use() becomes the outermost wrapper.
WithBase(rt http.RoundTripper) Override the underlying transport. Defaults to http.DefaultTransport. Useful for custom TLS, proxy, or connection pool settings.
WithClientTimeout(d time.Duration) Set http.Client.Timeout on the client returned by NewGamma. Ignored by NewTransport (a bare RoundTripper has no Timeout field).

Tip. WithClientTimeout and the Timeout middleware both enforce deadlines, but operate at different layers. WithClientTimeout is the standard-library hard ceiling (includes DNS, connect, TLS, read) and is invisible to the middleware pipeline. The Timeout middleware lives inside the pipeline and composes with retry. Prefer the middleware when you want to reason about per-attempt vs. overall semantics explicitly.

4. Retry #

func Retry(opts ...RetryOption) Middleware

4.1 Basics #

// Defaults: 2 attempts, retry on 429/503/504, exponential backoff (1s base, 2x factor).
client := gamma.NewGamma(gamma.Use(gamma.Retry()))

Retry sits in the chain like any other middleware. On each attempt it:

  1. Checks the parent context for cancellation.
  2. Clones the request (fresh body, fresh context with optional per-attempt deadline).
  3. Calls the next RoundTripper.
  4. Runs the response/error through the configured RetryPolicy.
  5. If retryable and attempts remain, waits for the BackoffStrategy's delay and loops.

4.2 All options #

OptionDefaultWhat it does
RetryMaxAttempts(n int) 2 Total number of attempts. 3 = 1 initial + 2 retries.
RetryOn(codes ...int) 429, 503, 504 Status codes that trigger a retry (in addition to network errors).
RetryWithBackoff(b BackoffStrategy) ExponentialBackoff(1s, 2.0) Delay strategy between attempts. See Backoff Strategies.
RetryWithPolicy(p RetryPolicy) DefaultRetryPolicy Custom "should retry?" function. See Retry Policy.
RetryPerAttemptTimeout(d time.Duration) 0 (disabled) Hard deadline for each individual attempt. Attempt gets cancelled at d; the loop moves on.

4.3 Why per-attempt timeout matters #

If a single attempt hangs — zombie TCP connection, server GC pause, dead replica that still accepts the connection — it eats your entire retry budget. Your "3 retries" become meaningless because attempt 1 never returns.

Without per-attempt timeout (overall 30s only): attempt 1: server hangs ─────────────────► 30s overall timeout retries never happen With per-attempt timeout (5s): attempt 1: server hangs ─X (killed at 5s) attempt 2: hits healthy replica ─► 200 OK (300ms)

This is why Envoy has per_try_timeout, AWS SDKs have per-request timeouts, and gRPC has per-RPC deadlines. Gamma's RetryPerAttemptTimeout is the same idea.

Rule of thumb: overall ≥ (per_attempt × max_attempts) + sum(backoffs). For 3 attempts of 2s each plus ~3s total backoff: allow at least 9s overall.

4.4 Request body replay #

Before the first attempt, gamma reads the full request body into memory once, closes it, and replays it as io.NopCloser(bytes.NewReader(...)) on every attempt. This is necessary because req.Body is a one-shot io.ReadCloser — the underlying transport consumes it, and a naïve retry would send an empty body the second time.

Implication for large bodies. Buffering means streaming uploads are materialised in memory. If you're sending multi-GB payloads that must be retryable, consider a custom middleware that only retries on network errors seen before the first byte is sent, or skip retry entirely for those requests via per-request overrides.

5. Backoff Strategies #

5.1 The interface #

type BackoffStrategy interface { Delay(attempt int, resp *http.Response) time.Duration }

A strategy receives the zero-indexed attempt number and the most recent response (which may be nil for network-level errors) and returns the wait duration before the next attempt. All built-in strategies honour the Retry-After header when present.

5.2 ExponentialBackoff #

func ExponentialBackoff(base time.Duration, factor float64) BackoffStrategy

Wait grows as base × factor^attempt. Classic choice when clients aren't highly concurrent.

b := gamma.ExponentialBackoff(time.Second, 2.0)
// attempt 0 → 1s,  attempt 1 → 2s,  attempt 2 → 4s,  attempt 3 → 8s ...

5.3 ExponentialJitterBackoff #

func ExponentialJitterBackoff(base time.Duration, factor float64) BackoffStrategy

Same exponential growth, but the actual wait is sampled uniformly from [full/2, full]. Use this whenever many clients might retry against the same backend — it prevents thundering-herd synchronisation where everyone backs off by the same amount and retries at the same moment.

b := gamma.ExponentialJitterBackoff(time.Second, 2.0)
// attempt 0 → random in [500ms, 1s]
// attempt 1 → random in [1s, 2s]
// attempt 2 → random in [2s, 4s]

5.4 ConstantBackoff #

func ConstantBackoff(d time.Duration) BackoffStrategy

Always waits d. Good when the remote expresses its own rate via Retry-After and you just want a safe fallback between attempts.

5.5 AdaptiveBackoff #

func AdaptiveBackoff(opts ...AdaptiveOption) BackoffStrategy

Picks a different strategy depending on the failure type. Defaults:

TriggerDefault strategyRationale
429 rate-limitExponentialBackoff(2s, 3.0)Back off hard — the server is asking us to.
5xx server errorExponentialJitterBackoff(1s, 2.0)Retry with jitter to avoid herd.
network error (resp == nil)ConstantBackoff(100ms)Often a transient hiccup; retry fast.
other retryableExponentialBackoff(1s, 2.0)Conservative fallback.
b := gamma.AdaptiveBackoff(
    gamma.AdaptiveOnRateLimit(gamma.ExponentialBackoff(5*time.Second, 3.0)),
    gamma.AdaptiveDefault(gamma.ExponentialJitterBackoff(time.Second, 2.0)),
)

5.6 Retry-After precedence #

If the server includes a Retry-After: N header (N as seconds), all built-in strategies return N seconds instead of their computed delay. Honouring this header is good HTTP citizenship — the server is explicitly telling you when it's ready.

5.7 Custom strategies via BackoffFunc #

type BackoffFunc func(attempt int, resp *http.Response) time.Duration

Any plain function can become a strategy:

decorrelated := gamma.BackoffFunc(func(attempt int, resp *http.Response) time.Duration {
    // "Decorrelated jitter" from AWS Architecture Blog
    base := 100 * time.Millisecond
    cap  := 10 * time.Second
    prev := time.Duration(attempt+1) * base
    next := time.Duration(rand.Int63n(int64(3*prev - base))) + base
    if next > cap {
        next = cap
    }
    return next
})

client := gamma.NewGamma(gamma.Use(gamma.Retry(
    gamma.RetryWithBackoff(decorrelated),
)))

6. Timeout #

func Timeout(d time.Duration) Middleware

A standalone timeout middleware. Its meaning depends on where you place it relative to Retry:

// Outside retry → OVERALL cap (30s across all attempts + backoff)
gamma.NewGamma(
    gamma.Use(gamma.Timeout(30 * time.Second)),
    gamma.Use(gamma.Retry(gamma.RetryMaxAttempts(3))),
)

// Inside retry → PER-ATTEMPT cap (each attempt gets a fresh 5s)
// Equivalent to RetryPerAttemptTimeout(5s), via pure composition.
gamma.NewGamma(
    gamma.Use(gamma.Retry(gamma.RetryMaxAttempts(3))),
    gamma.Use(gamma.Timeout(5 * time.Second)),
)

// Both → defense in depth
gamma.NewGamma(
    gamma.Use(gamma.Timeout(30 * time.Second)),  // overall
    gamma.Use(gamma.Retry(
        gamma.RetryMaxAttempts(3),
        gamma.RetryPerAttemptTimeout(5 * time.Second),
    )),
)

Which should I use? For plain per-attempt deadlines, prefer RetryPerAttemptTimeout — it's discoverable, lives with the retry config, and reads cleanly in reviews. Use the standalone Timeout middleware when you want overall enforcement or when you care about the composition model explicitly (e.g. publishing a reusable chain factory).

7. Retry Policy #

type RetryPolicy func(resp *http.Response, err error, retryStatusCodes []int) (shouldRetry bool)

A RetryPolicy is a pure decision function. The retry middleware calls it after each attempt; if it returns true and attempts remain, the loop continues. Otherwise the last response/error is returned to the caller.

Default behaviour

func DefaultRetryPolicy(resp *http.Response, err error, codes []int) bool {
    if err != nil {
        return true  // any network-level error → retry
    }
    for _, code := range codes {
        if resp.StatusCode == code {
            return true
        }
    }
    return false
}

Custom policies

A common pattern is to refuse retries for non-idempotent methods:

idempotentOnly := func(resp *http.Response, err error, codes []int) bool {
    if resp != nil && resp.Request != nil {
        switch resp.Request.Method {
        case http.MethodPost, http.MethodPatch:
            return false
        }
    }
    return gamma.DefaultRetryPolicy(resp, err, codes)
}

client := gamma.NewGamma(gamma.Use(gamma.Retry(
    gamma.RetryWithPolicy(idempotentOnly),
)))

8. Per-Request Overrides #

Middleware defaults are set once, globally, when you build the client. But some individual requests need different behaviour — a payment call that must never retry, an endpoint whose backend is notoriously slow, a background poll that can wait longer between attempts. That's what WithOverrides is for.

func WithOverrides(req *http.Request, opts ...OverrideOption) *http.Request

It attaches per-request configuration to the request via its context. The retry middleware reads the overrides and merges them on top of its defaults for that request only.

OptionOverrides
OverrideRetries(n int)RetryMaxAttempts
OverrideBackoff(b BackoffStrategy)RetryWithBackoff
OverridePerAttemptTimeout(d time.Duration)RetryPerAttemptTimeout
// This specific request should not be retried, and needs longer per-attempt time.
req, _ := http.NewRequest("POST", "https://api.example.com/pay", body)
req = gamma.WithOverrides(req,
    gamma.OverrideRetries(1),
    gamma.OverridePerAttemptTimeout(15 * time.Second),
)
resp, err := client.Do(req)

Why context? http.Request is a standard-library type — gamma can't add fields to it. Context is the one extension point the stdlib gives you for request-scoped data, which is why every Go library (auth, tracing, feature flags) uses the same pattern.

9. Writing Custom Middleware #

Because Middleware is just func(http.RoundTripper) http.RoundTripper, writing your own is trivial. The helper RoundTripperFunc (mirror of http.HandlerFunc) lets you avoid defining a struct.

Example: structured logging

func Logging(logger *slog.Logger) gamma.Middleware {
    return func(next http.RoundTripper) http.RoundTripper {
        return gamma.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
            start := time.Now()
            resp, err := next.RoundTrip(req)

            attrs := []slog.Attr{
                slog.String("method", req.Method),
                slog.String("url", req.URL.String()),
                slog.Duration("duration", time.Since(start)),
            }
            if err != nil {
                attrs = append(attrs, slog.String("error", err.Error()))
            } else {
                attrs = append(attrs, slog.Int("status", resp.StatusCode))
            }
            logger.LogAttrs(req.Context(), slog.LevelInfo, "http", attrs...)
            return resp, err
        })
    }
}

Example: auth header injection

func BearerAuth(token string) gamma.Middleware {
    return func(next http.RoundTripper) http.RoundTripper {
        return gamma.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
            // Clone so we don't mutate a shared request.
            clone := req.Clone(req.Context())
            clone.Header.Set("Authorization", "Bearer "+token)
            return next.RoundTrip(clone)
        })
    }
}

client := gamma.NewGamma(
    gamma.Use(BearerAuth(os.Getenv("API_TOKEN"))),
    gamma.Use(gamma.Retry()),
)

Always clone if you mutate. The *http.Request passed in may be shared across retries or other middlewares. Mutating its headers directly can corrupt later attempts. Use req.Clone(req.Context()) before any modification.

10. Full Example #

package main

import (
    "log/slog"
    "net/http"
    "os"
    "time"

    "github.com/Mehul-Kumar-27/gamma"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    client := gamma.NewGamma(
        // Overall cap — hard ceiling across all attempts + backoff.
        gamma.Use(gamma.Timeout(30 * time.Second)),

        // Observability — every attempt logged with duration & status.
        gamma.Use(Logging(logger)),

        // Retry with adaptive backoff and per-attempt timeout.
        gamma.Use(gamma.Retry(
            gamma.RetryMaxAttempts(4),
            gamma.RetryOn(429, 502, 503, 504),
            gamma.RetryWithBackoff(gamma.AdaptiveBackoff()),
            gamma.RetryPerAttemptTimeout(5 * time.Second),
        )),
    )

    // Normal request — uses all defaults from above.
    resp, err := client.Get("https://api.example.com/users/42")
    _ = resp; _ = err

    // Payment request — no retries, longer per-attempt timeout.
    req, _ := http.NewRequest("POST", "https://api.example.com/pay", nil)
    req = gamma.WithOverrides(req,
        gamma.OverrideRetries(1),
        gamma.OverridePerAttemptTimeout(20 * time.Second),
    )
    _, _ = client.Do(req)
}

11. Design Notes #

Why middleware instead of one big client?

A monolithic Client with a dozen option functions accumulates complexity quickly and forces every caller to pay the cost of every feature. Middleware lets you compose only what you need and place things in an order that makes physical sense. It also makes testing one concern at a time straightforward — install a single middleware, assert its behaviour, done.

Why http.RoundTripper and not a custom client?

Hundreds of libraries accept an *http.Client. A custom client type would force users to choose between gamma's resilience and their other tools' integration points. By hooking into http.RoundTripper — the standard library's extension seam — gamma is invisible to every consumer of the client.

Why buffer the request body?

Because a retry of a POST with an io.Reader body would otherwise send an empty body the second time. The standard library's retry-less transport consumes the reader. We read once, cache the bytes, and replay on each attempt. The tradeoff is memory overhead for large bodies (see the callout under Request body replay).

Why is PerAttemptTimeout inside the retry config and a standalone middleware?

Because both models are valid and neither is wrong. Some teams want a single obvious knob on retry ("each attempt is capped at N seconds"); others want pure composition they can reason about as a pipeline. Gamma supports both. Internally, RetryPerAttemptTimeout just wraps the attempt's context with context.WithTimeout — the same thing the standalone Timeout middleware does when placed inside the retry.

Why no baked-in observability?

Every team has opinions about logging format, metric names, tracing backends. Dictating any of them would be wrong. Gamma gives you a two-line Middleware to plug in exactly what you use today.

Thread safety

Gamma middlewares are safe for concurrent use by default. A single *http.Client built with NewGamma can be shared across goroutines — in fact that's the recommended pattern, because the underlying connection pool is per-transport.

12. API Index #

Constructors

NewGamma(opts ...Option) *http.ClientBuild a client with the middleware chain installed.
NewTransport(opts ...Option) http.RoundTripperBuild a bare round-tripper for use in your own *http.Client.

Options

Use(m Middleware) OptionAppend a middleware.
WithBase(rt http.RoundTripper) OptionOverride the underlying transport.
WithClientTimeout(d time.Duration) OptionSet http.Client.Timeout (NewGamma only).

Core types

Middlewarefunc(http.RoundTripper) http.RoundTripper
RoundTripperFuncFunction-to-RoundTripper adapter.
Chain(middlewares ...Middleware) MiddlewareCompose middlewares, first = outermost.

Retry

Retry(opts ...RetryOption) MiddlewareThe retry middleware.
RetryMaxAttempts(n int) RetryOptionTotal attempts.
RetryOn(codes ...int) RetryOptionRetryable status codes.
RetryWithBackoff(b BackoffStrategy) RetryOptionDelay strategy.
RetryWithPolicy(p RetryPolicy) RetryOptionCustom decision function.
RetryPerAttemptTimeout(d time.Duration) RetryOptionPer-attempt deadline.
RetryConfigConfig struct (exposed for advanced use).
RetryPolicyDecision function type.
DefaultRetryPolicyRetries on network errors and matching status codes.

Backoff

BackoffStrategyInterface with single Delay method.
BackoffFuncFunction-to-BackoffStrategy adapter.
ExponentialBackoff(base, factor)Exponential growth.
ExponentialJitterBackoff(base, factor)Exponential with jitter.
ConstantBackoff(d)Fixed wait.
AdaptiveBackoff(opts...)Composite strategy keyed on failure type.
AdaptiveRulesStruct mapping failure types to strategies.
AdaptiveOnRateLimit(b)Override the 429 strategy.
AdaptiveDefault(b)Override the fallback strategy.

Timeout

Timeout(d time.Duration) MiddlewareContext timeout middleware (overall or per-attempt depending on position).

Per-request overrides

WithOverrides(req, opts...) *http.RequestAttach overrides to a request.
OverridesThe override struct.
OverrideRetries(n)Override MaxAttempts.
OverrideBackoff(b)Override backoff strategy.
OverridePerAttemptTimeout(d)Override per-attempt deadline.