132 lines
4.2 KiB
Go
132 lines
4.2 KiB
Go
package utils
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
/*
|
|
Being rate limited by the model provider.
|
|
|
|
Groq's free tier is 8,000 tokens a minute, which is three or four Buddy
|
|
questions — so this is ordinary use, not an edge case. Its message reads:
|
|
|
|
Rate limit reached for model `openai/gpt-oss-120b` in organization
|
|
`org_01m38x8s72e759kn6g88ve2dhj` service tier `on_demand` on tokens per
|
|
minute (TPM): Limit 8000, Used 7320, Requested 792. Please try again in 840ms.
|
|
|
|
That sentence was being returned to the console as Buddy's ANSWER. It names our
|
|
billing account, it is about arithmetic the shopkeeper cannot influence, and the
|
|
condition it describes is usually over in under a second.
|
|
*/
|
|
|
|
func TestTheWaitIsReadOutOfTheProvidersProse(t *testing.T) {
|
|
// The part most likely to change, because it is somebody else's wording.
|
|
for _, tc := range []struct {
|
|
message string
|
|
want time.Duration
|
|
}{
|
|
{"Please try again in 840ms.", 840 * time.Millisecond},
|
|
{"please try again in 1.5s", 1500 * time.Millisecond},
|
|
{"Try again in 2s. Need more tokens?", 2 * time.Second},
|
|
{"Rate limit reached … Please try again in 397.499999ms.", 397499999 * time.Nanosecond},
|
|
} {
|
|
got := waitFromMessage(tc.message)
|
|
// Milliseconds is the resolution that matters; the fractional tail of
|
|
// 397.499999ms is noise.
|
|
if (got - tc.want).Abs() > time.Millisecond {
|
|
t.Fatalf("%q read as %v, want %v", tc.message, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProseWithNoWaitInItReadsAsUnknown(t *testing.T) {
|
|
// Zero means "no idea", and the caller uses its own floor. Guessing a
|
|
// number here would be inventing one.
|
|
for _, message := range []string{
|
|
"", "Rate limit reached.", "try again later", "try again in soon", "try again in 0ms",
|
|
} {
|
|
if got := waitFromMessage(message); got != 0 {
|
|
t.Fatalf("%q produced a wait of %v", message, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheRetryAfterHeaderWinsOverTheProse(t *testing.T) {
|
|
// It is the standard, and a proxy can add it where the body has nothing.
|
|
resp := &http.Response{Header: http.Header{}}
|
|
resp.Header.Set("Retry-After", "2")
|
|
|
|
got := retryAfter(resp, []byte(`{"error":{"message":"try again in 840ms"}}`))
|
|
if got != 2*time.Second {
|
|
t.Fatalf("header ignored: got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAnUnwaitableLimitIsNotWaitedFor(t *testing.T) {
|
|
// A hard quota can suggest a minute. Holding a request that long is worse
|
|
// than saying "busy" — the person watching the spinner decided it was
|
|
// broken long before it returned.
|
|
if waitBeforeRetry(context.Background(), time.Minute) {
|
|
t.Fatal("agreed to hold the request for a minute")
|
|
}
|
|
}
|
|
|
|
func TestNothingIsWaitedForOnceTheCallerHasGone(t *testing.T) {
|
|
// A retry after the browser gave up is work nobody will see.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
if waitBeforeRetry(ctx, 10*time.Millisecond) {
|
|
t.Fatal("waited although the request was already abandoned")
|
|
}
|
|
}
|
|
|
|
func TestAShortWaitIsHonoured(t *testing.T) {
|
|
start := time.Now()
|
|
if !waitBeforeRetry(context.Background(), 30*time.Millisecond) {
|
|
t.Fatal("refused a 30ms wait")
|
|
}
|
|
if time.Since(start) < 25*time.Millisecond {
|
|
t.Fatal("returned without waiting")
|
|
}
|
|
}
|
|
|
|
func TestBeingBusyIsRecognisableWithoutReadingTheText(t *testing.T) {
|
|
// The HTTP layer answers 429 on this, and the console tells the person to
|
|
// try again. Matching on the provider's wording instead would break the
|
|
// moment Groq rephrases it.
|
|
err := error(&tooManyRequests{what: "assistant", after: time.Second})
|
|
|
|
if !errors.Is(err, ErrBusy) {
|
|
t.Fatal("a rate-limited call is not recognisable as busy")
|
|
}
|
|
}
|
|
|
|
func TestTheProvidersBillingDetailsAreNotInTheMessage(t *testing.T) {
|
|
// The whole point. Whatever the provider said, this is what a shopkeeper
|
|
// reads.
|
|
err := error(&tooManyRequests{what: "assistant", after: 840 * time.Millisecond})
|
|
|
|
for _, leaked := range []string{"org_", "TPM", "8000", "tier", "billing"} {
|
|
if contains(err.Error(), leaked) {
|
|
t.Fatalf("%q reaches the console: %s", leaked, err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(haystack, needle string) bool {
|
|
return len(needle) > 0 && len(haystack) >= len(needle) &&
|
|
func() bool {
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}()
|
|
}
|