feat: add --retries flag with exponential backoff for transient LLM errors

Retry on timeout, 5xx, and 429 (rate limit) with 1s/2s/4s backoff.
Default 3 retries, configurable via --retries. Context cancellation
is respected between retries.
This commit is contained in:
BeaconCat 2026-04-13 11:42:43 +08:00
parent baf389786d
commit b36fef0e0c
2 changed files with 57 additions and 11 deletions

View file

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -17,6 +18,7 @@ type LLMClient struct {
Model string Model string
APIKey string APIKey string
NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific) NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific)
MaxRetries int // max retry attempts for transient errors (0 = no retry)
Client *http.Client Client *http.Client
} }
@ -27,6 +29,7 @@ type LLMClientOptions struct {
APIKey string APIKey string
Timeout time.Duration Timeout time.Duration
NoThinking bool NoThinking bool
MaxRetries int // max retry attempts (default 3)
} }
// NewLLMClient creates a client for an OpenAI-compatible chat completion API. // NewLLMClient creates a client for an OpenAI-compatible chat completion API.
@ -34,11 +37,16 @@ func NewLLMClient(opts LLMClientOptions) *LLMClient {
if opts.Timeout == 0 { if opts.Timeout == 0 {
opts.Timeout = 120 * time.Second opts.Timeout = 120 * time.Second
} }
maxRetries := opts.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
return &LLMClient{ return &LLMClient{
BaseURL: strings.TrimRight(opts.BaseURL, "/"), BaseURL: strings.TrimRight(opts.BaseURL, "/"),
Model: opts.Model, Model: opts.Model,
APIKey: opts.APIKey, APIKey: opts.APIKey,
NoThinking: opts.NoThinking, NoThinking: opts.NoThinking,
MaxRetries: maxRetries,
Client: &http.Client{ Client: &http.Client{
Timeout: opts.Timeout, Timeout: opts.Timeout,
}, },
@ -101,21 +109,55 @@ func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt strin
req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Authorization", "Bearer "+c.APIKey)
} }
resp, err := c.Client.Do(req) var respBody []byte
if err != nil { var lastErr error
return "", fmt.Errorf("http request: %w", err) for attempt := 0; attempt <= c.MaxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, ...
log.Printf("LLM retry %d/%d after %v: %v", attempt, c.MaxRetries, backoff, lastErr)
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(backoff):
} }
defer resp.Body.Close() // Rebuild request (body reader is consumed)
req, err = http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("read response: %w", err) return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
} }
var resp *http.Response
resp, lastErr = c.Client.Do(req)
if lastErr != nil {
continue // network/timeout error → retry
}
respBody, lastErr = io.ReadAll(resp.Body)
resp.Body.Close()
if lastErr != nil {
continue
}
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
lastErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
continue // rate limit or server error → retry
}
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
} }
lastErr = nil
break
}
if lastErr != nil {
return "", fmt.Errorf("after %d retries: %w", c.MaxRetries, lastErr)
}
var chatResp chatResponse var chatResp chatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil { if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("parse response: %w", err) return "", fmt.Errorf("parse response: %w", err)

View file

@ -27,6 +27,7 @@ var (
flagNoThinking bool flagNoThinking bool
flagLimit int flagLimit int
flagTimeout int flagTimeout int
flagRetries int
) )
func main() { func main() {
@ -66,6 +67,7 @@ func main() {
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)") BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)") evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests") evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
reportCmd := &cobra.Command{ reportCmd := &cobra.Command{
Use: "report", Use: "report",
@ -93,6 +95,7 @@ func main() {
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)") BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)") runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests") runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd) rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
@ -317,5 +320,6 @@ func buildLLMOptions() (LLMClientOptions, error) {
APIKey: apiKey, APIKey: apiKey,
NoThinking: flagNoThinking, NoThinking: flagNoThinking,
Timeout: time.Duration(flagTimeout) * time.Second, Timeout: time.Duration(flagTimeout) * time.Second,
MaxRetries: flagRetries,
}, nil }, nil
} }