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
- A sentinel plus errors.Is lets the caller test the outcome.
- The context is checked between attempts, not just during them.
- Every attempt's error is joined, so nothing is swallowed.
Keywords and builtins used here
breakcasedeferelseerrorforfuncifintreturnselectstructtypevar
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.
Step 1 of 1 in Encore, step 15 of 15 in Errors.