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.
1. Overview #
Gamma wraps http.RoundTripper, the same extension point the standard library gives you. That means:
- No custom client type.
gamma.NewGamma(...)returns a plain*http.Client. Every library that accepts one keeps working. - No custom request type. Requests are still
*http.Request, created however you already create them. - No baked-in logger, metrics, or tracing. Add your own via a one-line middleware.
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 #
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 #
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 #
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 #
| Option | Purpose |
|---|---|
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 #
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:
- Checks the parent context for cancellation.
- Clones the request (fresh body, fresh context with optional per-attempt deadline).
- Calls the next
RoundTripper. - Runs the response/error through the configured
RetryPolicy. - If retryable and attempts remain, waits for the
BackoffStrategy's delay and loops.
4.2 All options #
| Option | Default | What 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.
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 #
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 #
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 #
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 #
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 #
Picks a different strategy depending on the failure type. Defaults:
| Trigger | Default strategy | Rationale |
|---|---|---|
429 rate-limit | ExponentialBackoff(2s, 3.0) | Back off hard — the server is asking us to. |
5xx server error | ExponentialJitterBackoff(1s, 2.0) | Retry with jitter to avoid herd. |
network error (resp == nil) | ConstantBackoff(100ms) | Often a transient hiccup; retry fast. |
| other retryable | ExponentialBackoff(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 #
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 #
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 #
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.
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.
| Option | Overrides |
|---|---|
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.Client | Build a client with the middleware chain installed. |
NewTransport(opts ...Option) http.RoundTripper | Build a bare round-tripper for use in your own *http.Client. |
Options
Use(m Middleware) Option | Append a middleware. |
WithBase(rt http.RoundTripper) Option | Override the underlying transport. |
WithClientTimeout(d time.Duration) Option | Set http.Client.Timeout (NewGamma only). |
Core types
Middleware | func(http.RoundTripper) http.RoundTripper |
RoundTripperFunc | Function-to-RoundTripper adapter. |
Chain(middlewares ...Middleware) Middleware | Compose middlewares, first = outermost. |
Retry
Retry(opts ...RetryOption) Middleware | The retry middleware. |
RetryMaxAttempts(n int) RetryOption | Total attempts. |
RetryOn(codes ...int) RetryOption | Retryable status codes. |
RetryWithBackoff(b BackoffStrategy) RetryOption | Delay strategy. |
RetryWithPolicy(p RetryPolicy) RetryOption | Custom decision function. |
RetryPerAttemptTimeout(d time.Duration) RetryOption | Per-attempt deadline. |
RetryConfig | Config struct (exposed for advanced use). |
RetryPolicy | Decision function type. |
DefaultRetryPolicy | Retries on network errors and matching status codes. |
Backoff
BackoffStrategy | Interface with single Delay method. |
BackoffFunc | Function-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. |
AdaptiveRules | Struct mapping failure types to strategies. |
AdaptiveOnRateLimit(b) | Override the 429 strategy. |
AdaptiveDefault(b) | Override the fallback strategy. |
Timeout
Timeout(d time.Duration) Middleware | Context timeout middleware (overall or per-attempt depending on position). |
Per-request overrides
WithOverrides(req, opts...) *http.Request | Attach overrides to a request. |
Overrides | The override struct. |
OverrideRetries(n) | Override MaxAttempts. |
OverrideBackoff(b) | Override backoff strategy. |
OverridePerAttemptTimeout(d) | Override per-attempt deadline. |