mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
fix(llm): retry 429 responses and 2xx bodies carrying an error object
Six qwen3.8-27b-uncensored requests in the 2026-08-30 run failed instantly with zero tokens while the replica was warming; the gateway reported them in-band and the client gave up on the first attempt. Both shapes now retry within the existing backoff budget.
This commit is contained in:
@@ -135,8 +135,12 @@ func (c *OpenAIClient) Complete(ctx context.Context, req Request) (Response, err
|
|||||||
finishReason = resp.Choices[0].FinishReason
|
finishReason = resp.Choices[0].FinishReason
|
||||||
}
|
}
|
||||||
if resp.Error != nil {
|
if resp.Error != nil {
|
||||||
|
// A 2xx carrying a JSON error body is how some gateways report a
|
||||||
|
// transient upstream failure (observed as instant 0-token
|
||||||
|
// "errors" in the 2026-08-30 run while a replica was warming).
|
||||||
|
// Treat it like a 5xx: retry within the same attempt budget.
|
||||||
lastErr = fmt.Errorf("llm: api error: %s", resp.Error.Message)
|
lastErr = fmt.Errorf("llm: api error: %s", resp.Error.Message)
|
||||||
return Response{}, lastErr
|
continue
|
||||||
}
|
}
|
||||||
return Response{
|
return Response{
|
||||||
Text: text,
|
Text: text,
|
||||||
@@ -190,7 +194,9 @@ func (c *OpenAIClient) doRequest(ctx context.Context, payload []byte) (*chatComp
|
|||||||
return nil, &retryableError{err: fmt.Errorf("read body: %w", err)}
|
return nil, &retryableError{err: fmt.Errorf("read body: %w", err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
if httpResp.StatusCode >= 500 {
|
// 429 is as transient as a 5xx for a self-hosted gateway (rate limit or
|
||||||
|
// a briefly saturated upstream); retry it with the same backoff budget.
|
||||||
|
if httpResp.StatusCode >= 500 || httpResp.StatusCode == http.StatusTooManyRequests {
|
||||||
return nil, &retryableError{status: httpResp.StatusCode}
|
return nil, &retryableError{status: httpResp.StatusCode}
|
||||||
}
|
}
|
||||||
if httpResp.StatusCode >= 400 {
|
if httpResp.StatusCode >= 400 {
|
||||||
|
|||||||
@@ -236,7 +236,9 @@ func TestOpenAIClient_Complete_NoRetryOn4xx(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIClient_Complete_APIErrorField(t *testing.T) {
|
func TestOpenAIClient_Complete_APIErrorField(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
_, _ = w.Write([]byte(`{"error":{"message":"model overloaded"}}`))
|
_, _ = w.Write([]byte(`{"error":{"message":"model overloaded"}}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -248,6 +250,55 @@ func TestOpenAIClient_Complete_APIErrorField(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "model overloaded") {
|
if !strings.Contains(err.Error(), "model overloaded") {
|
||||||
t.Errorf("error = %v, want mention of model overloaded", err)
|
t.Errorf("error = %v, want mention of model overloaded", err)
|
||||||
}
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != maxRetries+1 {
|
||||||
|
t.Errorf("calls = %d, want %d (2xx-with-error-body is retried like a 5xx)", got, maxRetries+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Complete_RetriesAPIErrorThenSucceeds(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if atomic.AddInt32(&calls, 1) == 1 {
|
||||||
|
_, _ = w.Write([]byte(`{"error":{"message":"replica warming"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||||
|
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Complete() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if resp.Text != "ok" {
|
||||||
|
t.Errorf("Text = %q, want %q", resp.Text, "ok")
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||||
|
t.Errorf("calls = %d, want 2 (one error body, one success)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Complete_RetriesOn429ThenSucceeds(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if atomic.AddInt32(&calls, 1) == 1 {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||||
|
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Complete() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if resp.Text != "ok" {
|
||||||
|
t.Errorf("Text = %q, want %q", resp.Text, "ok")
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||||
|
t.Errorf("calls = %d, want 2 (one 429, one success)", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIClient_Complete_ContextCanceled(t *testing.T) {
|
func TestOpenAIClient_Complete_ContextCanceled(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user