Files
review-bot/llm/client_test.go
claw db479d0ff4
CI / test (pull_request) Successful in 15s
CI / review (/openai/v1, gpt-4.1, gpt41, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 25s
CI / review (/openai/v1, gpt-4.1-mini, gpt41-mini, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 29s
CI / review (/anthropic/v1, claude-sonnet-4-6, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 49s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 50s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m15s
CI / review (/openai/v1, gpt-5-mini, gpt5-mini, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 52s
fix: retry on transient LLM response body truncation
Addresses intermittent 'unexpected end of JSON input' failures where the
LLM response body is truncated in transit between the proxy and client.

Root cause: network-level truncation where io.ReadAll returns partial data
(observed in 3/50 CI runs through HAI proxy). The response body reading
was already using io.ReadAll correctly, but transient network issues
between the proxy and client can still cause partial reads.

Changes:
- Add Content-Length validation in doRequest: detect when fewer bytes
  arrive than the server declared, triggering a retry
- Add retry logic in Complete: retries once on retryable errors (body
  read failures, content-length mismatches) with a 500ms backoff
- Add parse-level retry in main: if ParseResponse fails, re-requests
  from the LLM once before giving up (defensive, since retries always
  succeed per issue evidence)
- Improve ParseResponse error diagnostics: log raw vs cleaned lengths
  and a preview of the cleaned content to aid future debugging

Does NOT retry on API errors (4xx/5xx) or structural issues — only
transient body read problems.

Closes #47
2026-05-07 00:44:32 -07:00

427 lines
13 KiB
Go

package llm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestComplete_Success(t *testing.T) {
resp := ChatResponse{
Choices: []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}{
{Message: struct {
Content string `json:"content"`
}{Content: "Hello, world!"}},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("unexpected auth: %s", r.Header.Get("Authorization"))
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("unexpected content type: %s", r.Header.Get("Content-Type"))
}
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req.Model != "gpt-4" {
t.Errorf("expected model %q, got %q", "gpt-4", req.Model)
}
if len(req.Messages) != 1 {
t.Errorf("expected 1 message, got %d", len(req.Messages))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL, "test-key", "gpt-4")
got, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Hello, world!" {
t.Errorf("expected %q, got %q", "Hello, world!", got)
}
}
func TestComplete_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":"rate limited"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-key", "gpt-4")
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err == nil {
t.Fatal("expected error for 429, got nil")
}
}
func TestComplete_NoChoices(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[]}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-key", "gpt-4")
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err == nil {
t.Fatal("expected error for no choices, got nil")
}
}
func TestComplete_BadJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`not json at all`))
}))
defer server.Close()
client := NewClient(server.URL, "test-key", "gpt-4")
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err == nil {
t.Fatal("expected error for bad JSON, got nil")
}
}
func TestComplete_ServerDown(t *testing.T) {
client := NewClient("http://127.0.0.1:1", "test-key", "gpt-4")
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err == nil {
t.Fatal("expected error for connection refused, got nil")
}
}
func TestWithTemperature(t *testing.T) {
client := NewClient("http://example.com", "key", "model")
if client.temperature != 0 {
t.Errorf("expected initial temperature 0, got %f", client.temperature)
}
result := client.WithTemperature(0.7)
if result != client {
t.Error("WithTemperature should return the same client for chaining")
}
if client.temperature != 0.7 {
t.Errorf("expected temperature 0.7, got %f", client.temperature)
}
}
func TestComplete_TemperatureOmittedWhenZero(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
if _, exists := req["temperature"]; exists {
t.Error("temperature should be omitted when zero (server default)")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ChatResponse{
Choices: []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}{{Message: struct {
Content string `json:"content"`
}{Content: "ok"}}},
})
}))
defer server.Close()
client := NewClient(server.URL, "key", "model")
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestComplete_TemperatureIncludedWhenSet(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
temp, exists := req["temperature"]
if !exists {
t.Error("temperature should be included when set")
}
if temp != 0.7 {
t.Errorf("expected temperature 0.7, got %v", temp)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ChatResponse{
Choices: []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}{{Message: struct {
Content string `json:"content"`
}{Content: "ok"}}},
})
}))
defer server.Close()
client := NewClient(server.URL, "key", "model").WithTemperature(0.7)
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWithTimeout(t *testing.T) {
client := NewClient("http://example.com", "key", "model")
result := client.WithTimeout(10 * time.Second)
if result != client {
t.Error("WithTimeout should return the same client for chaining")
}
// Verify timeout causes failure on slow server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer server.Close()
shortClient := NewClient(server.URL, "key", "model").WithTimeout(50 * time.Millisecond)
_, err := shortClient.Complete(context.Background(), []Message{{Role: "user", Content: "hi"}})
if err == nil {
t.Error("expected timeout error with 50ms timeout and 200ms server delay")
}
}
func TestComplete_Anthropic_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/messages" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("x-api-key") != "test-key" {
t.Errorf("expected x-api-key header, got %q", r.Header.Get("x-api-key"))
}
if r.Header.Get("anthropic-version") != "2023-06-01" {
t.Errorf("expected anthropic-version header, got %q", r.Header.Get("anthropic-version"))
}
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
if req["system"] != "You are helpful" {
t.Errorf("expected system prompt, got %v", req["system"])
}
msgs := req["messages"].([]interface{})
if len(msgs) != 1 {
t.Errorf("expected 1 user message, got %d", len(msgs))
}
if req["max_tokens"] != float64(8192) {
t.Errorf("expected max_tokens 8192, got %v", req["max_tokens"])
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"content":[{"type":"text","text":"Hello from Claude!"}]}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
got, err := client.Complete(context.Background(), []Message{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hi"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Hello from Claude!" {
t.Errorf("expected %q, got %q", "Hello from Claude!", got)
}
}
func TestComplete_Anthropic_NoContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"content":[]}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err == nil {
t.Fatal("expected error for empty content, got nil")
}
}
func TestComplete_Anthropic_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":{"message":"invalid request"}}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err == nil {
t.Fatal("expected error for 400, got nil")
}
}
func TestWithProvider(t *testing.T) {
client := NewClient("http://example.com", "key", "model")
if client.provider != ProviderOpenAI {
t.Errorf("expected default provider openai, got %s", client.provider)
}
result := client.WithProvider(ProviderAnthropic)
if result != client {
t.Error("WithProvider should return the same client for chaining")
}
if client.provider != ProviderAnthropic {
t.Errorf("expected provider anthropic, got %s", client.provider)
}
}
func TestComplete_RetryOnBodyReadError(t *testing.T) {
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
// First attempt: send headers then close connection abruptly
// Simulate by writing partial response and flushing with wrong Content-Length
w.Header().Set("Content-Length", "1000")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"choices":[{"message":{"con`))
// The test HTTP server will close the connection after handler returns,
// but Content-Length mismatch means client gets fewer bytes than expected
return
}
// Second attempt: succeed
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ChatResponse{
Choices: []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}{{Message: struct {
Content string `json:"content"`
}{Content: "success"}}},
})
}))
defer server.Close()
client := NewClient(server.URL, "key", "model")
got, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err != nil {
t.Fatalf("expected retry to succeed, got error: %v", err)
}
if got != "success" {
t.Errorf("expected %q, got %q", "success", got)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestComplete_ContentLengthMismatch(t *testing.T) {
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
// Claim Content-Length is larger than actual body
w.Header().Set("Content-Length", "500")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Write less than 500 bytes
w.Write([]byte(`{"choices":[{"message":{"content":"partial"}}]}`))
return
}
// Second attempt succeeds
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ChatResponse{
Choices: []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}{{Message: struct {
Content string `json:"content"`
}{Content: "complete"}}},
})
}))
defer server.Close()
client := NewClient(server.URL, "key", "model")
got, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err != nil {
t.Fatalf("expected retry to succeed on content-length mismatch, got: %v", err)
}
if got != "complete" {
t.Errorf("expected %q, got %q", "complete", got)
}
}
func TestComplete_NoRetryOnAPIError(t *testing.T) {
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"bad request"}`))
}))
defer server.Close()
client := NewClient(server.URL, "key", "model")
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
if err == nil {
t.Fatal("expected error for 400, got nil")
}
if attempts != 1 {
t.Errorf("should not retry on API errors, got %d attempts", attempts)
}
}
func TestIsRetryableError(t *testing.T) {
tests := []struct {
name string
err string
expected bool
}{
{"nil formatted", "", false},
{"read response error", "read response: unexpected EOF", true},
{"body length mismatch", "body length mismatch: Content-Length=1000, received=500", true},
{"API error", "LLM API error (status 400): bad request", false},
{"parse error", "parse response: unexpected end of JSON input", false},
{"request error", "LLM request: connection refused", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.err == "" {
if isRetryableError(nil) {
t.Error("nil error should not be retryable")
}
return
}
err := fmt.Errorf("%s", tt.err)
got := isRetryableError(err)
if got != tt.expected {
t.Errorf("isRetryableError(%q) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}