Skip to content

Commit c103b21

Browse files
somanshreddyclaude
andauthored
cli: reclassify non-envelope errors and split 401/403 auth codes (#205)
Route error responses with no v3 envelope through FromAPIError by HTTP status instead of the opaque `error` code: 4xx map to client codes, 5xx to unclassified_server_error, unmapped to unclassified_client_error, always with a non-empty message. Preserve code-only envelopes (relaxed the message-only gate). Split 401/403 into unauthorized/forbidden so a 403 no longer suggests logging in; enrichAuthHint is gated to not-authenticated codes. Help footer + README exit-code table now read '3 auth/permission'. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c402947 commit c103b21

10 files changed

Lines changed: 302 additions & 30 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Every command supports `--help`.
165165
|--------|----------|
166166
| **stdout** | Always JSON. Even `video download` — binary writes to disk; stdout emits `{"asset", "message", "path"}` so you can chain on `.path`. |
167167
| **stderr** | Structured envelope on error: `{"error": {"code", "message", "hint"}}`. Stable `code` values for programmatic branching. |
168-
| **Exit codes** | `0` ok · `1` API or network · `2` usage · `3` auth · `4` timeout under `--wait` (stdout contains partial resource for resume) |
168+
| **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. |
171171

cmd/heygen/auth_status_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ func TestAuthStatus_InvalidKey(t *testing.T) {
5353
if res.ExitCode != clierrors.ExitAuth {
5454
t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitAuth, res.Stderr)
5555
}
56-
if !strings.Contains(res.Stderr, `"code":"auth_error"`) {
57-
t.Fatalf("stderr = %s, want auth_error code", res.Stderr)
56+
if !strings.Contains(res.Stderr, `"code":"unauthorized"`) {
57+
t.Fatalf("stderr = %s, want unauthorized code", res.Stderr)
5858
}
5959
if !strings.Contains(res.Stderr, `"message":"invalid API key"`) {
6060
t.Fatalf("stderr = %s, want invalid API key message", res.Stderr)

cmd/heygen/context.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ func enrichAuthHint(cliErr *clierrors.CLIError, source auth.CredentialSource) {
7777
if cliErr.ExitCode != clierrors.ExitAuth {
7878
return
7979
}
80+
// Only enrich genuine not-authenticated errors. 403 (forbidden / permission)
81+
// also exits 3, but re-authenticating won't help, so never stamp a "log in"
82+
// hint on it — it carries its own permission-oriented hint.
83+
switch cliErr.Code {
84+
case "auth_error", "unauthorized", "invalid_credentials":
85+
default:
86+
return
87+
}
8088
if cliErr.Hint != "" {
8189
return
8290
}

cmd/heygen/context_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,54 @@ func TestEnrichAuthHint_ExistingHint_Preserved(t *testing.T) {
9494
t.Fatalf("no-key error should preserve authGuidance hint:\n%s", res.Stderr)
9595
}
9696
}
97+
98+
// A 403 (forbidden) exits 3 but must NOT be given a login / invalid-key hint by
99+
// enrichAuthHint — it keeps its permission-oriented hint.
100+
func TestEnrichAuthHint_Forbidden_NotOverwritten(t *testing.T) {
101+
srv := setupTestServer(t, map[string]testHandler{
102+
"GET /v3/videos": {
103+
StatusCode: 403,
104+
Body: `{"error":{"code":"forbidden","message":"not allowed"}}`,
105+
},
106+
})
107+
defer srv.Close()
108+
109+
res := runCommand(t, srv.URL, "some-key", "video", "list")
110+
111+
if res.ExitCode != clierrors.ExitAuth {
112+
t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitAuth, res.Stderr)
113+
}
114+
if !strings.Contains(res.Stderr, `"code":"forbidden"`) {
115+
t.Fatalf("stderr should carry the forbidden code:\n%s", res.Stderr)
116+
}
117+
if strings.Contains(res.Stderr, "HEYGEN_API_KEY environment variable") || strings.Contains(res.Stderr, "auth login") {
118+
t.Fatalf("a 403 must not get a login/invalid-key hint:\n%s", res.Stderr)
119+
}
120+
if !strings.Contains(res.Stderr, "not permitted") {
121+
t.Fatalf("a 403 should carry a permission hint:\n%s", res.Stderr)
122+
}
123+
}
124+
125+
// A non-envelope (HTML) 403 — status-derived forbidden — must also avoid the
126+
// login hint end-to-end, exercising the derived-code path through the formatter.
127+
func TestForbidden_NonEnvelope_NoLoginHint(t *testing.T) {
128+
srv := setupTestServer(t, map[string]testHandler{
129+
"GET /v3/videos": {
130+
StatusCode: 403,
131+
Body: `<html>403 Forbidden</html>`,
132+
},
133+
})
134+
defer srv.Close()
135+
136+
res := runCommand(t, srv.URL, "some-key", "video", "list")
137+
138+
if res.ExitCode != clierrors.ExitAuth {
139+
t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitAuth, res.Stderr)
140+
}
141+
if !strings.Contains(res.Stderr, `"code":"forbidden"`) {
142+
t.Fatalf("stderr should carry the derived forbidden code:\n%s", res.Stderr)
143+
}
144+
if strings.Contains(res.Stderr, "HEYGEN_API_KEY environment variable") || strings.Contains(res.Stderr, "auth login") {
145+
t.Fatalf("a non-envelope 403 must not get a login hint:\n%s", res.Stderr)
146+
}
147+
}

cmd/heygen/generated_root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ func TestGeneratedRoot_Help_ShowsExitCodesAndHidesCompletion(t *testing.T) {
181181
if res.ExitCode != 0 {
182182
t.Errorf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
183183
}
184-
if !strings.Contains(res.Stdout, "Exit Codes:") || !strings.Contains(res.Stdout, "3 Authentication error") {
184+
if !strings.Contains(res.Stdout, "Exit Codes:") || !strings.Contains(res.Stdout, "3 Authentication / permission error") {
185185
t.Errorf("root help missing documented exit codes\nstdout: %s", res.Stdout)
186186
}
187187
if strings.Contains(res.Stdout, "completion") {

cmd/heygen/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Exit Codes:
8989
0 Success
9090
1 General error (API error, network failure)
9191
2 Usage error (invalid flags, missing arguments)
92-
3 Authentication error (missing or invalid API key)
92+
3 Authentication / permission error (not authenticated, or not permitted)
9393
4 Timeout (resource created but operation not yet complete)
9494
9595
Tip: Use --request-schema on any command to see the expected JSON input fields.

internal/client/executor.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -496,18 +496,22 @@ func classifyPollContextError(err error, resourceID string, lastResp json.RawMes
496496
}
497497

498498
// parseErrorResponse parses an API error response into a CLIError.
499+
//
500+
// A body that unmarshals into the v3 envelope with a code OR message is a real app
501+
// error (the code-or-message gate is relaxed from the old message-only check so a
502+
// code-only envelope keeps its code instead of collapsing to generic "error"). Any
503+
// other body — gateway/WAF HTML, an empty body, or a foreign JSON shape — did not
504+
// come from the v3 app; it is classified by HTTP status via FromAPIError (which maps
505+
// 4xx to client codes, 5xx to unclassified_server_error, unmapped to
506+
// unclassified_client_error, and synthesizes a non-empty message).
499507
func parseErrorResponse(statusCode int, body []byte, requestID string) *clierrors.CLIError {
500508
var envelope struct {
501509
Error clierrors.APIError `json:"error"`
502510
}
503-
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Error.Message != "" {
511+
if err := json.Unmarshal(body, &envelope); err == nil &&
512+
(envelope.Error.Code != "" || envelope.Error.Message != "") {
504513
return clierrors.FromAPIError(statusCode, &envelope.Error, requestID)
505514
}
506515

507-
return &clierrors.CLIError{
508-
Code: "error",
509-
Message: fmt.Sprintf("API returned HTTP %d", statusCode),
510-
RequestID: requestID,
511-
ExitCode: clierrors.ExitGeneral,
512-
}
516+
return clierrors.FromAPIError(statusCode, &clierrors.APIError{}, requestID)
513517
}

internal/client/executor_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,3 +921,66 @@ func emptyInvocation() *command.Invocation {
921921
QueryParams: make(url.Values),
922922
}
923923
}
924+
925+
func TestParseErrorResponse_NonEnvelope(t *testing.T) {
926+
tests := []struct {
927+
name string
928+
status int
929+
body string
930+
wantCode string
931+
}{
932+
{"gateway HTML 502", 502, "<html>502 Bad Gateway</html>", "unclassified_server_error"},
933+
{"empty body 503", 503, "", "unclassified_server_error"},
934+
{"empty body 404", 404, "", "not_found"},
935+
{"unmapped 4xx 405", 405, "<html>405 Method Not Allowed</html>", "unclassified_client_error"},
936+
}
937+
for _, tt := range tests {
938+
t.Run(tt.name, func(t *testing.T) {
939+
err := parseErrorResponse(tt.status, []byte(tt.body), "req_1")
940+
if err.Code != tt.wantCode {
941+
t.Errorf("Code = %q, want %q", err.Code, tt.wantCode)
942+
}
943+
if err.Message == "" {
944+
t.Errorf("Message must never be empty (status %d)", tt.status)
945+
}
946+
})
947+
}
948+
}
949+
950+
func TestParseErrorResponse_CodeOnlyEnvelope(t *testing.T) {
951+
// An envelope with a code but no message must keep the code (relaxed gate),
952+
// not collapse to a generic error.
953+
err := parseErrorResponse(409, []byte(`{"error":{"code":"conflict"}}`), "req_1")
954+
if err.Code != "conflict" {
955+
t.Errorf("Code = %q, want conflict", err.Code)
956+
}
957+
if err.Message == "" {
958+
t.Errorf("Message should be synthesized when the envelope has none")
959+
}
960+
}
961+
962+
// The intended behavior change: a non-envelope 401/403 (opaque or HTML body) now
963+
// classifies as unauthorized/forbidden with ExitAuth, through the parseErrorResponse
964+
// gate that changed in this PR (previously it fell to generic error / ExitGeneral).
965+
func TestParseErrorResponse_NonEnvelope_AuthExit(t *testing.T) {
966+
tests := []struct {
967+
name string
968+
status int
969+
body string
970+
wantCode string
971+
}{
972+
{"empty 401", 401, "", "unauthorized"},
973+
{"HTML 403", 403, "<html>403 Forbidden</html>", "forbidden"},
974+
}
975+
for _, tt := range tests {
976+
t.Run(tt.name, func(t *testing.T) {
977+
err := parseErrorResponse(tt.status, []byte(tt.body), "req_1")
978+
if err.Code != tt.wantCode {
979+
t.Errorf("Code = %q, want %q", err.Code, tt.wantCode)
980+
}
981+
if err.ExitCode != clierrors.ExitAuth {
982+
t.Errorf("ExitCode = %d, want %d (ExitAuth)", err.ExitCode, clierrors.ExitAuth)
983+
}
984+
})
985+
}
986+
}

internal/errors/errors.go

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,47 +75,99 @@ func NewUsage(message string) *CLIError {
7575
}
7676
}
7777

78+
// isNonSpecific reports whether an API-provided error code is too generic to
79+
// trust for classification. v3 always sends a specific code; an empty or literal
80+
// "error" code means we should classify from the HTTP status instead.
81+
func isNonSpecific(code string) bool {
82+
return code == "" || code == "error"
83+
}
84+
85+
// forbiddenHint is the shared non-login guidance for 403 responses: the caller is
86+
// authenticated but not permitted, so re-authenticating will not help.
87+
const forbiddenHint = "Your credentials are valid but not permitted for this. Check your plan, permissions, or workspace — re-authenticating will not help. Contact support if this is unexpected."
88+
7889
// FromAPIError converts an API error envelope and HTTP status code into a CLIError.
90+
//
91+
// The v3 API always returns a specific lowercase code, which is preserved. When the
92+
// code is absent or non-specific (empty or literal "error" — e.g. a non-envelope
93+
// edge/gateway response routed here by parseErrorResponse), the code is derived from
94+
// the HTTP status instead.
7995
func FromAPIError(statusCode int, apiErr *APIError, requestID string) *CLIError {
8096
exitCode := ExitGeneral
8197
code := apiErr.Code
98+
if isNonSpecific(code) {
99+
code = "" // fall through to status-derived classification
100+
}
82101

83102
switch {
84-
case statusCode == 401 || statusCode == 403:
103+
case statusCode == 401:
85104
exitCode = ExitAuth
86105
if code == "" {
87-
code = "auth_error"
106+
code = "unauthorized"
107+
}
108+
case statusCode == 403:
109+
// Authenticated but not permitted — auth-family exit, but NOT "log in".
110+
exitCode = ExitAuth
111+
if code == "" {
112+
code = "forbidden"
88113
}
89114
case statusCode == 400:
90115
if code == "" {
91116
code = "validation_error"
92117
}
118+
case statusCode == 402:
119+
if code == "" {
120+
code = "insufficient_credit"
121+
}
93122
case statusCode == 404:
94123
if code == "" {
95124
code = "not_found"
96125
}
126+
case statusCode == 409:
127+
if code == "" {
128+
code = "conflict"
129+
}
130+
case statusCode == 413:
131+
if code == "" {
132+
code = "payload_too_large"
133+
}
97134
case statusCode == 429:
98135
if code == "" {
99136
code = "rate_limited"
100137
}
101138
case statusCode >= 500:
139+
// A v3 app 5xx carries its own code (internal_error); a 5xx with no specific
140+
// code did not come through the app's normal error path.
102141
if code == "" {
103-
code = "server_error"
142+
code = "unclassified_server_error"
104143
}
105144
default:
145+
// Unmapped 4xx (405, 410, 415, 422, ...) with no specific code: the request
146+
// was rejected in a way we don't classify — client-side.
106147
if code == "" {
107-
code = "error"
148+
code = "unclassified_client_error"
108149
}
109150
}
110151

111-
hint := hintForAPICode(apiErr.Code)
112-
if apiErr.Code == "invalid_parameter" && apiErr.Param != nil && *apiErr.Param != "" {
152+
message := apiErr.Message
153+
if message == "" {
154+
// Never render an empty message (e.g. a non-envelope body routed here).
155+
message = fmt.Sprintf("API returned HTTP %d", statusCode)
156+
}
157+
158+
hint := hintForAPICode(code)
159+
if code == "invalid_parameter" && apiErr.Param != nil && *apiErr.Param != "" {
113160
hint = fmt.Sprintf("Invalid field %q. %s", *apiErr.Param, hint)
114161
}
162+
// Generic non-login hint for any unmapped 403 code (hintForAPICode is code-only
163+
// and cannot see the status).
164+
if hint == "" && statusCode == 403 {
165+
hint = forbiddenHint
166+
}
115167

116168
return &CLIError{
117169
Code: code,
118-
Message: apiErr.Message,
170+
Message: message,
119171
Hint: hint,
120172
RequestID: requestID,
121173
ExitCode: exitCode,
@@ -124,6 +176,9 @@ func FromAPIError(statusCode int, apiErr *APIError, requestID string) *CLIError
124176

125177
// hintForAPICode returns a CLI-specific actionable hint for known API error
126178
// codes. Returns "" if the code has no associated hint.
179+
//
180+
// Note: "unauthorized" deliberately has NO hint here — the source-aware
181+
// enrichAuthHint (cmd/heygen) supplies better guidance (which credential is bad).
127182
func hintForAPICode(code string) string {
128183
switch code {
129184
case "avatar_not_found":
@@ -144,6 +199,16 @@ func hintForAPICode(code string) string {
144199
return "The asset may still be processing or was deleted"
145200
case "timeout":
146201
return "The operation may still be in progress. Check status with the corresponding get command"
202+
case "forbidden", "resource_access_denied", "ai_vendor_access_restricted":
203+
return forbiddenHint
204+
case "voice_not_usable":
205+
return "This voice can't be used for this request (e.g. not permitted on your plan). Choose another: heygen voice list"
206+
case "payload_too_large":
207+
return "The request or upload exceeds the size limit. Reduce the file size or payload"
208+
case "conflict":
209+
return "This conflicts with the current state (e.g. a duplicate or in-progress request). Retrying the same request is unlikely to help"
210+
case "unclassified_server_error":
211+
return "The server returned an error with no details. This is often transient — retry shortly; if it persists, contact support"
147212
}
148213
return ""
149214
}

0 commit comments

Comments
 (0)