gamma

package module
v2.0.1 Latest Latest
Warning

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

Go to latest
Published: Apr 24, 2026 License: MIT Imports: 9 Imported by: 0

README

Gamma

Gamma is a composable resilience layer for Go HTTP clients. Retry, timeouts, custom backoff, per-request overrides — each one a middleware you plug into a standard *http.Client. No custom client type, no custom request type, no surprises for callers.

client.Do(req)
  └─► gamma middleware chain
        ├─► Timeout              (optional, outermost)
        ├─► Retry                (with backoff + per-attempt timeout)
        └─► http.DefaultTransport (actual HTTP call)

Install

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

Requires Go 1.22+.

Quick Start

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 it. NewGamma returns a standard *http.Client — every existing tool that accepts one keeps working.

Why Middleware?

The old monolithic retry client had one big knob bag. Gamma replaces it with small, composable pieces:

  • You pick what you need. Just want retry? One Use() call. Want retry + timeout + rate limiting later? Three Use() calls.
  • You control the order. Timeout outside retry = overall cap. Timeout inside retry = per-attempt cap. Same middleware, different meaning based on position.
  • You can bring your own. Any func(http.RoundTripper) http.RoundTripper is a valid middleware. Logging, tracing, auth headers, feature flags — drop them into the chain.

Features

Retry

Automatic retry with configurable policy, backoff, and per-attempt timeout.

client := gamma.NewGamma(
    gamma.Use(gamma.Retry(
        gamma.RetryMaxAttempts(5),
        gamma.RetryOn(429, 502, 503, 504),
        gamma.RetryWithBackoff(gamma.ExponentialJitterBackoff(time.Second, 2.0)),
        gamma.RetryPerAttemptTimeout(3 * time.Second),
    )),
)
Option Default What it does
RetryMaxAttempts(n) 2 Total attempts (initial + retries)
RetryOn(codes...) 429, 503, 504 Status codes that trigger a retry
RetryWithBackoff(b) ExponentialBackoff(1s, 2.0) Delay strategy between attempts
RetryPerAttemptTimeout(d) 0 (disabled) Hard deadline for each individual attempt
RetryWithPolicy(fn) DefaultRetryPolicy Custom decision function for "should retry?"

About RetryPerAttemptTimeout: if a single attempt hangs (zombie TCP connection, server GC pause, dead replica), it would otherwise burn through your entire retry budget. This timeout cancels the stuck attempt so the retry loop actually gets to retry. Envoy, AWS SDK, and gRPC all have the same field — see the docs page linked below for the rationale.

Backoff Strategies
// Plain exponential — 1s, 2s, 4s, 8s…
gamma.ExponentialBackoff(time.Second, 2.0)

// Exponential with jitter — avoids thundering herd
gamma.ExponentialJitterBackoff(time.Second, 2.0)

// Fixed wait between every attempt
gamma.ConstantBackoff(200 * time.Millisecond)

// Different strategies for different failure types
gamma.AdaptiveBackoff(
    gamma.AdaptiveOnRateLimit(gamma.ExponentialBackoff(2*time.Second, 3.0)),
    gamma.AdaptiveDefault(gamma.ExponentialJitterBackoff(time.Second, 2.0)),
)

All strategies honour the Retry-After header when the server sends one.

Implement your own with a plain function:

custom := gamma.BackoffFunc(func(attempt int, resp *http.Response) time.Duration {
    return time.Duration(attempt+1) * 500 * time.Millisecond
})
Timeout

Standalone timeout middleware. Its meaning depends on where you place it in the chain:

// Overall cap — 30s across all retries + backoff combined
gamma.NewGamma(
    gamma.Use(gamma.Timeout(30 * time.Second)),
    gamma.Use(gamma.Retry(gamma.RetryMaxAttempts(3))),
)

// Per-attempt — same as RetryPerAttemptTimeout, 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),  // per-attempt
    )),
)
Per-Request Overrides

Different endpoints need different behaviour. Attach overrides to a specific request via its context — they take precedence over the middleware defaults for that request only.

req, _ := http.NewRequest("POST", "https://api.example.com/pay", body)
req = gamma.WithOverrides(req,
    gamma.OverrideRetries(1),                           // don't retry payments
    gamma.OverridePerAttemptTimeout(15 * time.Second),  // payment gateway is slow
)
resp, err := client.Do(req)

Available overrides: OverrideRetries, OverrideBackoff, OverridePerAttemptTimeout.

Custom Middleware

Any func(http.RoundTripper) http.RoundTripper is a valid middleware.

logging := func(next http.RoundTripper) http.RoundTripper {
    return gamma.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
        start := time.Now()
        resp, err := next.RoundTrip(req)
        log.Printf("%s %s → %v in %s", req.Method, req.URL, statusOf(resp, err), time.Since(start))
        return resp, err
    })
}

client := gamma.NewGamma(
    gamma.Use(logging),
    gamma.Use(gamma.Retry()),
)
Transport vs Client

Two constructors depending on how much control you need:

// Returns a ready-to-use *http.Client
client := gamma.NewGamma(gamma.Use(gamma.Retry()))

// Returns a bare http.RoundTripper — plug it into your own client
rt := gamma.NewTransport(gamma.Use(gamma.Retry()))
client := &http.Client{
    Transport: rt,
    Timeout:   30 * time.Second,
    Jar:       myJar,
}

Middleware Ordering

Middlewares are applied in the order they're added. The first Use() becomes the outermost wrapper — it runs first on the way in and last on the way out.

gamma.NewGamma(
    gamma.Use(gamma.Timeout(30 * time.Second)),   // runs first
    gamma.Use(logging),
    gamma.Use(gamma.Retry()),                     // closest to the network
)

// request flow:  Timeout → logging → Retry → http.DefaultTransport
// response flow: http.DefaultTransport → Retry → logging → Timeout

The placement of Timeout relative to Retry is the most common gotcha — see the Timeout section above.

File Layout

gamma.go        NewGamma, NewTransport
middleware.go   Middleware type, Chain
options.go      Option, Use, WithBase, WithClientTimeout
helpers.go      RoundTripperFunc
retry.go        Retry middleware + options
backoff.go      BackoffStrategy, Exponential, Jitter, Constant, Adaptive
timeout.go      Standalone Timeout middleware
overide.go      Per-request Overrides via context
policy.go       RetryPolicy, DefaultRetryPolicy

Documentation

For a deeper walkthrough with design rationale, a complete API reference, and runnable recipes, see docs/index.html.

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultRetryPolicy

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

DefaultRetryPolicy retries on any network error or when the response status code matches one of the configured retryable codes.

func NewGamma

func NewGamma(opts ...Option) *http.Client

NewGamma builds a standard *http.Client with the gamma middleware chain installed as its transport. This is the recommended entry point for most callers — you get a fully configured client in one call and keep using the familiar http.Client API.

Middlewares are applied in the order given: the first Use becomes the outermost wrapper (see Chain for the exact semantics). WithBase and WithClientTimeout may appear anywhere in the option list.

client := gamma.NewGamma(
    gamma.WithClientTimeout(30 * time.Second),
    gamma.Use(gamma.Retry(
        gamma.RetryMaxAttempts(3),
        gamma.RetryOn(429, 503, 504),
    )),
)
resp, err := client.Get("https://api.example.com/data")

func NewTransport

func NewTransport(opts ...Option) http.RoundTripper

NewTransport builds a composable http.RoundTripper from the given options. Use it when you need to attach the gamma middleware chain to an *http.Client you manage yourself — for example, because the client is shared with other code, or because you want to pin its Timeout, Jar, or redirect policy independently of gamma.

The WithClientTimeout option is ignored here (a bare RoundTripper has no Timeout field); set http.Client.Timeout on your client directly instead.

rt := gamma.NewTransport(
    gamma.Use(gamma.Retry()),
    gamma.Use(gamma.Timeout(5 * time.Second)),
)
client := &http.Client{Transport: rt, Timeout: 30 * time.Second}

func WithOverrides

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

WithOverrides attaches per-request configuration to the request via its context. Middlewares that support overrides (for example, the retry middleware) read these values through [getOverrides] and merge them on top of their own defaults.

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)

Types

type AdaptiveOption

type AdaptiveOption func(*AdaptiveRules)

AdaptiveOption configures an AdaptiveRules set via AdaptiveBackoff.

func AdaptiveDefault

func AdaptiveDefault(b BackoffStrategy) AdaptiveOption

AdaptiveDefault overrides the fallback backoff used when no specific rule matches.

func AdaptiveOnRateLimit

func AdaptiveOnRateLimit(b BackoffStrategy) AdaptiveOption

AdaptiveOnRateLimit overrides the backoff used for 429 responses.

backoff := gamma.AdaptiveBackoff(
    gamma.AdaptiveOnRateLimit(gamma.ConstantBackoff(5 * time.Second)),
)

type AdaptiveRules

type AdaptiveRules struct {
	// OnRateLimit is used when the server responds with 429 Too Many Requests.
	OnRateLimit BackoffStrategy
	// OnServerError is used for 5xx responses.
	OnServerError BackoffStrategy
	// OnConnReset is used when the response is nil (network-level failure).
	OnConnReset BackoffStrategy
	// Default is the fallback for any other retryable error.
	Default BackoffStrategy
}

AdaptiveRules maps error categories to dedicated BackoffStrategy implementations. Use with AdaptiveBackoff to apply different retry policies depending on the type of failure.

type BackoffFunc

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

BackoffFunc is an adapter that lets ordinary functions satisfy BackoffStrategy.

custom := gamma.BackoffFunc(func(attempt int, resp *http.Response) time.Duration {
    return time.Duration(attempt+1) * 500 * time.Millisecond
})

func (BackoffFunc) Delay

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

Delay calls f(attempt, resp).

type BackoffStrategy

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

BackoffStrategy determines the delay between retry attempts. Implementations receive the current attempt number (zero-indexed) and the HTTP response (which may be nil for network-level errors).

strategy := gamma.ExponentialBackoff(time.Second, 2.0)
delay := strategy.Delay(3, resp) // 1s * 2^3 = 8s

func AdaptiveBackoff

func AdaptiveBackoff(opts ...AdaptiveOption) BackoffStrategy

AdaptiveBackoff returns a composite strategy that selects a backoff policy based on the type of failure. By default it uses:

  • 429 rate-limit: exponential backoff (2s base, 3x factor)
  • 5xx server error: exponential backoff with jitter (1s base, 2x factor)
  • nil response (network error): constant 100ms
  • everything else: exponential backoff (1s base, 2x factor)

Pass AdaptiveOption values to override individual rules.

// use defaults
backoff := gamma.AdaptiveBackoff()

// override rate-limit strategy
backoff = gamma.AdaptiveBackoff(
    gamma.AdaptiveOnRateLimit(gamma.ExponentialJitterBackoff(3*time.Second, 2.0)),
)

func ConstantBackoff

func ConstantBackoff(d time.Duration) BackoffStrategy

ConstantBackoff returns a strategy that always waits the same duration, regardless of the attempt number. If the response contains a Retry-After header, that value takes precedence.

// always wait 200ms between retries
backoff := gamma.ConstantBackoff(200 * time.Millisecond)

func ExponentialBackoff

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

ExponentialBackoff returns a strategy that waits base * factor^attempt. If the response contains a Retry-After header, that value takes precedence.

// 1s, 2s, 4s, 8s, …
backoff := gamma.ExponentialBackoff(time.Second, 2.0)

// 500ms, 1.5s, 4.5s, 13.5s, …
backoff = gamma.ExponentialBackoff(500*time.Millisecond, 3.0)

func ExponentialJitterBackoff

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

ExponentialJitterBackoff is like ExponentialBackoff but adds random jitter to avoid thundering-herd problems. The delay is uniformly distributed in [half, full] where full = base * factor^attempt. If the response contains a Retry-After header, that value takes precedence.

// jittered delays centred around 1s, 2s, 4s, …
backoff := gamma.ExponentialJitterBackoff(time.Second, 2.0)

type Middleware

type Middleware func(http.RoundTripper) http.RoundTripper

Middleware wraps an http.RoundTripper and returns a new one with added behaviour. It is the fundamental building block of gamma — every feature (retry, timeout, circuit breaker, rate limit, observability hooks) is expressed as a Middleware so the pieces compose cleanly.

Because a Middleware is just a function, users can freely write their own and drop them into the chain alongside the built-ins.

logging := func(next http.RoundTripper) http.RoundTripper {
    return gamma.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
        log.Printf("→ %s %s", req.Method, req.URL)
        return next.RoundTrip(req)
    })
}

client := gamma.NewGamma(gamma.Use(logging))

func Chain

func Chain(middlewares ...Middleware) Middleware

Chain composes middlewares into a single Middleware. The first argument is the outermost wrapper: it runs first on the way in and last on the way out.

Given Chain(a, b, c), a request flows a → b → c → base transport, and the response flows back c → b → a. This matches the conventional "onion" model used by most HTTP middleware libraries.

chain := gamma.Chain(logging, rateLimit, retry)
rt    := chain(http.DefaultTransport)
client := &http.Client{Transport: rt}

func Retry

func Retry(opts ...RetryOption) Middleware

Retry returns a middleware that has a baked in default configuration for retry. This is the simplest way to get started with retry.

client := gamma.NewGamma(
    gamma.Use(gamma.Retry()),
)
resp, err := client.Get("https://api.example.com/data")

It also supports custom configuration via RetryOption functions.

client := gamma.NewGamma(
    gamma.Use(gamma.Retry(
        gamma.RetryMaxAttempts(3),
        gamma.RetryOn(429, 503, 504),
        gamma.RetryWithBackoff(gamma.ExponentialBackoff(time.Second, 2.0)),
    )),
)
resp, err := client.Get("https://api.example.com/data")

func Timeout

func Timeout(d time.Duration) Middleware

Timeout returns a middleware that enforces a deadline on the request context. Its meaning depends on where you place it in the chain:

  • Outside (before) Retry — acts as an overall timeout across every attempt plus the backoff waits between them. Once d elapses the context is cancelled and the retry loop gives up.

  • Inside (after) Retry — acts as a per-attempt timeout, because the retry middleware re-enters the rest of the chain on every iteration and each iteration gets a fresh Timeout context.

If you only need a per-attempt deadline, prefer RetryPerAttemptTimeout — it lives inside the retry config and is both simpler and more discoverable. Reach for this middleware when you want an overall cap or want the deadline expressed as a composable pipeline stage.

// overall: 30s hard cap across all retries + backoff
client := gamma.NewGamma(
    gamma.Use(gamma.Timeout(30 * time.Second)),
    gamma.Use(gamma.Retry()),
)

// per-attempt: each individual attempt capped at 5s
client = gamma.NewGamma(
    gamma.Use(gamma.Retry()),
    gamma.Use(gamma.Timeout(5 * time.Second)),
)

type Option

type Option func(*config)

Option customises the transport or client produced by NewTransport and NewGamma. Options follow the functional-options pattern: each one is a small function that mutates the internal [config].

func Use

func Use(m Middleware) Option

Use appends a Middleware to the chain. Order matters: the first Use() call becomes the outermost wrapper, so it runs first on the request and last on the response.

client := gamma.NewGamma(
    gamma.Use(gamma.Timeout(30 * time.Second)), // outermost
    gamma.Use(gamma.Retry()),
    gamma.Use(gamma.CircuitBreaker(5, 30*time.Second)),
)

func WithBase

func WithBase(rt http.RoundTripper) Option

WithBase overrides the underlying http.RoundTripper. Defaults to http.DefaultTransport. Use this when you need to customise the base transport (for example, to set proxy settings, TLS config, or connection pool limits) while still layering gamma middlewares on top.

base := &http.Transport{MaxIdleConnsPerHost: 100}
client := gamma.NewGamma(
    gamma.WithBase(base),
    gamma.Use(gamma.Retry()),
)

func WithClientTimeout

func WithClientTimeout(d time.Duration) Option

WithClientTimeout sets the http.Client.Timeout on the client returned by NewGamma. This is the hard ceiling the standard library enforces on the entire request (including redirects, connect, and read). It has no effect when using NewTransport.

Prefer Timeout middleware when you want the deadline to participate in the middleware pipeline (for example, applying per-attempt timeouts or composing with retry).

client := gamma.NewGamma(
    gamma.WithClientTimeout(10 * time.Second),
    gamma.Use(gamma.Retry()),
)

type OverrideOption

type OverrideOption func(*Overrides)

OverrideOption mutates an Overrides value. Pass one or more to WithOverrides to attach per-request settings to an http.Request.

func OverrideBackoff

func OverrideBackoff(b BackoffStrategy) OverrideOption

OverrideBackoff sets the BackoffStrategy for this request, overriding the one configured on the retry middleware.

req = gamma.WithOverrides(req,
    gamma.OverrideBackoff(gamma.ConstantBackoff(5 * time.Second)),
)

func OverridePerAttemptTimeout

func OverridePerAttemptTimeout(d time.Duration) OverrideOption

OverridePerAttemptTimeout sets the per-attempt deadline for this request, overriding the value configured on the retry middleware.

// this endpoint's backend is known to be slow
req = gamma.WithOverrides(req,
    gamma.OverridePerAttemptTimeout(15 * time.Second),
)

func OverrideRetries

func OverrideRetries(n int) OverrideOption

OverrideRetries sets the maximum number of attempts for this request, overriding whatever was configured on the retry middleware.

// this specific request should not be retried
req = gamma.WithOverrides(req, gamma.OverrideRetries(1))

type Overrides

type Overrides struct {
	// MaxAttempts, when non-nil, replaces [RetryConfig.MaxAttempts] for this
	// request only.
	MaxAttempts *int

	// RetryStatusCodes, when non-nil, replaces [RetryConfig.RetryStatusCodes]
	// for this request only.
	RetryStatusCodes []int

	// Backoff, when non-nil, replaces [RetryConfig.Backoff] for this request
	// only.
	Backoff BackoffStrategy

	// PerAttemptTimeout, when non-nil, replaces
	// [RetryConfig.PerAttemptTimeout] for this request only.
	PerAttemptTimeout *time.Duration
}

Overrides holds per-request configuration that takes precedence over the defaults baked into the middleware chain. Fields use pointers where the zero value is a meaningful setting (for example, 0 retries), so that an "unset" override can be distinguished from "explicitly set to zero".

Callers don't usually construct an Overrides directly — they use WithOverrides together with the Override* option constructors.

type RetryConfig

type RetryConfig struct {
	MaxAttempts       int
	Policy            RetryPolicy
	RetryStatusCodes  []int
	Backoff           BackoffStrategy
	PerAttemptTimeout time.Duration
}

RetryConfig holds all tuneable knobs for the retry middleware. Every field has a sensible default (see [defaultRetryConfig]), so callers only need to override what they care about via RetryOption functions.

cfg := &gamma.RetryConfig{
    MaxAttempts:      4,
    RetryStatusCodes: []int{429, 502, 503},
    Backoff:          gamma.ExponentialBackoff(500*time.Millisecond, 2.0),
    Policy:           gamma.DefaultRetryPolicy,
}

type RetryOption

type RetryOption func(*RetryConfig)

RetryOption is a functional option that mutates a RetryConfig. Pass one or more RetryOption values to Retry to customize behaviour.

gamma.Retry(
    gamma.RetryMaxAttempts(5),
    gamma.RetryOn(429, 503),
)

func RetryMaxAttempts

func RetryMaxAttempts(n int) RetryOption

RetryMaxAttempts sets the total number of attempts (initial + retries). For example, RetryMaxAttempts(3) means one initial request plus two retries. The default is 2.

client := gamma.NewGamma(
    gamma.Use(gamma.Retry(
        gamma.RetryMaxAttempts(5),
    )),
)

func RetryOn

func RetryOn(codes ...int) RetryOption

RetryOn replaces the default set of retryable HTTP status codes. Only responses whose status code appears in codes will be retried (in addition to network-level errors, which are always retried by the default policy).

client := gamma.NewGamma(
    gamma.Use(gamma.Retry(
        gamma.RetryOn(429, 502, 503, 504),
    )),
)

func RetryPerAttemptTimeout

func RetryPerAttemptTimeout(d time.Duration) RetryOption

RetryPerAttemptTimeout sets a per-attempt deadline. Each individual round-trip is cancelled if it exceeds this duration, and the next retry fires. A zero value (the default) means no per-attempt timeout — only the overall request context governs cancellation.

client := gamma.NewGamma(
    gamma.Use(gamma.Retry(
        gamma.RetryPerAttemptTimeout(2*time.Second),
    )),
)

func RetryWithBackoff

func RetryWithBackoff(b BackoffStrategy) RetryOption

RetryWithBackoff overrides the delay strategy used between retry attempts. The default is ExponentialBackoff with a 1 s base and a factor of 2.0.

client := gamma.NewGamma(
    gamma.Use(gamma.Retry(
        gamma.RetryWithBackoff(gamma.ExponentialBackoff(200*time.Millisecond, 3.0)),
    )),
)

func RetryWithPolicy

func RetryWithPolicy(p RetryPolicy) RetryOption

RetryWithPolicy overrides the function that decides whether a failed request should be retried. The default is DefaultRetryPolicy, which retries on network errors and any status code listed in RetryStatusCodes.

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

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

type RetryPolicy

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

RetryPolicy decides whether a failed request should be retried based on the response, error, and the set of retryable status codes.

type RoundTripperFunc

type RoundTripperFunc func(*http.Request) (*http.Response, error)

RoundTripperFunc adapts an ordinary function into an http.RoundTripper, mirroring the http.HandlerFunc pattern on the server side. It is exported so that callers writing their own middlewares can return a [RoundTripper] without defining a new struct type.

logging := func(next http.RoundTripper) http.RoundTripper {
    return gamma.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
        log.Printf("→ %s %s", req.Method, req.URL)
        return next.RoundTrip(req)
    })
}

func (RoundTripperFunc) RoundTrip

func (f RoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip calls f(req) and satisfies http.RoundTripper.

Jump to

Keyboard shortcuts

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