[object Object]

← back to Cli Printing Press

fix(cli): raise API error body truncation cap from 200 to 4096 bytes (#1285)

513ae01d9afa7e8a3b7d850db7824d596d118c61 · 2026-05-13 11:45:30 -0700 · Trevin Chow

* fix(cli): raise error body truncation cap from 200 to 4096 bytes

The generator's truncateBody helper caps API error response bodies at 200
characters, which is consistently smaller than the diagnostic detail in
modern cloud-vendor responses. Microsoft Power BI buries the actionable
message under pbi.error.details[].detail.value; Google nests under
error.details[].@type; AWS uses __type and message at the third level.
At 200 chars these payloads cut off mid-prefix, forcing users to a
side-channel (curl, Postman) to read what got chopped.

Bump the cap to 4096 — 20x current, still small enough to prevent log
explosions from runaway endpoints, large enough to surface real
diagnostic detail in every common case. Single-line bodies stay
single-line; no pretty-printing.

Fixes #1264

* refactor(cli): truncateBody UTF-8 safety + slice-before-string

Apply the AGENTS.md write-time defaults to truncateBody now that the cap
allows non-ASCII content through more often:

- Slice the []byte at maxBytes before string conversion, so a 1MB
  runaway body doesn't allocate a 1MB string just to discard most of it.
- Drop trailing partial runes via strings.ToValidUTF8 so the truncated
  output stays valid UTF-8 when an error body contains multi-byte
  characters cut mid-rune at the 4096 boundary.
- Promote the literal to a local const so the guard and the slice can
  never drift.

* test(cli): cover truncateBody cap + UTF-8 boundary in every printed CLI

Add client_test.go.tmpl alongside client.go.tmpl so every generated CLI
ships a TestTruncateBody that locks in the 4096-byte cap, the trailing
ellipsis on over-cap input, and the UTF-8-safe drop of a partial rune
straddling the boundary.

Without this test the strings.ToValidUTF8 hop could be silently removed
in a future refactor with zero failure signal. Golden fixtures catch
template-output drift but not runtime behavior; this test runs as part
of every printed CLI's go test ./... in CI and local dev.

Register the new template and bump the always-emitted file count plus
mustInclude in TestGenerateProjectsCompile to reflect the addition.

* test(cli): lock truncateBody UTF-8 partial-rune drop length

Add a length assertion to TestTruncateBody_UTF8RuneAtBoundary. The
'€' rune starts at byte 4094; the slice at maxBytes keeps two of its
three bytes. strings.ToValidUTF8 with an empty replacement drops both
partial bytes, leaving 4094 content bytes + 3 for "...". A future swap
to a non-empty replacement (e.g. "?") would still produce valid UTF-8
without U+FFFD and a trailing "...", silently passing the existing
checks. The length pin catches that case.

Files touched

Diff

commit 513ae01d9afa7e8a3b7d850db7824d596d118c61
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Wed May 13 11:45:30 2026 -0700

    fix(cli): raise API error body truncation cap from 200 to 4096 bytes (#1285)
    
    * fix(cli): raise error body truncation cap from 200 to 4096 bytes
    
    The generator's truncateBody helper caps API error response bodies at 200
    characters, which is consistently smaller than the diagnostic detail in
    modern cloud-vendor responses. Microsoft Power BI buries the actionable
    message under pbi.error.details[].detail.value; Google nests under
    error.details[].@type; AWS uses __type and message at the third level.
    At 200 chars these payloads cut off mid-prefix, forcing users to a
    side-channel (curl, Postman) to read what got chopped.
    
    Bump the cap to 4096 — 20x current, still small enough to prevent log
    explosions from runaway endpoints, large enough to surface real
    diagnostic detail in every common case. Single-line bodies stay
    single-line; no pretty-printing.
    
    Fixes #1264
    
    * refactor(cli): truncateBody UTF-8 safety + slice-before-string
    
    Apply the AGENTS.md write-time defaults to truncateBody now that the cap
    allows non-ASCII content through more often:
    
    - Slice the []byte at maxBytes before string conversion, so a 1MB
      runaway body doesn't allocate a 1MB string just to discard most of it.
    - Drop trailing partial runes via strings.ToValidUTF8 so the truncated
      output stays valid UTF-8 when an error body contains multi-byte
      characters cut mid-rune at the 4096 boundary.
    - Promote the literal to a local const so the guard and the slice can
      never drift.
    
    * test(cli): cover truncateBody cap + UTF-8 boundary in every printed CLI
    
    Add client_test.go.tmpl alongside client.go.tmpl so every generated CLI
    ships a TestTruncateBody that locks in the 4096-byte cap, the trailing
    ellipsis on over-cap input, and the UTF-8-safe drop of a partial rune
    straddling the boundary.
    
    Without this test the strings.ToValidUTF8 hop could be silently removed
    in a future refactor with zero failure signal. Golden fixtures catch
    template-output drift but not runtime behavior; this test runs as part
    of every printed CLI's go test ./... in CI and local dev.
    
    Register the new template and bump the always-emitted file count plus
    mustInclude in TestGenerateProjectsCompile to reflect the addition.
    
    * test(cli): lock truncateBody UTF-8 partial-rune drop length
    
    Add a length assertion to TestTruncateBody_UTF8RuneAtBoundary. The
    '€' rune starts at byte 4094; the slice at maxBytes keeps two of its
    three bytes. strings.ToValidUTF8 with an empty replacement drops both
    partial bytes, leaving 4094 content bytes + 3 for "...". A future swap
    to a non-empty replacement (e.g. "?") would still produce valid UTF-8
    without U+FFFD and a trailing "...", silently passing the existing
    checks. The length pin catches that case.
---
 internal/generator/generator.go                    |  1 +
 internal/generator/generator_test.go               |  7 ++-
 internal/generator/templates/client.go.tmpl        |  8 +--
 internal/generator/templates/client_test.go.tmpl   | 72 ++++++++++++++++++++++
 .../internal/client/client.go                      |  8 +--
 .../internal/client/client.go                      |  8 +--
 .../tier-routing-golden/internal/client/client.go  |  8 +--
 7 files changed, 93 insertions(+), 19 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 6b903c52..d5d2c4ec 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1406,6 +1406,7 @@ func (g *Generator) renderSingleFiles() error {
 		"config.go.tmpl":                     filepath.Join("internal", "config", "config.go"),
 		"cache.go.tmpl":                      filepath.Join("internal", "cache", "cache.go"),
 		"client.go.tmpl":                     filepath.Join("internal", "client", "client.go"),
+		"client_test.go.tmpl":                filepath.Join("internal", "client", "client_test.go"),
 		"cliutil_fanout.go.tmpl":             filepath.Join("internal", "cliutil", "fanout.go"),
 		"cliutil_text.go.tmpl":               filepath.Join("internal", "cliutil", "text.go"),
 		"cliutil_probe.go.tmpl":              filepath.Join("internal", "cliutil", "probe.go"),
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 4d9fa1dc..3c440b33 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -61,6 +61,7 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		"internal/cliutil/extractnumber_test.go",
 		"internal/cliutil/cliutil_test.go",
 		"internal/client/client.go",
+		"internal/client/client_test.go",
 		"internal/config/config.go",
 		"internal/mcp/cobratree/walker.go",
 		"internal/mcp/cobratree/classify.go",
@@ -80,9 +81,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// Bump it AND add to mustInclude above when adding always-emitted
 		// templates. Per-spec dynamic files (per-resource command files,
 		// generated tests) account for the difference between fixtures.
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 59},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 64},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 61},
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 60},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 65},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 62},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 47a19a3d..763de5a4 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -1541,9 +1541,9 @@ func maskToken(token string) string {
 }
 
 func truncateBody(b []byte) string {
-	s := string(b)
-	if len(s) > 200 {
-		return s[:200] + "..."
+	const maxBytes = 4096
+	if len(b) <= maxBytes {
+		return string(b)
 	}
-	return s
+	return strings.ToValidUTF8(string(b[:maxBytes]), "") + "..."
 }
diff --git a/internal/generator/templates/client_test.go.tmpl b/internal/generator/templates/client_test.go.tmpl
new file mode 100644
index 00000000..b35f7dd4
--- /dev/null
+++ b/internal/generator/templates/client_test.go.tmpl
@@ -0,0 +1,72 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+	"unicode/utf8"
+)
+
+func TestTruncateBody(t *testing.T) {
+	t.Parallel()
+
+	const maxBytes = 4096
+
+	cases := []struct {
+		name        string
+		input       []byte
+		wantLen     int
+		wantHasTail bool
+	}{
+		{"empty", nil, 0, false},
+		{"under cap", []byte("hello"), 5, false},
+		{"at cap", bytes.Repeat([]byte("a"), maxBytes), maxBytes, false},
+		{"one over cap", bytes.Repeat([]byte("a"), maxBytes+1), maxBytes + 3, true},
+		{"huge body", bytes.Repeat([]byte("a"), maxBytes*8), maxBytes + 3, true},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := truncateBody(tc.input)
+			if len(got) != tc.wantLen {
+				t.Fatalf("len = %d, want %d", len(got), tc.wantLen)
+			}
+			if tc.wantHasTail && !strings.HasSuffix(got, "...") {
+				t.Fatalf("want trailing %q", "...")
+			}
+			if !tc.wantHasTail && strings.HasSuffix(got, "...") {
+				t.Fatalf("unexpected trailing %q in %q", "...", got)
+			}
+		})
+	}
+}
+
+func TestTruncateBody_UTF8RuneAtBoundary(t *testing.T) {
+	t.Parallel()
+
+	// '€' is 3 bytes (0xE2 0x82 0xAC). Place it so the slice at byte 4096 cuts
+	// mid-rune; strings.ToValidUTF8 should drop the partial rune cleanly rather
+	// than emit U+FFFD or invalid UTF-8.
+	prefix := strings.Repeat("a", 4094)
+	body := []byte(prefix + "€" + strings.Repeat("b", 100))
+	got := truncateBody(body)
+
+	if !utf8.ValidString(got) {
+		t.Fatalf("output is not valid UTF-8")
+	}
+	if strings.ContainsRune(got, utf8.RuneError) {
+		t.Fatalf("output contains replacement rune U+FFFD")
+	}
+	if !strings.HasSuffix(got, "...") {
+		t.Fatalf("want trailing %q", "...")
+	}
+	// Partial rune must be dropped, not replaced: 4094 valid bytes + "...".
+	if want := 4094 + 3; len(got) != want {
+		t.Fatalf("len = %d, want %d (partial rune should be dropped, not replaced)", len(got), want)
+	}
+}
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
index e9ceedc7..6ff99176 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -609,9 +609,9 @@ func maskToken(token string) string {
 }
 
 func truncateBody(b []byte) string {
-	s := string(b)
-	if len(s) > 200 {
-		return s[:200] + "..."
+	const maxBytes = 4096
+	if len(b) <= maxBytes {
+		return string(b)
 	}
-	return s
+	return strings.ToValidUTF8(string(b[:maxBytes]), "") + "..."
 }
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 4f78b1b9..15dd85d3 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -539,9 +539,9 @@ func maskToken(token string) string {
 }
 
 func truncateBody(b []byte) string {
-	s := string(b)
-	if len(s) > 200 {
-		return s[:200] + "..."
+	const maxBytes = 4096
+	if len(b) <= maxBytes {
+		return string(b)
 	}
-	return s
+	return strings.ToValidUTF8(string(b[:maxBytes]), "") + "..."
 }
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
index 01454246..c0ac2fa5 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -553,9 +553,9 @@ func maskToken(token string) string {
 }
 
 func truncateBody(b []byte) string {
-	s := string(b)
-	if len(s) > 200 {
-		return s[:200] + "..."
+	const maxBytes = 4096
+	if len(b) <= maxBytes {
+		return string(b)
 	}
-	return s
+	return strings.ToValidUTF8(string(b[:maxBytes]), "") + "..."
 }

← ee3638b2 fix(cli): hide const-default query flags from --help (#1287)  ·  back to Cli Printing Press  ·  fix(skills): skip publish-validate in mid-pipeline polish (# 8cc7e655 →