Refactor llm client timeout

This commit is contained in:
dwrz
2026-10-08 01:01:35 +00:00
parent 6d25f3d4ed
commit bee05be62d
3 changed files with 40 additions and 32 deletions
+11 -20
View File
@@ -13,7 +13,7 @@ const (
// chatTemplateEnableThinking is the chat-template argument that selects // chatTemplateEnableThinking is the chat-template argument that selects
// whether a reasoning model emits reasoning tokens. // whether a reasoning model emits reasoning tokens.
chatTemplateEnableThinking = "enable_thinking" chatTemplateEnableThinking = "enable_thinking"
// defaultTimeout limits requests when Config.Timeout is empty. // defaultTimeout limits requests when Config.Timeout is zero.
defaultTimeout = 5 * time.Minute defaultTimeout = 5 * time.Minute
// sseDone is the sentinel that terminates a chat completion stream. // sseDone is the sentinel that terminates a chat completion stream.
sseDone = "[DONE]" sseDone = "[DONE]"
@@ -26,20 +26,17 @@ type Config struct {
// Provider is returned unchanged by Client.Provider. It is not sent to the // Provider is returned unchanged by Client.Provider. It is not sent to the
// inference endpoint. // inference endpoint.
Provider string `yaml:"provider"` Provider string `yaml:"provider"`
// Timeout limits each request, including reading a streaming response. It // Timeout limits each request, including reading a streaming response.
// uses Go duration syntax and defaults to five minutes when empty. // It defaults to five minutes when zero.
Timeout string `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
// URL is the endpoint root. Client appends /chat/completions or /models. // URL is the endpoint root. Client appends /chat/completions or /models.
URL string `yaml:"url"` URL string `yaml:"url"`
} }
// Validate reports whether Timeout is a Go duration, when set, and URL is // Validate reports whether Timeout is non-negative and URL is non-empty.
// non-empty. It does not contact the endpoint.
func (cfg Config) Validate() error { func (cfg Config) Validate() error {
if cfg.Timeout != "" { if cfg.Timeout < 0 {
if _, err := time.ParseDuration(cfg.Timeout); err != nil { return fmt.Errorf("invalid timeout %v: must not be negative", cfg.Timeout)
return fmt.Errorf("invalid timeout: %w", err)
}
} }
if cfg.URL == "" { if cfg.URL == "" {
return fmt.Errorf("missing URL") return fmt.Errorf("missing URL")
@@ -57,9 +54,7 @@ type Client struct {
timeout time.Duration timeout time.Duration
} }
// NewClient creates a client from cfg. If cfg.Timeout is empty, requests have a // NewClient creates a client from cfg.
// five-minute timeout. log must be non-nil; pass
// slog.New(slog.DiscardHandler) to run without logs.
func NewClient(cfg Config, log *slog.Logger) (*Client, error) { func NewClient(cfg Config, log *slog.Logger) (*Client, error) {
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err) return nil, fmt.Errorf("invalid config: %w", err)
@@ -68,13 +63,9 @@ func NewClient(cfg Config, log *slog.Logger) (*Client, error) {
return nil, fmt.Errorf("missing logger") return nil, fmt.Errorf("missing logger")
} }
timeout := defaultTimeout timeout := cfg.Timeout
if cfg.Timeout != "" { if timeout == 0 {
d, err := time.ParseDuration(cfg.Timeout) timeout = defaultTimeout
if err != nil {
return nil, fmt.Errorf("parse timeout: %v", err)
}
timeout = d
} }
return &Client{ return &Client{
+24 -8
View File
@@ -3,6 +3,7 @@ package llm
import ( import (
"log/slog" "log/slog"
"testing" "testing"
"time"
) )
func TestConfigValidate(t *testing.T) { func TestConfigValidate(t *testing.T) {
@@ -22,10 +23,10 @@ func TestConfigValidate(t *testing.T) {
wantErr: true, wantErr: true,
}, },
{ {
name: "invalid timeout", name: "negative timeout",
cfg: Config{ cfg: Config{
URL: "http://localhost:8080", URL: "http://localhost:8080",
Timeout: "not-a-duration", Timeout: -time.Second,
}, },
wantErr: true, wantErr: true,
}, },
@@ -40,7 +41,7 @@ func TestConfigValidate(t *testing.T) {
name: "valid config with timeout", name: "valid config with timeout",
cfg: Config{ cfg: Config{
URL: "http://localhost:8080", URL: "http://localhost:8080",
Timeout: "15m", Timeout: 15 * time.Minute,
}, },
wantErr: false, wantErr: false,
}, },
@@ -48,7 +49,7 @@ func TestConfigValidate(t *testing.T) {
name: "valid config with complex timeout", name: "valid config with complex timeout",
cfg: Config{ cfg: Config{
URL: "http://localhost:8080", URL: "http://localhost:8080",
Timeout: "1h30m45s", Timeout: time.Hour + 30*time.Minute + 45*time.Second,
}, },
wantErr: false, wantErr: false,
}, },
@@ -56,7 +57,7 @@ func TestConfigValidate(t *testing.T) {
name: "valid full config", name: "valid full config",
cfg: Config{ cfg: Config{
Key: "sk-test-key", Key: "sk-test-key",
Timeout: "30m", Timeout: 30 * time.Minute,
URL: "http://localhost:8080", URL: "http://localhost:8080",
}, },
wantErr: false, wantErr: false,
@@ -89,6 +90,14 @@ func TestNewClient(t *testing.T) {
cfg: Config{}, cfg: Config{},
wantErr: "invalid config: missing URL", wantErr: "invalid config: missing URL",
}, },
{
name: "negative timeout precedes nil logger",
cfg: Config{
URL: "http://localhost:8080",
Timeout: -time.Second,
},
wantErr: "invalid config: invalid timeout -1s: must not be negative",
},
{ {
name: "nil logger", name: "nil logger",
cfg: Config{ cfg: Config{
@@ -107,7 +116,7 @@ func TestNewClient(t *testing.T) {
name: "valid config with timeout", name: "valid config with timeout",
cfg: Config{ cfg: Config{
URL: "http://localhost:8080", URL: "http://localhost:8080",
Timeout: "10m", Timeout: 10 * time.Minute,
}, },
log: log, log: log,
}, },
@@ -115,7 +124,7 @@ func TestNewClient(t *testing.T) {
name: "valid config with all fields", name: "valid config with all fields",
cfg: Config{ cfg: Config{
Key: "test-key", Key: "test-key",
Timeout: "5m", Timeout: 5 * time.Minute,
URL: "http://localhost:8080", URL: "http://localhost:8080",
}, },
log: log, log: log,
@@ -144,7 +153,14 @@ func TestNewClient(t *testing.T) {
t.Fatalf("NewClient() error = %v, want nil", err) t.Fatalf("NewClient() error = %v, want nil", err)
} }
if client == nil { if client == nil {
t.Error("NewClient() client = nil, want non-nil") t.Fatal("NewClient() client = nil, want non-nil")
}
wantTimeout := tt.cfg.Timeout
if wantTimeout == 0 {
wantTimeout = 5 * time.Minute
}
if client.timeout != wantTimeout {
t.Errorf("timeout = %v, want %v", client.timeout, wantTimeout)
} }
}) })
} }
+5 -4
View File
@@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"testing" "testing"
"time"
) )
// testURL returns the LLM endpoint for integration tests. // testURL returns the LLM endpoint for integration tests.
@@ -41,7 +42,7 @@ func TestIntegration_ListModels(t *testing.T) {
cfg := Config{ cfg := Config{
URL: testURL(t), URL: testURL(t),
Key: testKey(t), Key: testKey(t),
Timeout: "30s", Timeout: 30 * time.Second,
} }
client, err := NewClient(cfg, slog.Default()) client, err := NewClient(cfg, slog.Default())
if err != nil { if err != nil {
@@ -67,7 +68,7 @@ func TestIntegration_Completions(t *testing.T) {
cfg := Config{ cfg := Config{
URL: testURL(t), URL: testURL(t),
Key: testKey(t), Key: testKey(t),
Timeout: "60s", Timeout: 60 * time.Second,
} }
client, err := NewClient(cfg, slog.Default()) client, err := NewClient(cfg, slog.Default())
if err != nil { if err != nil {
@@ -100,7 +101,7 @@ func TestIntegration_CompletionsStream(t *testing.T) {
cfg := Config{ cfg := Config{
URL: testURL(t), URL: testURL(t),
Key: testKey(t), Key: testKey(t),
Timeout: "60s", Timeout: 60 * time.Second,
} }
client, err := NewClient(cfg, slog.Default()) client, err := NewClient(cfg, slog.Default())
if err != nil { if err != nil {
@@ -140,7 +141,7 @@ func TestIntegration_ToolCalling(t *testing.T) {
client, err := NewClient(Config{ client, err := NewClient(Config{
URL: testURL(t), URL: testURL(t),
Key: testKey(t), Key: testKey(t),
Timeout: "60s", Timeout: 60 * time.Second,
}, slog.Default()) }, slog.Default())
if err != nil { if err != nil {
t.Fatalf("NewClient: %v", err) t.Fatalf("NewClient: %v", err)