Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/client/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ func (c *Client) executeWithContext(ctx context.Context, spec *command.Spec, inv
return nil, &clierrors.CLIError{
Code: "network_error",
Message: fmt.Sprintf("request failed: %v", err),
Hint: "This is usually a temporary network issue. If it persists, check your connection",
ExitCode: clierrors.ExitGeneral,
}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/client/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ func TestExecute_NetworkError(t *testing.T) {
if cliErr.Code != "network_error" {
t.Errorf("Code = %q, want %q", cliErr.Code, "network_error")
}
if cliErr.Hint == "" {
t.Error("Hint should be non-empty for network errors")
}
}

func TestExecute_RetryOn429(t *testing.T) {
Expand Down
23 changes: 19 additions & 4 deletions internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ func FromAPIError(statusCode int, apiErr *APIError, requestID string) *CLIError
}
}

hint := hintForAPICode(code)
hint := hintForAPICode(apiErr.Code)
if apiErr.Code == "invalid_parameter" && apiErr.Param != nil && *apiErr.Param != "" {
hint = fmt.Sprintf("Invalid field %q. %s", *apiErr.Param, hint)
}

return &CLIError{
Code: code,
Expand All @@ -122,11 +125,23 @@ func FromAPIError(statusCode int, apiErr *APIError, requestID string) *CLIError
func hintForAPICode(code string) string {
switch code {
case "avatar_not_found":
return "List available avatars: heygen avatar list"
return "This avatar does not exist. Retrying the same ID is unlikely to help. List avatars: heygen avatar list"
case "video_not_found":
return "List your videos: heygen video list"
return "This resource does not exist. Retrying the same ID is unlikely to help. List your videos: heygen video list"
case "voice_not_found":
return "List available voices: heygen voice list"
return "This voice does not exist. Retrying the same ID is unlikely to help. List voices: heygen voice list"
case "insufficient_credit":
return "Check your credit balance: heygen user me get"
case "invalid_parameter":
return "Use --request-schema on the command to see expected fields"
case "rate_limited":
return "The CLI retries rate-limited requests automatically. If this persists, reduce request frequency"
case "resource_not_found", "not_found":
return "The requested resource does not exist. Retrying the same ID is unlikely to help"
case "asset_not_available":
return "The asset may still be processing or was deleted"
case "timeout":
return "The operation may still be in progress. Check status with the corresponding get command"
}
return ""
}
63 changes: 63 additions & 0 deletions internal/errors/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,69 @@ func TestFromAPIError_UnknownCode_NoHint(t *testing.T) {
}
}

func TestHintForAPICode_AllMappedCodes(t *testing.T) {
codes := []struct {
code string
wantContains string
}{
{"avatar_not_found", "heygen avatar list"},
{"video_not_found", "heygen video list"},
{"voice_not_found", "heygen voice list"},
{"insufficient_credit", "heygen user me get"},
{"invalid_parameter", "--request-schema"},
{"rate_limited", "retries rate-limited"},
{"resource_not_found", "does not exist"},
{"not_found", "does not exist"},
{"asset_not_available", "may still be processing"},
{"timeout", "may still be in progress"},
}
for _, tt := range codes {
t.Run(tt.code, func(t *testing.T) {
hint := hintForAPICode(tt.code)
if hint == "" {
t.Fatalf("hintForAPICode(%q) returned empty", tt.code)
}
if !strings.Contains(hint, tt.wantContains) {
t.Errorf("hint = %q, want it to contain %q", hint, tt.wantContains)
}
})
}
}

func TestFromAPIError_Generic404_NoPermanenceHint(t *testing.T) {
apiErr := &APIError{Message: "not found"}
cliErr := FromAPIError(404, apiErr, "")

if strings.Contains(cliErr.Hint, "unlikely to help") {
t.Errorf("Hint = %q, generic 404 without API code should not claim permanence", cliErr.Hint)
}
}

func TestFromAPIError_InvalidParam_WithParamName(t *testing.T) {
param := "avatar_id"
apiErr := &APIError{Code: "invalid_parameter", Message: "bad value", Param: &param}
cliErr := FromAPIError(400, apiErr, "")

if !strings.Contains(cliErr.Hint, "avatar_id") {
t.Errorf("Hint = %q, want param name in hint", cliErr.Hint)
}
if !strings.Contains(cliErr.Hint, "--request-schema") {
t.Errorf("Hint = %q, want --request-schema in hint", cliErr.Hint)
}
}

func TestFromAPIError_InvalidParam_NilParam(t *testing.T) {
apiErr := &APIError{Code: "invalid_parameter", Message: "bad value"}
cliErr := FromAPIError(400, apiErr, "")

if strings.Contains(cliErr.Hint, "Invalid field") {
t.Errorf("Hint = %q, should not mention field when param is nil", cliErr.Hint)
}
if !strings.Contains(cliErr.Hint, "--request-schema") {
t.Errorf("Hint = %q, want --request-schema in hint", cliErr.Hint)
}
}

func TestConstructors(t *testing.T) {
t.Run("New", func(t *testing.T) {
err := New("something broke")
Expand Down
Loading