fix: add configurable HTTP timeout for LLM API calls (#238)

* fix: add configurable HTTP timeout for LLM API calls

The LLM HTTP client had no user-configurable request timeout. When the
LLM API becomes unresponsive (e.g., rate limiting, network issues), the
ocr process hangs indefinitely with 0% CPU, holding an ESTABLISHED TCP
connection that never completes.

Changes:
- Add Timeout field to ResolvedEndpoint struct
- Add OCR_LLM_TIMEOUT environment variable (value in seconds)
- Add timeout_sec field to config.json llm and provider sections
- Pass timeout from ResolvedEndpoint to ClientConfig in NewLLMClient

The default timeout remains 5 minutes when not explicitly configured.
Users can now set a shorter timeout via:
  - Environment: OCR_LLM_TIMEOUT=120
  - Config file: {"llm": {"timeout_sec": 120}}

Fixes #237

* fix: address Copilot review feedback

- Make OCR_LLM_TIMEOUT a global override (works with any endpoint strategy)
- Add proper overflow detection for timeout values (check before multiply)
- Add validateTimeoutSec helper with negative/overflow guards
- Add comprehensive tests for timeout parsing, validation, and forwarding
- Reject negative timeout_sec in config files with explicit error

* fix: address review feedback for timeout configuration

- parseTimeoutEnv now returns errors instead of silent fallback for invalid values (negative, non-integer, overflow)
- Reuse validateTimeoutSec in parseTimeoutEnv to eliminate duplicated validation logic
- Remove redundant comment in tryOCREnv
- Add missing tests:
  - TestResolveEndpoint_ProviderConfigTimeoutSec
  - TestResolveEndpoint_ProviderConfigNegativeTimeoutSec
  - TestResolveEndpoint_EnvTimeoutOverridesConfigTimeout
  - TestResolveEndpoint_EnvTimeoutOverridesProviderTimeout
  - TestResolveEndpoint_InvalidEnvTimeoutWithConfig
  - TestResolveEndpoint_NegativeEnvTimeoutWithConfig
This commit is contained in:
MurphyYi 2026-06-30 11:22:24 +08:00 committed by GitHub
parent 62972cbcf8
commit eae863502b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 390 additions and 1 deletions

View file

@ -198,6 +198,7 @@ func NewLLMClient(ep ResolvedEndpoint) LLMClient {
APIKey: ep.Token,
Model: ep.Model,
AuthHeader: ep.AuthHeader,
Timeout: ep.Timeout,
ExtraBody: ep.ExtraBody,
ExtraHeaders: ep.ExtraHeaders,
}

View file

@ -3,10 +3,13 @@ package llm
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// ResolvedEndpoint holds the resolved LLM endpoint configuration.
@ -19,6 +22,7 @@ type ResolvedEndpoint struct {
Source string // human-readable config source label
ExtraBody map[string]any // vendor-specific request body fields
ExtraHeaders map[string]string // extra HTTP headers for the LLM request
Timeout time.Duration // per-request HTTP timeout; 0 means use default (5 min)
}
// Environment variable names for OCR-specific configuration.
@ -28,6 +32,7 @@ const (
envOCRLLMModel = "OCR_LLM_MODEL"
envOCRLLMAuthHeader = "OCR_LLM_AUTH_HEADER"
envOCRLLMExtraHeaders = "OCR_LLM_EXTRA_HEADERS"
envOCRLLMTimeout = "OCR_LLM_TIMEOUT"
envOCRUseAnthropic = "OCR_USE_ANTHROPIC"
)
@ -71,6 +76,16 @@ func ResolveEndpointWithModelOverride(configPath, modelOverride string) (Resolve
ep.Source = s.name
}
ep.Model = stripModelSuffix(ep.Model)
// OCR_LLM_TIMEOUT is a global override: applies regardless of
// which strategy resolved the endpoint, and takes precedence
// over config-file values when set.
envTimeout, ok, err := parseTimeoutEnv()
if err != nil {
return ResolvedEndpoint{}, fmt.Errorf("resolve %s: %w", s.name, err)
}
if ok {
ep.Timeout = envTimeout
}
return ep, nil
}
}
@ -78,6 +93,43 @@ func ResolveEndpointWithModelOverride(configPath, modelOverride string) (Resolve
return ResolvedEndpoint{}, fmt.Errorf("no valid LLM endpoint configured; one of OCR_LLM_URL/OCR_LLM_TOKEN/OCR_LLM_MODEL, ~/.opencodereview/config.json, or ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN/ANTHROPIC_MODEL must be set")
}
// parseTimeoutEnv reads and validates the OCR_LLM_TIMEOUT environment variable.
// Returns the parsed duration and true if set, or 0 and false if unset/empty.
// Returns an error for invalid values (non-integer, negative, overflow) to give
// the user clear feedback instead of silently falling back to the default.
func parseTimeoutEnv() (time.Duration, bool, error) {
raw := strings.TrimSpace(os.Getenv(envOCRLLMTimeout))
if raw == "" {
return 0, false, nil
}
sec, err := strconv.Atoi(raw)
if err != nil {
return 0, false, fmt.Errorf("OCR_LLM_TIMEOUT must be an integer (seconds): %w", err)
}
d, err := validateTimeoutSec(sec)
if err != nil {
return 0, false, fmt.Errorf("OCR_LLM_TIMEOUT: %w", err)
}
return d, true, nil
}
// validateTimeoutSec converts a config-file timeout (in seconds) to time.Duration.
// Returns 0 for zero input (use default). Rejects negative values and overflow.
func validateTimeoutSec(sec int) (time.Duration, error) {
if sec == 0 {
return 0, nil
}
if sec < 0 {
return 0, fmt.Errorf("timeout_sec must be non-negative, got %d", sec)
}
// Guard against overflow: time.Duration is int64 nanoseconds.
maxSec := int64(math.MaxInt64 / int64(time.Second))
if int64(sec) > maxSec {
return 0, fmt.Errorf("timeout_sec %d overflows time.Duration (max %d)", sec, maxSec)
}
return time.Duration(sec) * time.Second, nil
}
// tryOCREnv reads OCR-specific environment variables.
func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) {
url := os.Getenv(envOCRLLMURL)
@ -132,6 +184,7 @@ type llmFileConfig struct {
AuthHeader string `json:"auth_header,omitempty"`
Model string `json:"model,omitempty"`
UseAnthropic *bool `json:"use_anthropic,omitempty"` // pointer to distinguish unset from false
TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds
ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
}
@ -144,6 +197,7 @@ type providerEntryConfig struct {
Model string `json:"model,omitempty"`
Models []string `json:"models,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds
ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
}
@ -288,6 +342,11 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
extraBody = entry.ExtraBody
extraHeaders := entry.ExtraHeaders
timeout, err := validateTimeoutSec(entry.TimeoutSec)
if err != nil {
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q: %w", cfg.Provider, err)
}
if protocol == "anthropic" {
url = ensureMessagesSuffix(url)
}
@ -301,6 +360,7 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
Source: "provider:" + cfg.Provider,
ExtraBody: extraBody,
ExtraHeaders: extraHeaders,
Timeout: timeout,
}, true, nil
}
@ -336,7 +396,12 @@ func tryLegacyLlmConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
}
}
return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody, ExtraHeaders: cfg.Llm.ExtraHeaders}, true, nil
timeout, err := validateTimeoutSec(cfg.Llm.TimeoutSec)
if err != nil {
return ResolvedEndpoint{}, false, fmt.Errorf("OCR config file: %w", err)
}
return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody, ExtraHeaders: cfg.Llm.ExtraHeaders, Timeout: timeout}, true, nil
}
// tryCCEnv reads Claude Code environment variables.

View file

@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestStripModelSuffix(t *testing.T) {
@ -1123,3 +1124,325 @@ func TestResolveEndpoint_LegacyLlmExtraHeaders(t *testing.T) {
t.Errorf("ExtraHeaders[\"X-Legacy\"] = %q, want %q", v, "yes")
}
}
func TestParseTimeoutEnv(t *testing.T) {
tests := []struct {
name string
value string
want time.Duration
wantOK bool
wantErr bool
}{
{"empty", "", 0, false, false},
{"valid 120", "120", 120 * time.Second, true, false},
{"valid 60", "60", 60 * time.Second, true, false},
{"zero", "0", 0, true, false},
{"negative", "-5", 0, false, true},
{"non-integer", "abc", 0, false, true},
{"with spaces", " 90 ", 90 * time.Second, true, false},
{"overflow", "99999999999999", 0, false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("OCR_LLM_TIMEOUT", tt.value)
got, ok, err := parseTimeoutEnv()
if (err != nil) != tt.wantErr {
t.Errorf("parseTimeoutEnv() error = %v, wantErr %v", err, tt.wantErr)
}
if ok != tt.wantOK {
t.Errorf("parseTimeoutEnv() ok = %v, want %v", ok, tt.wantOK)
}
if got != tt.want {
t.Errorf("parseTimeoutEnv() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidateTimeoutSec(t *testing.T) {
tests := []struct {
name string
input int
want time.Duration
wantErr bool
}{
{"zero", 0, 0, false},
{"positive 60", 60, 60 * time.Second, false},
{"positive 300", 300, 300 * time.Second, false},
{"negative -1", -1, 0, true},
{"negative -100", -100, 0, true},
{"max safe", 9223372036, 9223372036 * time.Second, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := validateTimeoutSec(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateTimeoutSec(%d) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("validateTimeoutSec(%d) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestResolveEndpoint_EnvTimeoutGlobalOverride(t *testing.T) {
clearAllEnv(t)
t.Setenv("OCR_LLM_URL", "https://api.example.com/v1")
t.Setenv("OCR_LLM_TOKEN", "test-token")
t.Setenv("OCR_LLM_MODEL", "mimo-v2.5-pro")
t.Setenv("OCR_USE_ANTHROPIC", "false")
t.Setenv("OCR_LLM_TIMEOUT", "90")
ep, err := ResolveEndpoint(filepath.Join(t.TempDir(), "nonexistent.json"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Timeout != 90*time.Second {
t.Errorf("Timeout = %v, want %v", ep.Timeout, 90*time.Second)
}
}
func TestResolveEndpoint_ConfigTimeoutSec(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Llm: llmFileConfig{
URL: "https://api.example.com/v1/messages",
AuthToken: "test-token",
Model: "claude-opus-4-6",
TimeoutSec: 120,
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Timeout != 120*time.Second {
t.Errorf("Timeout = %v, want %v", ep.Timeout, 120*time.Second)
}
}
func TestResolveEndpoint_NegativeConfigTimeoutSec(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Llm: llmFileConfig{
URL: "https://api.example.com/v1/messages",
AuthToken: "test-token",
Model: "claude-opus-4-6",
TimeoutSec: -5,
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for negative timeout_sec, got nil")
}
if !strings.Contains(err.Error(), "non-negative") {
t.Errorf("error %q should mention non-negative", err.Error())
}
}
func TestNewLLMClient_TimeoutForwarded(t *testing.T) {
ep := ResolvedEndpoint{
URL: "https://api.example.com/v1",
Token: "test-token",
Model: "test-model",
Timeout: 2 * time.Minute,
}
client := NewLLMClient(ep)
if client == nil {
t.Fatal("NewLLMClient returned nil")
}
// Verify the client was created (we can't easily inspect the internal timeout,
// but we can verify the client is functional and was constructed without error).
if oc, ok := client.(*OpenAIClient); ok {
if oc.cfg.Timeout != 2*time.Minute {
t.Errorf("OpenAIClient cfg.Timeout = %v, want %v", oc.cfg.Timeout, 2*time.Minute)
}
} else {
t.Errorf("expected *OpenAIClient, got %T", client)
}
}
func TestNewLLMClient_DefaultTimeout(t *testing.T) {
ep := ResolvedEndpoint{
URL: "https://api.example.com/v1",
Token: "test-token",
Model: "test-model",
// Timeout not set — should default to 5 minutes
}
client := NewLLMClient(ep)
if oc, ok := client.(*OpenAIClient); ok {
if oc.cfg.Timeout != 5*time.Minute {
t.Errorf("OpenAIClient cfg.Timeout = %v, want default %v", oc.cfg.Timeout, 5*time.Minute)
}
}
}
func TestResolveEndpoint_ProviderConfigTimeoutSec(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {
APIKey: "sk-ant-test",
Model: "claude-sonnet-4-6",
TimeoutSec: 180,
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Timeout != 180*time.Second {
t.Errorf("Timeout = %v, want %v", ep.Timeout, 180*time.Second)
}
}
func TestResolveEndpoint_ProviderConfigNegativeTimeoutSec(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {
APIKey: "sk-ant-test",
Model: "claude-sonnet-4-6",
TimeoutSec: -10,
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for negative provider timeout_sec")
}
if !strings.Contains(err.Error(), "non-negative") {
t.Errorf("error %q should mention non-negative", err.Error())
}
}
func TestResolveEndpoint_EnvTimeoutOverridesConfigTimeout(t *testing.T) {
clearAllEnv(t)
t.Setenv("OCR_LLM_TIMEOUT", "60")
cfg := configFile{
Llm: llmFileConfig{
URL: "https://api.example.com/v1/messages",
AuthToken: "test-token",
Model: "claude-opus-4-6",
TimeoutSec: 120,
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// OCR_LLM_TIMEOUT (60s) should override config timeout_sec (120s)
if ep.Timeout != 60*time.Second {
t.Errorf("Timeout = %v, want %v (env should override config)", ep.Timeout, 60*time.Second)
}
}
func TestResolveEndpoint_EnvTimeoutOverridesProviderTimeout(t *testing.T) {
clearAllEnv(t)
t.Setenv("OCR_LLM_TIMEOUT", "45")
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {
APIKey: "sk-ant-test",
Model: "claude-sonnet-4-6",
TimeoutSec: 300,
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// OCR_LLM_TIMEOUT (45s) should override provider timeout_sec (300s)
if ep.Timeout != 45*time.Second {
t.Errorf("Timeout = %v, want %v (env should override provider config)", ep.Timeout, 45*time.Second)
}
}
func TestResolveEndpoint_InvalidEnvTimeoutWithConfig(t *testing.T) {
clearAllEnv(t)
t.Setenv("OCR_LLM_TIMEOUT", "invalid")
cfg := configFile{
Llm: llmFileConfig{
URL: "https://api.example.com/v1/messages",
AuthToken: "test-token",
Model: "claude-opus-4-6",
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for invalid OCR_LLM_TIMEOUT")
}
if !strings.Contains(err.Error(), "OCR_LLM_TIMEOUT") {
t.Errorf("error %q should mention OCR_LLM_TIMEOUT", err.Error())
}
}
func TestResolveEndpoint_NegativeEnvTimeoutWithConfig(t *testing.T) {
clearAllEnv(t)
t.Setenv("OCR_LLM_TIMEOUT", "-30")
cfg := configFile{
Llm: llmFileConfig{
URL: "https://api.example.com/v1/messages",
AuthToken: "test-token",
Model: "claude-opus-4-6",
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for negative OCR_LLM_TIMEOUT")
}
if !strings.Contains(err.Error(), "OCR_LLM_TIMEOUT") {
t.Errorf("error %q should mention OCR_LLM_TIMEOUT", err.Error())
}
}