summaryrefslogtreecommitdiff
path: root/internal/llm/policy
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-11 08:19:24 +0300
committerPaul Buetow <paul@buetow.org>2026-06-11 08:19:24 +0300
commit8135a6b851b420597c22cfa5e58e6d69e894a00d (patch)
tree7f70ac1325e4908139fc60af665dfb573275530b /internal/llm/policy
parentb4f06c656d8d8733a9bf2e2e2a5eb2a7a48389bb (diff)
Consolidate LLM timeout/retry/breaker values into policy package
The per-request HTTP timeouts (30s chat, 120s research), retry policy (attempts, backoff, jitter), and circuit-breaker tuning (threshold, cooldown) were previously scattered as bare literals across each provider constructor and resilience.go/circuitbreaker.go, making the operational policy hard to discover and prone to drift. Introduce internal/llm/policy with documented named constants as the single source of truth, and reference them from all provider constructors, the default retry policy, and the shared circuit breaker. Update comments to explain the policy and reasoning. Add tests validating the values and their internal consistency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal/llm/policy')
-rw-r--r--internal/llm/policy/policy.go68
-rw-r--r--internal/llm/policy/policy_test.go67
2 files changed, 135 insertions, 0 deletions
diff --git a/internal/llm/policy/policy.go b/internal/llm/policy/policy.go
new file mode 100644
index 0000000..e133e27
--- /dev/null
+++ b/internal/llm/policy/policy.go
@@ -0,0 +1,68 @@
+// Package policy centralizes the timeout, retry, and circuit-breaker tuning
+// values used throughout the LLM HTTP call path. Previously these durations and
+// counts were scattered as bare literals across each provider constructor and
+// across resilience.go / circuitbreaker.go, which made the operational policy
+// hard to discover and easy to drift out of sync.
+//
+// Consolidating them here gives a single, documented source of truth: anyone
+// adjusting how aggressively Hexai times out, retries, or sheds load against an
+// upstream LLM API only has to look in one place, and every provider inherits
+// the same defaults automatically.
+package policy
+
+import "time"
+
+// Request timeouts.
+//
+// DefaultRequestTimeout is the per-request HTTP client timeout applied to chat
+// providers (Anthropic, OpenAI, OpenRouter, Ollama) when the user has not
+// configured an explicit RequestTimeout. Thirty seconds is generous enough for
+// normal completions yet short enough that a hung connection fails fast rather
+// than blocking interactive use indefinitely.
+//
+// ResearchRequestTimeout applies to the You.com Research provider, whose
+// multi-step research pipeline routinely runs much longer than a single chat
+// completion. It therefore gets a substantially larger default so legitimate
+// long-running research is not cut off prematurely.
+const (
+ DefaultRequestTimeout = 30 * time.Second
+ ResearchRequestTimeout = 120 * time.Second
+)
+
+// DefaultRequestTimeoutSeconds and ResearchRequestTimeoutSeconds expose the
+// timeouts as whole seconds, matching the int-seconds shape that provider
+// constructors and the user-facing Config.RequestTimeout field use.
+const (
+ DefaultRequestTimeoutSeconds = int(DefaultRequestTimeout / time.Second)
+ ResearchRequestTimeoutSeconds = int(ResearchRequestTimeout / time.Second)
+)
+
+// Retry policy.
+//
+// These govern how transient LLM HTTP failures (network resets, 5xx, 429 rate
+// limits) are retried with exponential backoff plus jitter. The defaults are
+// intentionally conservative: a handful of fast attempts that smooth over brief
+// upstream blips without hammering the API or stalling interactive use.
+//
+// - RetryMaxAttempts: total tries including the first; <=1 disables retries.
+// - RetryBaseDelay: delay before the first retry; doubles each subsequent attempt.
+// - RetryMaxDelay: upper bound on any single backoff so growth stays bounded.
+// - RetryJitterFraction: +/- fraction of random jitter to avoid thundering-herd retries.
+const (
+ RetryMaxAttempts = 3
+ RetryBaseDelay = 200 * time.Millisecond
+ RetryMaxDelay = 2 * time.Second
+ RetryJitterFraction = 0.2
+)
+
+// Circuit breaker.
+//
+// The shared circuit breaker guards the whole LLM HTTP path. After
+// CircuitFailureThreshold consecutive transient failures it trips open and
+// rejects requests for CircuitCooldown, giving an unhealthy upstream time to
+// recover instead of forcing every caller to wait out full retry+timeout
+// cycles.
+const (
+ CircuitFailureThreshold = 5
+ CircuitCooldown = 30 * time.Second
+)
diff --git a/internal/llm/policy/policy_test.go b/internal/llm/policy/policy_test.go
new file mode 100644
index 0000000..976b41e
--- /dev/null
+++ b/internal/llm/policy/policy_test.go
@@ -0,0 +1,67 @@
+package policy
+
+import (
+ "testing"
+ "time"
+)
+
+// TestRequestTimeouts verifies the documented default and research timeouts and
+// that their whole-second mirrors stay in sync with the duration constants.
+func TestRequestTimeouts(t *testing.T) {
+ if DefaultRequestTimeout != 30*time.Second {
+ t.Fatalf("DefaultRequestTimeout = %v, want 30s", DefaultRequestTimeout)
+ }
+ if ResearchRequestTimeout != 120*time.Second {
+ t.Fatalf("ResearchRequestTimeout = %v, want 120s", ResearchRequestTimeout)
+ }
+ if DefaultRequestTimeoutSeconds != 30 {
+ t.Fatalf("DefaultRequestTimeoutSeconds = %d, want 30", DefaultRequestTimeoutSeconds)
+ }
+ if ResearchRequestTimeoutSeconds != 120 {
+ t.Fatalf("ResearchRequestTimeoutSeconds = %d, want 120", ResearchRequestTimeoutSeconds)
+ }
+ // The integer mirrors must equal the duration constants converted to seconds.
+ if got := int(DefaultRequestTimeout / time.Second); got != DefaultRequestTimeoutSeconds {
+ t.Fatalf("DefaultRequestTimeoutSeconds out of sync: %d vs %d", DefaultRequestTimeoutSeconds, got)
+ }
+ if got := int(ResearchRequestTimeout / time.Second); got != ResearchRequestTimeoutSeconds {
+ t.Fatalf("ResearchRequestTimeoutSeconds out of sync: %d vs %d", ResearchRequestTimeoutSeconds, got)
+ }
+ // Research must allow a longer window than a normal chat completion.
+ if ResearchRequestTimeout <= DefaultRequestTimeout {
+ t.Fatalf("ResearchRequestTimeout (%v) must exceed DefaultRequestTimeout (%v)", ResearchRequestTimeout, DefaultRequestTimeout)
+ }
+}
+
+// TestRetryPolicyConstants verifies the retry tuning values stay within sane,
+// documented bounds.
+func TestRetryPolicyConstants(t *testing.T) {
+ if RetryMaxAttempts != 3 {
+ t.Fatalf("RetryMaxAttempts = %d, want 3", RetryMaxAttempts)
+ }
+ if RetryBaseDelay != 200*time.Millisecond {
+ t.Fatalf("RetryBaseDelay = %v, want 200ms", RetryBaseDelay)
+ }
+ if RetryMaxDelay != 2*time.Second {
+ t.Fatalf("RetryMaxDelay = %v, want 2s", RetryMaxDelay)
+ }
+ if RetryBaseDelay > RetryMaxDelay {
+ t.Fatalf("RetryBaseDelay (%v) must not exceed RetryMaxDelay (%v)", RetryBaseDelay, RetryMaxDelay)
+ }
+ if RetryJitterFraction <= 0 || RetryJitterFraction >= 1 {
+ t.Fatalf("RetryJitterFraction = %v, want in (0,1)", RetryJitterFraction)
+ }
+}
+
+// TestCircuitBreakerConstants verifies the circuit-breaker tuning values.
+func TestCircuitBreakerConstants(t *testing.T) {
+ if CircuitFailureThreshold != 5 {
+ t.Fatalf("CircuitFailureThreshold = %d, want 5", CircuitFailureThreshold)
+ }
+ if CircuitCooldown != 30*time.Second {
+ t.Fatalf("CircuitCooldown = %v, want 30s", CircuitCooldown)
+ }
+ if CircuitFailureThreshold <= 0 {
+ t.Fatalf("CircuitFailureThreshold must be positive to trip the breaker")
+ }
+}