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
3 changes: 3 additions & 0 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ func runAuthLogin(opts *AuthLoginOptions) error {
Hint: "Check your email and API key at Profile > API Key in DeployHQ",
}
}
if output.IsNetworkErr(err) {
return &output.NetworkError{Message: "validate credentials", Cause: err}
Comment on lines +134 to +135
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 Map network failures to network_error JSON code

Returning &output.NetworkError{...} here causes ErrorResponseFromErr to emit code: "error" in JSON mode because that switch only handles UserError, AuthError, and InternalError (see internal/output/breadcrumbs.go, ErrorResponseFromErr). This regresses machine-readable semantics for these login network failures: clients no longer get the documented network_error code and retryable may be false for non-timeout transport errors like connection refused.

Useful? React with 👍 / 👎.

}
return &output.InternalError{Message: "validate credentials", Cause: err}
}

Expand Down
3 changes: 3 additions & 0 deletions internal/commands/hello.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ func helloLogin(env *output.Envelope, reader *bufio.Reader) (*auth.Credentials,
Hint: "Check your email and API key at Profile > API Key in DeployHQ",
}
}
if output.IsNetworkErr(err) {
return nil, &output.NetworkError{Message: "validate credentials", Cause: err}
}
return nil, &output.InternalError{Message: "validate credentials", Cause: err}
}

Expand Down
67 changes: 64 additions & 3 deletions internal/output/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
// - errors are classified for appropriate exit codes
package output

import "fmt"
import (
"errors"
"fmt"
"net"
"net/url"
"syscall"
)

// ExitCode constants for error classification.
const (
Expand Down Expand Up @@ -63,6 +69,57 @@ func (e *AuthError) Error() string {
return e.Message
}

// NetworkError represents a connectivity failure (timeout, DNS, refused
// connection, unreachable host). These produce exit code 4 so wrappers can
// distinguish "the network is broken" from "the CLI itself is broken".
type NetworkError struct {
Message string
Cause error
}

func (e *NetworkError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return e.Message
}

func (e *NetworkError) Unwrap() error { return e.Cause }

// IsNetworkErr reports whether err (or any error it wraps) is a connectivity
// failure originating from the network/transport layer. It returns false for
// successful HTTP exchanges that returned non-2xx status (those should be
// classified by status code, not bucketed as network).
func IsNetworkErr(err error) bool {
if err == nil {
return false
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return true
}
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
return IsNetworkErr(urlErr.Err)
}
if errors.Is(err, syscall.ECONNREFUSED) ||
errors.Is(err, syscall.ECONNRESET) ||
errors.Is(err, syscall.EHOSTUNREACH) ||
errors.Is(err, syscall.ENETUNREACH) ||
errors.Is(err, syscall.EPIPE) {
return true
}
return false
}

// ClassifyError returns the appropriate exit code for an error.
func ClassifyError(err error) int {
if err == nil {
Expand All @@ -75,7 +132,11 @@ func ClassifyError(err error) int {
return ExitInternalError
case *AuthError:
return ExitAuthError
default:
return ExitInternalError
case *NetworkError:
return ExitNetworkError
}
if IsNetworkErr(err) {
return ExitNetworkError
}
return ExitInternalError
}
104 changes: 104 additions & 0 deletions internal/output/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package output

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

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -42,3 +46,103 @@ func TestClassifyError_Nil(t *testing.T) {
func TestClassifyError_GenericError(t *testing.T) {
assert.Equal(t, ExitInternalError, ClassifyError(errors.New("unknown")))
}

func TestNetworkError(t *testing.T) {
cause := errors.New("dial tcp: i/o timeout")
err := &NetworkError{Message: "validate credentials", Cause: cause}
assert.Contains(t, err.Error(), "validate credentials")
assert.Contains(t, err.Error(), "dial tcp")
assert.Equal(t, ExitNetworkError, ClassifyError(err))
assert.Equal(t, cause, errors.Unwrap(err))
}

func TestIsNetworkErr_Nil(t *testing.T) {
assert.False(t, IsNetworkErr(nil))
}

func TestIsNetworkErr_GenericError(t *testing.T) {
assert.False(t, IsNetworkErr(errors.New("not a network thing")))
}

func TestIsNetworkErr_Syscalls(t *testing.T) {
cases := []syscall.Errno{
syscall.ECONNREFUSED,
syscall.ECONNRESET,
syscall.EHOSTUNREACH,
syscall.ENETUNREACH,
syscall.EPIPE,
}
for _, e := range cases {
assert.True(t, IsNetworkErr(e), "expected syscall errno %v to classify as network", e)
}
}

func TestIsNetworkErr_DNSError(t *testing.T) {
dnsErr := &net.DNSError{Err: "no such host", Name: "api.example.invalid"}
assert.True(t, IsNetworkErr(dnsErr))
}

func TestIsNetworkErr_OpError(t *testing.T) {
opErr := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}
assert.True(t, IsNetworkErr(opErr))
}

// timeoutErr satisfies net.Error with Timeout() = true.
type timeoutErr struct{}

func (timeoutErr) Error() string { return "i/o timeout" }
func (timeoutErr) Timeout() bool { return true }
func (timeoutErr) Temporary() bool { return true }

func TestIsNetworkErr_TimeoutNetError(t *testing.T) {
assert.True(t, IsNetworkErr(timeoutErr{}))
}

func TestIsNetworkErr_URLErrorWraps(t *testing.T) {
urlErr := &url.Error{
Op: "Get",
URL: "https://api.example.com",
Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
}
assert.True(t, IsNetworkErr(urlErr))
}

func TestIsNetworkErr_URLErrorWithNonNetworkCause(t *testing.T) {
urlErr := &url.Error{
Op: "Get",
URL: "https://api.example.com",
Err: errors.New("malformed body"),
}
assert.False(t, IsNetworkErr(urlErr))
}

func TestClassifyError_DetectsRawNetworkError(t *testing.T) {
// An untyped network error returned from the SDK should classify as ExitNetworkError,
// not ExitInternalError, even when no command wraps it.
err := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}
assert.Equal(t, ExitNetworkError, ClassifyError(err))
}

func TestClassifyError_DetectsURLNetworkError(t *testing.T) {
urlErr := &url.Error{
Op: "Get",
URL: "https://api.example.com",
Err: timeoutErr{},
}
assert.Equal(t, ExitNetworkError, ClassifyError(urlErr))
}

// 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) {
deadline := &net.OpError{
Op: "read",
Net: "tcp",
Source: nil,
Addr: nil,
Err: timeoutErr{},
}
assert.True(t, IsNetworkErr(deadline))
// And confirm it doesn't depend on the elapsed time
_ = time.Second
}
Loading