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
6 changes: 6 additions & 0 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ func runAuthLogin(opts *AuthLoginOptions) error {
Hint: "Check your email and API key at Profile > API Key in DeployHQ",
}
}
if sdk.IsForbidden(err) {
return &output.AuthError{
Message: "Access denied",
Hint: "Your account may have API access restricted, or your user may not be a member of this account. Check Account Settings > API.",
}
}
if output.IsNetworkErr(err) {
return &output.NetworkError{Message: "validate credentials", Cause: err}
}
Expand Down
33 changes: 32 additions & 1 deletion internal/output/breadcrumbs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package output

import "strings"
import (
"errors"
"net/http"
"strings"

"github.com/deployhq/deployhq-cli/pkg/sdk"
)

// Breadcrumb represents a suggested next action.
type Breadcrumb struct {
Expand Down Expand Up @@ -98,6 +104,31 @@ func ErrorResponseFromErr(err error) *Response {
case *InternalError:
code = "internal_error"
message = e.Message
case *NetworkError:
code = "network_error"
message = e.Message
}

// Mirror the status-code mapping from ClassifyError so a raw *sdk.APIError
// returned without wrapping produces a `code` field consistent with the
// exit_code agents already follow.
if code == "error" {
var apiErr *sdk.APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.StatusCode == http.StatusUnauthorized,
apiErr.StatusCode == http.StatusForbidden:
code = "auth_error"
case apiErr.StatusCode == http.StatusNotFound:
code = "not_found"
case apiErr.StatusCode == http.StatusConflict:
code = "conflict"
case apiErr.StatusCode >= 400 && apiErr.StatusCode < 500:
code = "user_error"
case apiErr.StatusCode >= 500:
code = "internal_error"
}
}
}

// Enrich errors with actionable suggestions when no hint is set
Expand Down
33 changes: 33 additions & 0 deletions internal/output/breadcrumbs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package output

import (
"encoding/json"
"net/http"
"testing"

"github.com/deployhq/deployhq-cli/pkg/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -116,6 +118,37 @@ func TestErrorResponseFromErr_Retryable(t *testing.T) {
assert.True(t, data.Retryable)
}

func TestErrorResponseFromErr_APIErrorByStatus(t *testing.T) {
cases := []struct {
status int
wantCode string
wantExit int
}{
{401, "auth_error", ExitAuthError},
{403, "auth_error", ExitAuthError},
{404, "not_found", ExitNotFoundError},
{409, "conflict", ExitConflictError},
{422, "user_error", ExitUserError},
{500, "internal_error", ExitInternalError},
{503, "internal_error", ExitInternalError},
}
for _, tc := range cases {
t.Run(http.StatusText(tc.status), func(t *testing.T) {
err := &sdk.APIError{StatusCode: tc.status, Message: "x"}
data := ErrorResponseFromErr(err).Data.(ErrorData)
assert.Equal(t, tc.wantCode, data.Code, "code mismatch")
assert.Equal(t, tc.wantExit, data.ExitCode, "exit_code mismatch")
})
}
}

func TestErrorResponseFromErr_NetworkError(t *testing.T) {
err := &NetworkError{Message: "validate credentials"}
data := ErrorResponseFromErr(err).Data.(ErrorData)
assert.Equal(t, "network_error", data.Code)
assert.Equal(t, ExitNetworkError, data.ExitCode)
}

func TestErrorData_JSONShape(t *testing.T) {
resp := ErrorResponseFromErr(&UserError{Message: "test error"})

Expand Down
24 changes: 24 additions & 0 deletions internal/output/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"syscall"

"github.com/deployhq/deployhq-cli/pkg/sdk"
)

// ExitCode constants for error classification.
Expand Down Expand Up @@ -121,6 +124,11 @@ func IsNetworkErr(err error) bool {
}

// ClassifyError returns the appropriate exit code for an error.
//
// Precedence: typed errors (UserError, InternalError, etc.) the command code
// chose explicitly always win. Raw *sdk.APIError values returned without
// wrapping fall back to a status-code mapping so 4xx doesn't get reported as
// an internal CLI bug.
func ClassifyError(err error) int {
if err == nil {
return ExitOK
Expand All @@ -135,6 +143,22 @@ func ClassifyError(err error) int {
case *NetworkError:
return ExitNetworkError
}
var apiErr *sdk.APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.StatusCode == http.StatusUnauthorized,
apiErr.StatusCode == http.StatusForbidden:
return ExitAuthError
case apiErr.StatusCode == http.StatusNotFound:
return ExitNotFoundError
case apiErr.StatusCode == http.StatusConflict:
return ExitConflictError
case apiErr.StatusCode >= 400 && apiErr.StatusCode < 500:
return ExitUserError
Comment on lines +147 to +157
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return matching JSON error codes for API errors

When a command returns a raw *sdk.APIError in --json/piped mode, main builds the envelope with ErrorResponseFromErr, which only sets Data.Code for *UserError, *AuthError, and *InternalError and otherwise leaves it as the default "error" while taking exit_code from ClassifyError. This new status-code branch therefore produces inconsistent JSON such as code: "error", exit_code: 3 for 401/403 or code: "error", exit_code: 1 for 4xx API failures, so agents that follow the documented auth_error/user_error codes will miss these newly reclassified cases; please mirror the API status mapping in the JSON error response as well.

Useful? React with 👍 / 👎.

case apiErr.StatusCode >= 500:
return ExitInternalError
}
}
if IsNetworkErr(err) {
return ExitNetworkError
}
Expand Down
31 changes: 31 additions & 0 deletions internal/output/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package output

import (
"errors"
"fmt"
"net"
"net/url"
"syscall"
"testing"
"time"

"github.com/deployhq/deployhq-cli/pkg/sdk"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -132,6 +134,35 @@ func TestClassifyError_DetectsURLNetworkError(t *testing.T) {
assert.Equal(t, ExitNetworkError, ClassifyError(urlErr))
}

func TestClassifyError_APIErrorByStatus(t *testing.T) {
cases := []struct {
status int
want int
name string
}{
{401, ExitAuthError, "401 unauthorized"},
{403, ExitAuthError, "403 forbidden"},
{404, ExitNotFoundError, "404 not found uses dedicated exit code"},
{409, ExitConflictError, "409 conflict uses dedicated exit code"},
{422, ExitUserError, "422 validation is bad input"},
{500, ExitInternalError, "500 server is internal"},
{503, ExitInternalError, "503 unavailable is internal"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := &sdk.APIError{StatusCode: tc.status, Message: "x"}
assert.Equal(t, tc.want, ClassifyError(err))
})
}
}

func TestClassifyError_APIErrorWrapped(t *testing.T) {
// errors.As should unwrap through a wrapping error to find the APIError.
apiErr := &sdk.APIError{StatusCode: 404, Message: "not found"}
wrapped := fmt.Errorf("fetch project: %w", apiErr)
assert.Equal(t, ExitNotFoundError, ClassifyError(wrapped))
}

// Sanity check: a deadline-style error wrapped in net.Error.Timeout() classifies
// even when accessed through deadline-exceeded chains used by net/http.
func TestIsNetworkErr_DeadlineExceededViaNetError(t *testing.T) {
Expand Down
8 changes: 8 additions & 0 deletions pkg/sdk/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,11 @@ func IsUnauthorized(err error) bool {
}
return false
}

// IsForbidden checks whether err is an APIError with status 403.
func IsForbidden(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.IsForbidden()
}
return false
}
Loading