Implement a retry-with-timeout helper that runs a function in a goroutine and returns early when the per-attempt or overall deadline expires, without leaking goroutines.
Question 530HardGo 1.22 to 1.25
Each attempt gets a child context with a per-attempt timeout, and the overall deadline comes from the parent ctx. The attempt runs in a goroutine that writes into a buffered channel of size 1. If the caller gives up, the goroutine can still send its result and exit instead of blocking forever. That buffer is the leak fix.
type result[T any] struct { // generic funcs can't declare local types
v T
err error
}
func attempt[T any](ctx context.Context, d time.Duration, fn func(context.Context) (T, error)) (T, error) {
ctx, cancel := context.WithTimeout(ctx, d)
defer cancel()
ch := make(chan result[T], 1)
go func() {
v, err := fn(ctx)
ch <- result[T]{v, err} // never blocks thanks to the buffer
}()
select {
case r := <-ch:
return r.v, r.err
case <-ctx.Done():
var zero T
return zero, ctx.Err()
}
}
func Retry[T any](ctx context.Context, attempts int, perAttempt time.Duration,
fn func(context.Context) (T, error)) (T, error) {
var zero T
var errs []error
backoff := 50 * time.Millisecond
for i := range attempts {
v, err := attempt(ctx, perAttempt, fn)
if err == nil {
return v, nil
}
errs = append(errs, fmt.Errorf("attempt %d: %w", i+1, err))
if ctx.Err() != nil || i == attempts-1 {
break
}
t := time.NewTimer(backoff + rand.N(backoff)) // math/rand/v2 jitter
select {
case <-t.C:
case <-ctx.Done():
t.Stop()
return zero, errors.Join(append(errs, ctx.Err())...)
}
backoff *= 2
}
return zero, errors.Join(errs...)
}
Key points:
- You cannot kill a goroutine in Go. "Returning early" only stops waiting. If
fnignores itsctx, it keeps running and consuming resources after the timeout. A leak-free design therefore needs both the buffered channel and afnthat honours cancellation. - With an unbuffered
ch, every timed-out attempt leaks a goroutine blocked on its send. That is the classic bug interviewers look for. - Only retry idempotent operations. Inspect the error before retrying: don't retry on
context.Canceledor on 4xx errors. - Add jitter to avoid a thundering herd.
errors.Joinkeeps every cause, soerrors.Is(err, context.DeadlineExceeded)still works on the result.- Declaring a type inside a generic function is a compile error ("not currently supported"), which is why
result[T]is at package level.
More on Classic Concurrency Coding Problems
- Q528Implement a sharded concurrent map (N shards with separate mutexes). When does it beat sync.Map and a single RWMutex?
- Q529Implement a readers-writer lock that prefers writers using only channels or sync.Mutex. Why is it hard to get right?
- Q531Implement a heartbeat/watchdog: a worker must signal liveness every T, or a supervisor restarts it.