Go

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 fn ignores its ctx, it keeps running and consuming resources after the timeout. A leak-free design therefore needs both the buffered channel and a fn that 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.Canceled or on 4xx errors.
  • Add jitter to avoid a thundering herd.
  • errors.Join keeps every cause, so errors.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

All 16 Classic Concurrency Coding Problems questions