From 4a0226d8bd61847dd465ad4f5eed5e102329e148 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:31:30 +0200 Subject: [PATCH] test(llm): the response cap is pinned at its boundary and as the production default (#53) Only the 8-byte override was exercised; >= vs >, a huge default and a non-positive option all stayed green. Also says why the error-body read ignores its error. Found by the PR #58 review. Co-Authored-By: Claude Sonnet 5.5 --- internal/adapters/llm/bodycap_test.go | 75 +++++++++++++++++++++++++++ internal/adapters/llm/client.go | 1 + 2 files changed, 76 insertions(+) diff --git a/internal/adapters/llm/bodycap_test.go b/internal/adapters/llm/bodycap_test.go index 293381f..d56e3d7 100644 --- a/internal/adapters/llm/bodycap_test.go +++ b/internal/adapters/llm/bodycap_test.go @@ -79,3 +79,78 @@ func TestClient_AFailingBodyIsReadOnlyUpToTheErrorBound(t *testing.T) { t.Fatalf("the client kept reading an endless error body: took %s", time.Since(start)) } } + +// sizedGateway answers 200 with a valid completion padded with trailing whitespace to +// exactly size bytes, so the boundary of the cap can be probed to the byte. +func sizedGateway(t *testing.T, size int) *httptest.Server { + t.Helper() + const base = `{"choices":[{"message":{"content":"ok"}}]}` + payload := base + strings.Repeat(" ", size-len(base)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestClient_ABodyOfExactlyTheCapIsReadAndOneByteMoreIsRefused(t *testing.T) { + const capBytes = 4096 + for _, tt := range []struct { + name string + size int + wantErr bool + }{ + {"one under", capBytes - 1, false}, + {"exactly the cap", capBytes, false}, + {"one over", capBytes + 1, true}, + } { + t.Run(tt.name, func(t *testing.T) { + c := New(sizedGateway(t, tt.size).URL, "", "m", 5*time.Second, WithMaxResponseBytes(capBytes)) + + got, err := c.Complete(context.Background(), "s", "u") + + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("want a response-too-large error, got %q, %v", got, err) + } + return + } + if err != nil || got != "ok" { + t.Fatalf("want ok, got %q, %v", got, err) + } + }) + } +} + +// New is what production calls: no option. The default is therefore the live bound. +func TestClient_TheDefaultCapIsEightMiBWhenNoOptionIsGiven(t *testing.T) { + for _, tt := range []struct { + name string + size int + wantErr bool + }{ + {"exactly 8 MiB", 8 << 20, false}, + {"8 MiB plus one", 8<<20 + 1, true}, + } { + t.Run(tt.name, func(t *testing.T) { + c := New(sizedGateway(t, tt.size).URL, "", "m", 10*time.Second) + + _, err := c.Complete(context.Background(), "s", "u") + + if (err != nil) != tt.wantErr { + t.Fatalf("size %d: err = %v, wantErr %v", tt.size, err, tt.wantErr) + } + }) + } +} + +func TestClient_ANonPositiveCapOptionKeepsTheDefault(t *testing.T) { + for _, n := range []int64{0, -1} { + c := New(sizedGateway(t, 1024).URL, "", "m", 5*time.Second, WithMaxResponseBytes(n)) + + if got, err := c.Complete(context.Background(), "s", "u"); err != nil || got != "ok" { + t.Fatalf("WithMaxResponseBytes(%d): want ok from the default cap, got %q, %v", n, got, err) + } + } +} diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go index 1c5a92f..b9d5cca 100644 --- a/internal/adapters/llm/client.go +++ b/internal/adapters/llm/client.go @@ -202,6 +202,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err if resp.StatusCode != http.StatusOK { // Only the bounded head is ever used (HTTPError.Error truncates to it), so read no more. + // A read error here only shortens a diagnostic; the status code below is what callers act on. head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1)) return "", &HTTPError{Code: resp.StatusCode, Body: string(head)} }