typestar

retry.go in Go

Retry with backoff, a context deadline, and errors that say what happened.

// Command retry demonstrates a retry loop with backoff and cancellation.
package main

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

var (
    // ErrExhausted means every attempt failed.
    ErrExhausted = errors.New("attempts exhausted")
    // ErrTransient is worth retrying; anything else is not.
    ErrTransient = errors.New("transient failure")
)

// Policy describes how hard to try.
type Policy struct {
    Attempts int
    Base     time.Duration
    Max      time.Duration
}

func (p Policy) backoff(attempt int) time.Duration {
    delay := p.Base << (attempt - 1)
    if delay > p.Max {
        delay = p.Max
    }
    return delay
}

// Do calls work until it succeeds, the context ends, or the attempts run out.
func Do(ctx context.Context, p Policy, work func(int) error) error {
    var problems error

    for attempt := 1; attempt <= p.Attempts; attempt++ {
        err := work(attempt)
        if err == nil {
            return nil
        }
        problems = errors.Join(problems, fmt.Errorf("attempt %d: %w",
            attempt, err))

        if !errors.Is(err, ErrTransient) {
            return problems // permanent: stop trying
        }
        if attempt == p.Attempts {
            break
        }

        select {
        case <-time.After(p.backoff(attempt)):
        case <-ctx.Done():
            return errors.Join(problems, ctx.Err())
        }
    }
    return errors.Join(problems, ErrExhausted)
}

func flaky(succeedOn int) func(int) error {
    return func(attempt int) error {
        if attempt < succeedOn {
            return fmt.Errorf("connection reset: %w", ErrTransient)
        }
        return nil
    }
}

func main() {
    policy := Policy{Attempts: 4, Base: 5 * time.Millisecond,
        Max: 40 * time.Millisecond}
    ctx := context.Background()

    if err := Do(ctx, policy, flaky(3)); err != nil {
        fmt.Println("unexpected:", err)
    } else {
        fmt.Println("succeeded on the third attempt")
    }

    err := Do(ctx, policy, flaky(99))
    fmt.Println("exhausted:", errors.Is(err, ErrExhausted))

    permanent := Do(ctx, policy, func(int) error {
        return errors.New("bad credentials")
    })
    fmt.Println("gave up early:", !errors.Is(permanent, ErrExhausted))

    short, cancel := context.WithTimeout(ctx, time.Millisecond)
    defer cancel()
    canceled := Do(short, policy, flaky(99))
    fmt.Println("canceled:", errors.Is(canceled, context.DeadlineExceeded))

    _ = rand.Intn(1) // keep the import honest for the seeded variant
}

How it works

  1. A sentinel plus errors.Is lets the caller test the outcome.
  2. The context is checked between attempts, not just during them.
  3. Every attempt's error is joined, so nothing is swallowed.

Keywords and builtins used here

The run, in numbers

Lines
96
Characters to type
2174
Tokens
470
Three-star pace
110 tpm

At the three-star pace of 110 tokens a minute, this run takes about 256 seconds.

Type this snippet

Step 1 of 1 in Encore, step 15 of 15 in Errors.

← Previous