Skip to content

Commit 844c843

Browse files
somanshreddyclaude
andcommitted
cli: surface error doc_url + param, fix rate-limit hint code
Add doc_url and param to the CLI error envelope, read from the API error envelope and emitted only when present, so agents and users get the failing field and a link to the error's documentation. Fix the rate-limit hint: it was keyed on `rate_limited`, but the v3 API emits `rate_limit_exceeded` (429), so real rate limits got no hint. Key the hint on `rate_limit_exceeded` (and `quota_exceeded`), and align the 429 empty-code fallback to `rate_limit_exceeded`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c103b21 commit 844c843

5 files changed

Lines changed: 76 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,15 @@ 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"}}`. 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. |
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. |
171171

172172
Example error envelope:
173173

174174
```json
175-
{"error": {"code": "not_found", "message": "Video not found", "hint": "Check ID with: heygen video list"}}
175+
{"error": {"code": "not_found", "message": "Video not found", "hint": "Check ID with: heygen video list", "doc_url": "https://developers.heygen.com/docs/error-codes#not-found"}}
176176
```
177177

178178
## Configuration

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ heygen video get --response-schema
6969
## Output Contract
7070

7171
- **stdout**: JSON (always). This is the only output agents should consume.
72-
- **stderr**: JSON error envelope on failure: `{"error":{"code":"...","message":"...","hint":"..."}}`
72+
- **stderr**: JSON error envelope on failure: `{"error":{"code":"...","message":"...","hint":"...","doc_url":"...","param":"..."}}` (`hint`/`doc_url`/`param`/`request_id` present when applicable)
7373
- Do not pass `--human`. It produces unstructured text that cannot be parsed.
7474

7575
## Notes

internal/client/executor_test.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ func TestExecute_RetryOn429(t *testing.T) {
257257
calls++
258258
if calls == 1 {
259259
w.WriteHeader(http.StatusTooManyRequests)
260-
_, _ = w.Write([]byte(`{"error":{"code":"rate_limited","message":"too many requests"}}`))
260+
_, _ = w.Write([]byte(`{"error":{"code":"rate_limit_exceeded","message":"too many requests"}}`))
261261
return
262262
}
263263
w.WriteHeader(http.StatusOK)
@@ -292,7 +292,7 @@ func TestExecute_RetryPreservesBody(t *testing.T) {
292292
calls++
293293
if calls == 1 {
294294
w.WriteHeader(http.StatusTooManyRequests)
295-
_, _ = w.Write([]byte(`{"error":{"code":"rate_limited","message":"too many requests"}}`))
295+
_, _ = w.Write([]byte(`{"error":{"code":"rate_limit_exceeded","message":"too many requests"}}`))
296296
return
297297
}
298298
w.WriteHeader(http.StatusOK)
@@ -984,3 +984,16 @@ func TestParseErrorResponse_NonEnvelope_AuthExit(t *testing.T) {
984984
})
985985
}
986986
}
987+
988+
// Integration: param/doc_url in an HTTP error body survive JSON parsing all the way
989+
// to the CLIError (not just when passed a pre-built APIError struct).
990+
func TestParseErrorResponse_SurfacesParamAndDocURL(t *testing.T) {
991+
body := []byte(`{"error":{"code":"invalid_parameter","message":"bad value","param":"avatar_id","doc_url":"https://developers.heygen.com/docs/error-codes#invalid-parameter"}}`)
992+
err := parseErrorResponse(400, body, "req_1")
993+
if err.Param != "avatar_id" {
994+
t.Errorf("Param = %q, want avatar_id", err.Param)
995+
}
996+
if !strings.Contains(err.DocURL, "error-codes") {
997+
t.Errorf("DocURL = %q, want the doc URL", err.DocURL)
998+
}
999+
}

internal/errors/errors.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type CLIError struct {
1919
Code string // machine-readable: "auth_error", "not_found", "network_error"
2020
Message string // human-readable description
2121
Hint string // actionable fix: "Run heygen auth login"
22+
Param string // API: request field that caused the error (validation errors)
23+
DocURL string // API: link to this error code's documentation
2224
RequestID string // from API X-Request-Id header (if applicable)
2325
ExitCode int // process exit code (0/1/2/3/4)
2426
}
@@ -41,6 +43,12 @@ func (e *CLIError) ToErrorEnvelope() map[string]any {
4143
if e.Hint != "" {
4244
inner["hint"] = e.Hint
4345
}
46+
if e.Param != "" {
47+
inner["param"] = e.Param
48+
}
49+
if e.DocURL != "" {
50+
inner["doc_url"] = e.DocURL
51+
}
4452
if e.RequestID != "" {
4553
inner["request_id"] = e.RequestID
4654
}
@@ -133,7 +141,7 @@ func FromAPIError(statusCode int, apiErr *APIError, requestID string) *CLIError
133141
}
134142
case statusCode == 429:
135143
if code == "" {
136-
code = "rate_limited"
144+
code = "rate_limit_exceeded"
137145
}
138146
case statusCode >= 500:
139147
// A v3 app 5xx carries its own code (internal_error); a 5xx with no specific
@@ -165,10 +173,23 @@ func FromAPIError(statusCode int, apiErr *APIError, requestID string) *CLIError
165173
hint = forbiddenHint
166174
}
167175

176+
// Surface the API's own param / doc_url so agents and users get the field that
177+
// failed and a link to the error's documentation (present only on API errors).
178+
param := ""
179+
if apiErr.Param != nil {
180+
param = *apiErr.Param
181+
}
182+
docURL := ""
183+
if apiErr.DocURL != nil {
184+
docURL = *apiErr.DocURL
185+
}
186+
168187
return &CLIError{
169188
Code: code,
170189
Message: message,
171190
Hint: hint,
191+
Param: param,
192+
DocURL: docURL,
172193
RequestID: requestID,
173194
ExitCode: exitCode,
174195
}
@@ -191,7 +212,7 @@ func hintForAPICode(code string) string {
191212
return "Check your credit balance: heygen user me get. Purchase API credits: " + APIKeySettingsURL
192213
case "invalid_parameter":
193214
return "Use --request-schema on the command to see expected fields"
194-
case "rate_limited":
215+
case "rate_limit_exceeded", "quota_exceeded":
195216
return "The CLI retries rate-limited requests automatically. If this persists, reduce request frequency"
196217
case "resource_not_found", "not_found":
197218
return "The requested resource does not exist. Retrying the same ID is unlikely to help"

internal/errors/errors_test.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func TestFromAPIError_ExitCodes(t *testing.T) {
9595
{"404 → not_found", 404, ExitGeneral, "not_found"},
9696
{"409 → conflict", 409, ExitGeneral, "conflict"},
9797
{"413 → payload_too_large", 413, ExitGeneral, "payload_too_large"},
98-
{"429 → rate_limited", 429, ExitGeneral, "rate_limited"},
98+
{"429 → rate_limit_exceeded", 429, ExitGeneral, "rate_limit_exceeded"},
9999
{"500 → unclassified_server_error", 500, ExitGeneral, "unclassified_server_error"},
100100
{"502 → unclassified_server_error", 502, ExitGeneral, "unclassified_server_error"},
101101
{"503 → unclassified_server_error", 503, ExitGeneral, "unclassified_server_error"},
@@ -174,7 +174,8 @@ func TestHintForAPICode_AllMappedCodes(t *testing.T) {
174174
{"voice_not_found", "heygen voice list"},
175175
{"insufficient_credit", "heygen user me get"},
176176
{"invalid_parameter", "--request-schema"},
177-
{"rate_limited", "retries rate-limited"},
177+
{"rate_limit_exceeded", "retries rate-limited"},
178+
{"quota_exceeded", "retries rate-limited"},
178179
{"resource_not_found", "does not exist"},
179180
{"not_found", "does not exist"},
180181
{"asset_not_available", "may still be processing"},
@@ -329,3 +330,35 @@ func TestConstructors(t *testing.T) {
329330
}
330331
})
331332
}
333+
334+
func TestFromAPIError_SurfacesDocURLAndParam(t *testing.T) {
335+
param := "avatar_id"
336+
docURL := "https://developers.heygen.com/docs/error-codes#invalid-parameter"
337+
apiErr := &APIError{Code: "invalid_parameter", Message: "bad value", Param: &param, DocURL: &docURL}
338+
cliErr := FromAPIError(400, apiErr, "req_1")
339+
340+
if cliErr.Param != "avatar_id" {
341+
t.Errorf("Param = %q, want avatar_id", cliErr.Param)
342+
}
343+
if cliErr.DocURL != docURL {
344+
t.Errorf("DocURL = %q, want %q", cliErr.DocURL, docURL)
345+
}
346+
347+
inner := cliErr.ToErrorEnvelope()["error"].(map[string]any)
348+
if inner["param"] != "avatar_id" {
349+
t.Errorf("envelope param = %v, want avatar_id", inner["param"])
350+
}
351+
if inner["doc_url"] != docURL {
352+
t.Errorf("envelope doc_url = %v, want %q", inner["doc_url"], docURL)
353+
}
354+
}
355+
356+
func TestToErrorEnvelope_OmitsDocURLAndParamWhenEmpty(t *testing.T) {
357+
inner := (&CLIError{Code: "network_error", Message: "x"}).ToErrorEnvelope()["error"].(map[string]any)
358+
if _, ok := inner["param"]; ok {
359+
t.Error("param should be omitted when empty")
360+
}
361+
if _, ok := inner["doc_url"]; ok {
362+
t.Error("doc_url should be omitted when empty")
363+
}
364+
}

0 commit comments

Comments
 (0)