Retry with Exponential Backoff and Jitter (Go)

Retry transient operations with capped exponential delays, full jitter, context cancellation, and a permanent-error sentinel.

GoResilience

Code

snippet
package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"
)

type RetryConfig struct {
	Attempts  int
	BaseDelay time.Duration
	MaxDelay  time.Duration
	Jitter    float64
}

func (c RetryConfig) validate() error {
	if c.Attempts < 1 {
		return fmt.Errorf("attempts must be at least 1")
	}
	if c.BaseDelay < 0 || c.MaxDelay < 0 {
		return fmt.Errorf("delays must not be negative")
	}
	if c.Jitter < 0 || c.Jitter > 1 {
		return fmt.Errorf("jitter must be between 0 and 1")
	}
	return nil
}

var errPermanent = errors.New("permanent failure")

func isRetryable(err error) bool {
	return !errors.Is(err, errPermanent)
}

func Do(ctx context.Context, cfg RetryConfig, operation func(context.Context) error) error {
	if err := cfg.validate(); err != nil {
		return err
	}

	var lastErr error
	for attempt := 1; attempt <= cfg.Attempts; attempt++ {
		if err := ctx.Err(); err != nil {
			return err
		}
		lastErr = operation(ctx)
		if lastErr == nil {
			return nil
		}
		if !isRetryable(lastErr) || attempt == cfg.Attempts {
			break
		}

		shift := attempt - 1
		if shift > 6 {
			shift = 6
		}
		delay := cfg.BaseDelay * time.Duration(1<<shift)
		if cfg.MaxDelay > 0 && delay > cfg.MaxDelay {
			delay = cfg.MaxDelay
		}
		if cfg.Jitter > 0 {
			factor := 1 + (rand.Float64()*2-1)*cfg.Jitter
			delay = time.Duration(float64(delay) * factor)
		}

		timer := time.NewTimer(delay)
		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-timer.C:
		}
	}
	return fmt.Errorf("after retries: %w", lastErr)
}

func main() {
	cfg := RetryConfig{
		Attempts:  4,
		BaseDelay: 10 * time.Millisecond,
		MaxDelay:  40 * time.Millisecond,
		Jitter:    0.2,
	}

	attempts := 0
	err := Do(context.Background(), cfg, func(ctx context.Context) error {
		attempts++
		if attempts < 3 {
			return fmt.Errorf("temporary failure %d", attempts)
		}
		return nil
	})
	fmt.Printf("transient attempts=%d err=%v\n", attempts, err)

	attempts = 0
	err = Do(context.Background(), cfg, func(ctx context.Context) error {
		attempts++
		return fmt.Errorf("bad request: %w", errPermanent)
	})
	fmt.Printf("permanent attempts=%d err=%v\n", attempts, err)
}