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:
parent
baf389786d
commit
b36fef0e0c
2 changed files with 57 additions and 11 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue