Skip to content

Commit 23a8536

Browse files
somanshreddyclaude
andauthored
cli: reclassify parse/read failures, add source/http_status telemetry, reserve cli_ code prefix (#208)
* cli: reclassify parse/read failures + add source/http_status telemetry Reclassify the remaining opaque `error` sites: a mid-response read failure -> network_error; JSON-parse and poll field-extraction failures -> response_parse_error. Capture the request id from x-amzn-request-id / x-amzn-trace-id (the app does not set X-Request-Id; the ALB does), falling back to X-Request-Id. Add telemetry-only origin signals to COMMAND_RUN_COMPLETE: source (api = from the API envelope, cli = CLI-generated; defaults to cli) and http_status (upstream status when known). Both are omitted on success and are NOT in the user-facing error envelope. Dashboards can then group by (source, error_code) collision-safely and separate an app 5xx from a no-envelope 5xx via source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * cli: add reserved cli_ prefix + code registry with enforcement test Namespace the CLI-originated response-parse code as cli_response_parse_error, add a central registry of all CLI-minted codes, and a test that scans source and fails on any unregistered or bare-but-not-allowlisted CLI code, forcing the cli_ prefix on future codes so they can never collide with an API code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * cli: drop x-amzn request-id fallback from the error envelope The x-amzn-request-id/trace-id fallback populated request_id, but api_service does not log those ids, so there is no working server-side correlation path for a caller to use. Revert to reading only X-Request-Id (which the app doesn't set today, so request_id stays omitted) rather than surfacing an id nobody can trace. The request_id envelope field is kept so it populates for free once the backend sets/logs a real request id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * cli: catch code:= in the registry scan; correct the envelope-doc bare-code note Codex nits on the cli_ namespacing: the source-scan regex missed the 'code :=' short-declaration form (a bare CLI code declared that way would evade the enforcement test), and the README said every bare code carries API semantics, omitting the grandfathered-legacy-CLI exception. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * cli: address #208 review — http_status on known-status CLI sites, invert cli_ scan, tidy classifyError - Set HTTPStatus on the CLI-synthesized errors whose status is known: the mid-body-read network_error and the download url-expired/failed cases, so the telemetry (source, http_status) axis can tell a 403 from a 404 and a 500 from a 502 (previously http_status=0 there). Completes the origin-axis design. - Add an inverted registry scan: any cli_-prefixed string literal in production source must be a registered code (or an explicit non-code literal like the cli_version analytics key), closing the identifier-coupling gap where a code assigned via a non-'code' variable name would evade the Code:/code= scan. - Move the CLI-origin default into classifyError so main.go's wrapped-error branch no longer special-cases source. - Fix an em dash in the cli_download_failed hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b89fae3 commit 23a8536

11 files changed

Lines changed: 454 additions & 37 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ Every command supports `--help`.
164164
| Aspect | Behavior |
165165
|--------|----------|
166166
| **stdout** | Always JSON. Even `video download` — binary writes to disk; stdout emits `{"asset", "message", "path"}` so you can chain on `.path`. |
167-
| **stderr** | Structured envelope on error: `{"error": {"code", "message", "hint", "param", "doc_url", "request_id"}}`. `code`/`message` are always present; `hint`/`param`/`doc_url`/`request_id` appear when applicable (`param`/`doc_url` are surfaced from the API for validation and documented errors). Stable `code` values for programmatic branching. |
167+
| **stderr** | Structured envelope on error: `{"error": {"code", "message", "hint", "param", "doc_url", "request_id"}}`. `code`/`message` are always present; `hint`/`param`/`doc_url`/`request_id` appear when applicable (`param`/`doc_url` are surfaced from the API for validation and documented errors). Stable `code` values for programmatic branching. A code prefixed `cli_` is originated by the CLI itself (client/transport/local conditions, e.g. `cli_download_url_expired`). A bare code is either an API code (or a CLI mirror of one) or one of a small frozen set of legacy CLI codes that predate the prefix. The `cli_` prefix is reserved for the CLI, so a new CLI code can never collide with an API code. |
168168
| **Exit codes** | `0` ok · `1` API or network · `2` usage · `3` auth / not permitted · `4` timeout under `--wait` (stdout contains partial resource for resume) |
169169
| **Request bodies** | Flags for simple inputs; `-d` for nested JSON (inline, file path, or `-` for stdin). Flags override matching fields. |
170170
| **Async jobs** | `--wait` blocks with exponential backoff; `--timeout` sets max (default 20m). 429s and 5xx retry automatically. |

cmd/heygen/main.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,33 @@ func main() {
2626

2727
exitCode := 0
2828
errorCode := ""
29+
source := ""
30+
httpStatus := 0
2931
if err != nil {
3032
var cliErr *clierrors.CLIError
3133
if errors.As(err, &cliErr) {
3234
enrichAuthHint(cliErr, credSourceFromCmd(executedCmd))
3335
formatter.Error(cliErr)
3436
exitCode = cliErr.ExitCode
3537
errorCode = cliErr.Code
38+
source = cliErr.Source
39+
if source == "" {
40+
source = "cli" // any error the CLI raised without an explicit origin
41+
}
42+
httpStatus = cliErr.HTTPStatus
3643
} else {
3744
// Cobra returns plain errors for unknown commands and arg validation.
3845
// Detect these and wrap as usage errors (exit 2).
3946
wrapped := classifyError(err)
4047
formatter.Error(wrapped)
4148
exitCode = wrapped.ExitCode
4249
errorCode = wrapped.Code
50+
source = wrapped.Source
4351
}
4452
}
4553

4654
if analyticsClient.Started() && executedCmd != nil {
47-
analyticsClient.CommandRunComplete(executedCmd.CommandPath(), exitCode, time.Since(start), errorCode)
55+
analyticsClient.CommandRunComplete(executedCmd.CommandPath(), exitCode, time.Since(start), errorCode, source, httpStatus)
4856
}
4957
analyticsClient.Close()
5058
os.Exit(exitCode)
@@ -55,15 +63,19 @@ func main() {
5563
// everything else gets exit 1.
5664
func classifyError(err error) *clierrors.CLIError {
5765
msg := err.Error()
66+
var wrapped *clierrors.CLIError
5867
if strings.HasPrefix(msg, "unknown command") ||
5968
strings.HasPrefix(msg, "unknown flag") ||
6069
strings.HasPrefix(msg, "unknown shorthand flag") ||
6170
strings.Contains(msg, "accepts ") ||
6271
strings.HasPrefix(msg, "required flag") ||
6372
strings.HasPrefix(msg, "invalid argument") {
64-
return clierrors.NewUsage(msg)
73+
wrapped = clierrors.NewUsage(msg)
74+
} else {
75+
wrapped = clierrors.New(msg)
6576
}
66-
return clierrors.New(msg)
77+
wrapped.Source = "cli" // Cobra-wrapped errors are always CLI-origin.
78+
return wrapped
6779
}
6880

6981
func analyticsEnabled() bool {

cmd/heygen/video_download.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,17 +218,19 @@ func downloadFile(ctx context.Context, videoURL, dest string) error {
218218
// re-fetch); any other status is the asset host (our storage/CDN) failing.
219219
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound {
220220
return &clierrors.CLIError{
221-
Code: "cli_download_url_expired",
222-
Message: fmt.Sprintf("download link expired or unavailable (HTTP %d)", resp.StatusCode),
223-
Hint: "This download link has expired. Re-fetch a fresh URL: heygen video get <video_id>",
224-
ExitCode: clierrors.ExitGeneral,
221+
Code: "cli_download_url_expired",
222+
Message: fmt.Sprintf("download link expired or unavailable (HTTP %d)", resp.StatusCode),
223+
Hint: "This download link has expired. Re-fetch a fresh URL: heygen video get <video_id>",
224+
HTTPStatus: resp.StatusCode,
225+
ExitCode: clierrors.ExitGeneral,
225226
}
226227
}
227228
return &clierrors.CLIError{
228-
Code: "cli_download_failed",
229-
Message: fmt.Sprintf("download failed with HTTP %d", resp.StatusCode),
230-
Hint: "The asset host returned an error fetching the file. This is usually transient — retry shortly.",
231-
ExitCode: clierrors.ExitGeneral,
229+
Code: "cli_download_failed",
230+
Message: fmt.Sprintf("download failed with HTTP %d", resp.StatusCode),
231+
Hint: "The asset host returned an error fetching the file. This is usually transient. Retry shortly.",
232+
HTTPStatus: resp.StatusCode,
233+
ExitCode: clierrors.ExitGeneral,
232234
}
233235
}
234236

internal/analytics/analytics.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,28 @@ func (c *Client) CommandRun(command string) {
7070
})
7171
}
7272

73-
func (c *Client) CommandRunComplete(command string, exitCode int, duration time.Duration, errorCode string) {
73+
// CommandRunComplete records a completed command. On error, source is "api" (the
74+
// error came from an API response envelope) or "cli" (CLI-generated), and httpStatus
75+
// is the upstream status when known (0 otherwise); both are omitted on success.
76+
func (c *Client) CommandRunComplete(command string, exitCode int, duration time.Duration, errorCode, source string, httpStatus int) {
7477
if !c.enabled || c.ph == nil {
7578
return
7679
}
80+
props := c.baseProperties(command).
81+
Set("exit_code", exitCode).
82+
Set("duration_ms", duration.Milliseconds()).
83+
Set("success", exitCode == 0).
84+
Set("error_code", errorCode)
85+
if source != "" {
86+
props = props.Set("source", source)
87+
}
88+
if httpStatus > 0 {
89+
props = props.Set("http_status", httpStatus)
90+
}
7791
_ = c.ph.Enqueue(posthog.Capture{
7892
DistinctId: c.distinctID,
7993
Event: "COMMAND_RUN_COMPLETE",
80-
Properties: c.baseProperties(command).
81-
Set("exit_code", exitCode).
82-
Set("duration_ms", duration.Milliseconds()).
83-
Set("success", exitCode == 0).
84-
Set("error_code", errorCode),
94+
Properties: props,
8595
})
8696
}
8797

internal/analytics/analytics_test.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func TestCommandRunComplete_Properties(t *testing.T) {
5959
client := newWithCapture("v1.2.3", stub)
6060
client.distinctID = "anon-id"
6161

62-
client.CommandRunComplete("heygen video create", 4, 1500*time.Millisecond, "timeout")
62+
client.CommandRunComplete("heygen video create", 4, 1500*time.Millisecond, "timeout", "cli", 0)
6363

6464
if len(stub.messages) != 1 {
6565
t.Fatalf("messages = %d, want 1", len(stub.messages))
@@ -107,7 +107,7 @@ func TestCommandRunComplete_IncludesClientOrigin(t *testing.T) {
107107
client := newWithCapture("v1.2.3", stub)
108108
client.clientOrigin = "claude_code"
109109

110-
client.CommandRunComplete("heygen video create", 0, time.Second, "")
110+
client.CommandRunComplete("heygen video create", 0, time.Second, "", "", 0)
111111

112112
msg := stub.messages[0].(posthog.Capture)
113113
if got := msg.Properties["client_origin"]; got != "claude_code" {
@@ -225,3 +225,39 @@ func TestDistinctID_Persists(t *testing.T) {
225225
t.Fatalf("second distinct ID = %q, want %q", second, first)
226226
}
227227
}
228+
229+
func TestCommandRunComplete_SourceAndHTTPStatus(t *testing.T) {
230+
t.Run("api source carries http_status", func(t *testing.T) {
231+
stub := &stubCaptureClient{}
232+
client := newWithCapture("v1.2.3", stub)
233+
client.CommandRunComplete("heygen video create", 1, time.Second, "insufficient_credit", "api", 402)
234+
msg := stub.messages[0].(posthog.Capture)
235+
if got := msg.Properties["source"]; got != "api" {
236+
t.Fatalf("source = %v, want api", got)
237+
}
238+
if got := msg.Properties["http_status"]; got != 402 {
239+
t.Fatalf("http_status = %v, want 402", got)
240+
}
241+
})
242+
t.Run("cli source omits http_status when 0", func(t *testing.T) {
243+
stub := &stubCaptureClient{}
244+
client := newWithCapture("v1.2.3", stub)
245+
client.CommandRunComplete("heygen video create", 1, time.Second, "cli_file_io_error", "cli", 0)
246+
msg := stub.messages[0].(posthog.Capture)
247+
if got := msg.Properties["source"]; got != "cli" {
248+
t.Fatalf("source = %v, want cli", got)
249+
}
250+
if _, ok := msg.Properties["http_status"]; ok {
251+
t.Fatalf("http_status should be omitted when 0")
252+
}
253+
})
254+
t.Run("success omits source", func(t *testing.T) {
255+
stub := &stubCaptureClient{}
256+
client := newWithCapture("v1.2.3", stub)
257+
client.CommandRunComplete("heygen video list", 0, time.Second, "", "", 0)
258+
msg := stub.messages[0].(posthog.Capture)
259+
if _, ok := msg.Properties["source"]; ok {
260+
t.Fatalf("source should be omitted on success")
261+
}
262+
})
263+
}

internal/client/executor.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,13 @@ func (c *Client) ExecuteAndPoll(
8181

8282
resourceID, err := extractJSONPath(createResp, spec.PollConfig.IDField)
8383
if err != nil {
84-
return nil, clierrors.New(fmt.Sprintf(
85-
"failed to extract resource ID from %q: %v. This command may require manual polling for batch responses",
86-
spec.PollConfig.IDField, err,
87-
))
84+
return nil, &clierrors.CLIError{
85+
Code: "cli_response_parse_error",
86+
Message: fmt.Sprintf(
87+
"failed to extract resource ID from %q: %v", spec.PollConfig.IDField, err),
88+
Hint: "The create response did not have the expected shape. This command may require manual polling for batch responses.",
89+
ExitCode: clierrors.ExitGeneral,
90+
}
8891
}
8992

9093
// If IDField uses an array index (e.g., "data.ids.0"), reject batch
@@ -131,10 +134,13 @@ func (c *Client) ExecuteAndPoll(
131134

132135
status, err := extractJSONPath(statusResp, spec.PollConfig.StatusField)
133136
if err != nil {
134-
return nil, clierrors.New(fmt.Sprintf(
135-
"failed to extract status from %q: %v",
136-
spec.PollConfig.StatusField, err,
137-
))
137+
return nil, &clierrors.CLIError{
138+
Code: "cli_response_parse_error",
139+
Message: fmt.Sprintf(
140+
"failed to extract status from %q: %v", spec.PollConfig.StatusField, err),
141+
Hint: "The status response did not have the expected shape. Retry; if it persists, report it.",
142+
ExitCode: clierrors.ExitGeneral,
143+
}
138144
}
139145

140146
if slices.Contains(spec.PollConfig.TerminalOK, status) {
@@ -201,6 +207,10 @@ func (c *Client) executeWithContext(ctx context.Context, spec *command.Spec, inv
201207
ExitCode: clierrors.ExitAuth,
202208
}
203209
}
210+
// network_error is a grandfathered bare code (not cli_-prefixed): it is
211+
// shared with the download-command transport path (cmd/heygen/video_download.go)
212+
// and predates the cli_ convention. See the grandfathered set in
213+
// internal/errors/codes.go; keep both emission sites bare and in sync.
204214
return nil, &clierrors.CLIError{
205215
Code: "network_error",
206216
Message: fmt.Sprintf("request failed: %v", err),
@@ -212,7 +222,17 @@ func (c *Client) executeWithContext(ctx context.Context, spec *command.Spec, inv
212222

213223
respBody, err := io.ReadAll(resp.Body)
214224
if err != nil {
215-
return nil, clierrors.New(fmt.Sprintf("failed to read response body: %v", err))
225+
// Mid-response read failure is a dropped connection, not a parse issue.
226+
// The response arrived (status known) before the body dropped, so record
227+
// it: dashboards can then tell this from the pre-response transport failure
228+
// above (where resp is nil and the status is genuinely 0).
229+
return nil, &clierrors.CLIError{
230+
Code: "network_error",
231+
Message: fmt.Sprintf("failed to read response body: %v", err),
232+
Hint: "This is usually a temporary network issue. Check your connection and retry.",
233+
HTTPStatus: resp.StatusCode,
234+
ExitCode: clierrors.ExitGeneral,
235+
}
216236
}
217237

218238
if resp.StatusCode >= 400 {
@@ -394,7 +414,12 @@ func extractJSONPath(raw json.RawMessage, path string) (string, error) {
394414

395415
var current any
396416
if err := json.Unmarshal(raw, &current); err != nil {
397-
return "", clierrors.New(fmt.Sprintf("failed to parse JSON response: %v", err))
417+
return "", &clierrors.CLIError{
418+
Code: "cli_response_parse_error",
419+
Message: fmt.Sprintf("failed to parse JSON response: %v", err),
420+
Hint: "The API response could not be parsed. Retry; if it persists, report it (possible CLI/API mismatch).",
421+
ExitCode: clierrors.ExitGeneral,
422+
}
398423
}
399424

400425
for _, part := range strings.Split(path, ".") {

internal/client/executor_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,3 +997,32 @@ func TestParseErrorResponse_SurfacesParamAndDocURL(t *testing.T) {
997997
t.Errorf("DocURL = %q, want the doc URL", err.DocURL)
998998
}
999999
}
1000+
1001+
func TestExtractJSONPath_ParseError(t *testing.T) {
1002+
_, err := extractJSONPath([]byte("{not valid json"), "data.id")
1003+
var cliErr *clierrors.CLIError
1004+
if !errors.As(err, &cliErr) || cliErr.Code != "cli_response_parse_error" {
1005+
t.Fatalf("err = %v, want a cli_response_parse_error CLIError", err)
1006+
}
1007+
}
1008+
1009+
// errReadCloser fails on Read, simulating a connection dropped mid-response.
1010+
type errReadCloser struct{}
1011+
1012+
func (errReadCloser) Read([]byte) (int, error) { return 0, errors.New("connection reset mid-body") }
1013+
func (errReadCloser) Close() error { return nil }
1014+
1015+
type errBodyTransport struct{}
1016+
1017+
func (errBodyTransport) RoundTrip(*http.Request) (*http.Response, error) {
1018+
return &http.Response{StatusCode: 200, Body: errReadCloser{}, Header: make(http.Header)}, nil
1019+
}
1020+
1021+
func TestExecute_ResponseBodyReadError_NetworkError(t *testing.T) {
1022+
c := New("key", WithBaseURL("http://example.invalid"), WithHTTPClient(&http.Client{Transport: errBodyTransport{}}))
1023+
_, err := c.Execute(&command.Spec{Endpoint: "/v3/videos", Method: "GET"}, &command.Invocation{PathParams: map[string]string{}})
1024+
var cliErr *clierrors.CLIError
1025+
if !errors.As(err, &cliErr) || cliErr.Code != "network_error" {
1026+
t.Fatalf("err = %v, want a network_error CLIError", err)
1027+
}
1028+
}

internal/errors/codes.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package errors
2+
3+
// CLI error-code namespace governance.
4+
//
5+
// A code is "CLI-originated" when the CLI synthesizes it itself, as opposed to
6+
// relaying a code from the API response envelope. Every CLI-originated code must
7+
// be registered in exactly one of the three sets below. New CLI-originated codes
8+
// MUST carry the reserved cli_ prefix (cliPrefixedCodes); the two bare sets are
9+
// CLOSED allowlists (grandfathered legacy + API-semantic) that must not grow.
10+
//
11+
// The cli_ prefix is reserved so a CLI code can never collide with a future API
12+
// code, provided the API never mints a cli_-prefixed code (enforced API-side).
13+
// codes_test.go scans the source and fails on any CLI-minted code that is either
14+
// unregistered or bare-but-not-allowlisted, which forces the prefix on new codes.
15+
16+
// CLICodePrefix is reserved for CLI-originated error codes. The API must never
17+
// emit a code beginning with this prefix.
18+
const CLICodePrefix = "cli_"
19+
20+
// cliPrefixedCodes are CLI-originated codes carrying the reserved prefix.
21+
var cliPrefixedCodes = []string{
22+
"cli_response_encode_error",
23+
"cli_response_parse_error",
24+
"cli_download_url_expired",
25+
"cli_download_failed",
26+
"cli_download_interrupted",
27+
"cli_file_io_error",
28+
}
29+
30+
// grandfatheredBareCodes are CLI-originated codes that shipped bare in a stable
31+
// release before the cli_ convention. FROZEN: do not add. Their contract is the
32+
// exit code (buckets) or they are CLI-only names the API has no reason to mint.
33+
var grandfatheredBareCodes = []string{
34+
"error", "usage_error", "auth_error", "timeout", "canceled",
35+
"network_error", "confirmation_required", "file_exists",
36+
"batch_not_supported", "wrong_install_method",
37+
"video_failed", "video_not_ready",
38+
}
39+
40+
// bareAPISemanticCodes are bare codes that carry API semantics: CLI mirrors of
41+
// API codes (same name, same meaning, so a same-name overlap is correct, not a
42+
// clash) and CLI fallback labels for API faults. Not reverse-collision risk;
43+
// unclassified_* is an accepted risk (the API will not mint an "unclassified"
44+
// code). asset_not_available is dual-use: an API code the CLI also mints.
45+
var bareAPISemanticCodes = []string{
46+
"not_found", "insufficient_credit", "forbidden", "unauthorized",
47+
"conflict", "rate_limit_exceeded", "validation_error", "payload_too_large",
48+
"asset_not_available",
49+
"unclassified_server_error", "unclassified_client_error",
50+
}

0 commit comments

Comments
 (0)