llm.NewRetrying wraps a Provider and retries ErrRateLimited, ErrTimeout and 5xx ErrProvider at most twice. A rate limit waits RetryAfter, capped at 20 s; other retries use jittered exponential backoff. ErrAuth, 4xx and other errors are never retried, and no retry is made whose wait would not end before the caller's context deadline. Generate gets a 60 s per-attempt timeout when Request.Timeout is 0. Maestro task: 1a882923-fdb4-4576-ab82-9529e9974b26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
7.9 KiB
Go
272 lines
7.9 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeClock is a clock whose sleeps return at once, advance it and are
|
|
// recorded.
|
|
type fakeClock struct {
|
|
t time.Time
|
|
sleeps []time.Duration
|
|
}
|
|
|
|
func (c *fakeClock) now() time.Time { return c.t }
|
|
|
|
func (c *fakeClock) sleep(ctx context.Context, d time.Duration) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
c.sleeps = append(c.sleeps, d)
|
|
c.t = c.t.Add(d)
|
|
return nil
|
|
}
|
|
|
|
// newTestRetrying returns a Retrying over a fresh Fake with a fake clock
|
|
// at the real current time, so real context deadlines compare sensibly,
|
|
// and jitter that always returns its maximum.
|
|
func newTestRetrying() (*Retrying, *Fake, *fakeClock) {
|
|
f := &Fake{}
|
|
c := &fakeClock{t: time.Now()}
|
|
r := NewRetrying(f)
|
|
r.now = c.now
|
|
r.sleep = c.sleep
|
|
r.jitter = func(n time.Duration) time.Duration { return n - 1 }
|
|
return r, f, c
|
|
}
|
|
|
|
var retryReq = Request{Messages: []Message{{Role: RoleUser, Content: "plan dinners"}}}
|
|
|
|
func ok(text string) GenerateResult { return GenerateResult{Response: Response{Text: text}} }
|
|
|
|
func fail(err error) GenerateResult { return GenerateResult{Err: err} }
|
|
|
|
func assertConsumed(t *testing.T, f *Fake, attempts int) {
|
|
t.Helper()
|
|
if g, _, _ := f.Remaining(); g != 0 {
|
|
t.Errorf("%d scripted Generate results unused", g)
|
|
}
|
|
if n := len(f.Requests()); n != attempts {
|
|
t.Errorf("made %d attempts, want %d", n, attempts)
|
|
}
|
|
}
|
|
|
|
func TestRetryingRateLimitedThenSuccess(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
f.QueueGenerate(fail(NewRateLimitedError(3*time.Second, "")), ok("dinner"))
|
|
|
|
resp, err := r.Generate(context.Background(), retryReq)
|
|
if err != nil || resp.Text != "dinner" {
|
|
t.Fatalf("Generate = %+v, %v", resp, err)
|
|
}
|
|
if want := []time.Duration{3 * time.Second}; !slices.Equal(c.sleeps, want) {
|
|
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
|
}
|
|
assertConsumed(t, f, 2)
|
|
}
|
|
|
|
func TestRetryingRateLimitedWaitIsCapped(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
f.QueueGenerate(fail(NewRateLimitedError(time.Hour, "")), ok("dinner"))
|
|
|
|
if _, err := r.Generate(context.Background(), retryReq); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := []time.Duration{MaxRetryAfter}; !slices.Equal(c.sleeps, want) {
|
|
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
|
}
|
|
assertConsumed(t, f, 2)
|
|
}
|
|
|
|
func TestRetryingRateLimitedWithoutRetryAfterBacksOff(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
f.QueueGenerate(fail(NewRateLimitedError(0, "")), ok("dinner"))
|
|
|
|
if _, err := r.Generate(context.Background(), retryReq); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := []time.Duration{time.Second - 1}; !slices.Equal(c.sleeps, want) {
|
|
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
|
}
|
|
}
|
|
|
|
func TestRetrying5xxTwiceThenSuccess(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
f.QueueGenerate(
|
|
fail(NewProviderError(503, []byte("busy"), "")),
|
|
// Adapters wrap the error they return.
|
|
fail(fmt.Errorf("openai: generate: %w", NewProviderError(503, []byte("busy"), ""))),
|
|
ok("dinner"),
|
|
)
|
|
|
|
resp, err := r.Generate(context.Background(), retryReq)
|
|
if err != nil || resp.Text != "dinner" {
|
|
t.Fatalf("Generate = %+v, %v", resp, err)
|
|
}
|
|
// Max jitter: [0.5 s, 1 s) then [1 s, 2 s).
|
|
if want := []time.Duration{time.Second - 1, 2*time.Second - 1}; !slices.Equal(c.sleeps, want) {
|
|
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
|
}
|
|
assertConsumed(t, f, 3)
|
|
}
|
|
|
|
func TestRetryingBackoffJitterLowerBound(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
r.jitter = func(time.Duration) time.Duration { return 0 }
|
|
f.QueueGenerate(fail(ErrTimeout), fail(ErrTimeout), ok("dinner"))
|
|
|
|
if _, err := r.Generate(context.Background(), retryReq); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := []time.Duration{500 * time.Millisecond, time.Second}; !slices.Equal(c.sleeps, want) {
|
|
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
|
}
|
|
}
|
|
|
|
func TestRetrying5xxThreeTimesFails(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
last := NewProviderError(503, []byte("third"), "")
|
|
f.QueueGenerate(
|
|
fail(NewProviderError(503, []byte("first"), "")),
|
|
fail(NewProviderError(503, []byte("second"), "")),
|
|
fail(last),
|
|
)
|
|
|
|
_, err := r.Generate(context.Background(), retryReq)
|
|
if err != last {
|
|
t.Fatalf("err = %v, want the last attempt's error", err)
|
|
}
|
|
if len(c.sleeps) != MaxRetries {
|
|
t.Errorf("slept %d times, want %d", len(c.sleeps), MaxRetries)
|
|
}
|
|
assertConsumed(t, f, 3)
|
|
}
|
|
|
|
func TestRetryingDoesNotRetryPermanentErrors(t *testing.T) {
|
|
for name, err := range map[string]error{
|
|
"401 auth": ErrAuth,
|
|
"400": NewProviderError(400, []byte("bad request"), ""),
|
|
"404": NewProviderError(404, []byte("no such model"), ""),
|
|
"bad output": ErrBadOutput,
|
|
"other": errors.New("llm: request has no messages"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
f.QueueGenerate(fail(err))
|
|
|
|
if _, got := r.Generate(context.Background(), retryReq); got != err {
|
|
t.Fatalf("err = %v, want %v", got, err)
|
|
}
|
|
if len(c.sleeps) != 0 {
|
|
t.Errorf("slept %v", c.sleeps)
|
|
}
|
|
assertConsumed(t, f, 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRetryingDeadlineShorterThanRetryAfter(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
rl := NewRateLimitedError(10*time.Second, "")
|
|
f.QueueGenerate(fail(rl))
|
|
ctx, cancel := context.WithDeadline(context.Background(), c.t.Add(5*time.Second))
|
|
defer cancel()
|
|
|
|
if _, err := r.Generate(ctx, retryReq); err != rl {
|
|
t.Fatalf("err = %v, want the rate-limited error", err)
|
|
}
|
|
if len(c.sleeps) != 0 {
|
|
t.Errorf("slept %v", c.sleeps)
|
|
}
|
|
assertConsumed(t, f, 1)
|
|
}
|
|
|
|
func TestRetryingDeadlineShorterThanBackoff(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
f.QueueGenerate(fail(ErrTimeout))
|
|
// The first backoff with maximum jitter is just under 1 s.
|
|
ctx, cancel := context.WithDeadline(context.Background(), c.t.Add(time.Second-1))
|
|
defer cancel()
|
|
|
|
if _, err := r.Generate(ctx, retryReq); !errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("err = %v, want ErrTimeout", err)
|
|
}
|
|
if len(c.sleeps) != 0 {
|
|
t.Errorf("slept %v", c.sleeps)
|
|
}
|
|
assertConsumed(t, f, 1)
|
|
}
|
|
|
|
func TestRetryingRetriesWithinDeadline(t *testing.T) {
|
|
r, f, c := newTestRetrying()
|
|
f.QueueGenerate(fail(NewRateLimitedError(3*time.Second, "")), ok("dinner"))
|
|
ctx, cancel := context.WithDeadline(context.Background(), c.t.Add(time.Minute))
|
|
defer cancel()
|
|
|
|
if _, err := r.Generate(ctx, retryReq); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertConsumed(t, f, 2)
|
|
}
|
|
|
|
func TestRetryingStopsWhenContextDone(t *testing.T) {
|
|
r, f, _ := newTestRetrying()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
f.QueueGenerate(fail(ErrTimeout))
|
|
|
|
if _, err := r.Generate(ctx, retryReq); !errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("err = %v, want ErrTimeout", err)
|
|
}
|
|
assertConsumed(t, f, 1)
|
|
}
|
|
|
|
func TestRetryingDefaultTimeout(t *testing.T) {
|
|
r, f, _ := newTestRetrying()
|
|
f.QueueGenerate(ok("a"), ok("b"))
|
|
custom := retryReq
|
|
custom.Timeout = 5 * time.Second
|
|
|
|
for _, req := range []Request{retryReq, custom} {
|
|
if _, err := r.Generate(context.Background(), req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got := f.Requests()
|
|
if got[0].Timeout != DefaultTimeout || got[1].Timeout != 5*time.Second {
|
|
t.Fatalf("timeouts = %v, %v; want %v, 5s", got[0].Timeout, got[1].Timeout, DefaultTimeout)
|
|
}
|
|
}
|
|
|
|
func TestRetryingPassesThroughListModelsAndVerify(t *testing.T) {
|
|
r, f, _ := newTestRetrying()
|
|
f.QueueListModels(ListModelsResult{Err: ErrTimeout})
|
|
f.QueueVerify(ErrTimeout)
|
|
|
|
if _, err := r.ListModels(context.Background()); !errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("ListModels err = %v", err)
|
|
}
|
|
if err := r.Verify(context.Background()); !errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("Verify err = %v", err)
|
|
}
|
|
if _, l, v := f.Remaining(); l != 0 || v != 0 {
|
|
t.Fatalf("Remaining = %d, %d", l, v)
|
|
}
|
|
}
|
|
|
|
func TestSleepStopsWhenContextDone(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := sleep(ctx, time.Hour); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("sleep err = %v", err)
|
|
}
|
|
if err := sleep(context.Background(), time.Millisecond); err != nil {
|
|
t.Fatalf("sleep err = %v", err)
|
|
}
|
|
}
|