diff --git a/internal/client/executor.go b/internal/client/executor.go index ac6f6ea..27aa25b 100644 --- a/internal/client/executor.go +++ b/internal/client/executor.go @@ -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, } } diff --git a/internal/client/executor_test.go b/internal/client/executor_test.go index 28deb3d..1b51e51 100644 --- a/internal/client/executor_test.go +++ b/internal/client/executor_test.go @@ -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) { diff --git a/internal/errors/errors.go b/internal/errors/errors.go index c0c5c17..a81e6ea 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -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, @@ -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 "" } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 55ae1e5..a04810f 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -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: ¶m} + 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")