Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 e344522c63 Return context error when a retry backoff wait is cancelled
CI / ci (push) Successful in 1m35s
CI / ci (pull_request) Successful in 1m35s
Retrying.Generate returned the earlier provider error (e.g. a 429) when
cancellation or the deadline interrupted the backoff wait, so callers and
the ai_runs record saw rate limiting instead of cancellation. Return the
context error from the interrupted wait instead. Retry counts, backoff and
other error semantics are unchanged.

Maestro task: 79447ef7-e55c-4641-b351-37bc22ffa760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:33:20 -04:00
2 changed files with 41 additions and 3 deletions
+4 -3
View File
@@ -30,7 +30,8 @@ const (
// rate limit that names no wait, waits a jittered exponential backoff. The Request's Timeout, or DefaultTimeout, is one
// deadline for the whole call, retries and waits included; a caller's
// earlier context deadline wins. A retry whose wait would not end before
// that deadline is not made: the last error is returned at once.
// that deadline is not made: the last error is returned at once. A wait
// cut short by cancellation or the deadline returns the context's error.
// ListModels and Verify pass straight through.
type Retrying struct {
Provider
@@ -83,8 +84,8 @@ func (r *Retrying) Generate(ctx context.Context, req Request) (Response, error)
if !r.now().Add(wait).Before(deadline) {
return resp, err
}
if serr := r.sleep(ctx, wait); serr != nil {
return resp, err
if err := r.sleep(ctx, wait); err != nil {
return Response{}, err
}
}
}
+37
View File
@@ -240,6 +240,43 @@ func TestRetryingStopsWhenContextDone(t *testing.T) {
assertConsumed(t, f, 1)
}
func TestRetryingWaitInterruptedReturnsContextError(t *testing.T) {
t.Run("cancelled", func(t *testing.T) {
r, f, _ := newTestRetrying()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r.sleep = func(ctx context.Context, d time.Duration) error {
cancel()
return sleep(ctx, d)
}
f.QueueGenerate(fail(NewRateLimitedError(3*time.Second, "")))
_, err := r.Generate(ctx, retryReq)
if !errors.Is(err, context.Canceled) || errors.Is(err, ErrRateLimited) {
t.Fatalf("err = %v, want context.Canceled", err)
}
assertConsumed(t, f, 1)
})
t.Run("deadline", func(t *testing.T) {
r, f, _ := newTestRetrying()
// The fake clock stands still, so the 500ms backoff fits before
// the caller's deadline; the real wait outlasts it.
r.jitter = func(time.Duration) time.Duration { return 0 }
r.sleep = func(ctx context.Context, _ time.Duration) error {
return sleep(ctx, time.Hour)
}
ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
defer cancel()
f.QueueGenerate(fail(NewRateLimitedError(0, "")))
_, err := r.Generate(ctx, retryReq)
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrRateLimited) {
t.Fatalf("err = %v, want context.DeadlineExceeded", err)
}
assertConsumed(t, f, 1)
})
}
// slowProvider is a Provider whose every attempt takes d on clock c,
// records the deadline of the context it received, and times out.
type slowProvider struct {