From 958a3938796723a65cac6a77903bb313f5d93cdc Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 19 Feb 2026 15:11:18 -0500 Subject: [PATCH] improve error classification for unknown and network errors (#6796) Add explicit classifiers in MapError for: - context.Canceled as user.canceled (was internal.errors_errorString) - context.DeadlineExceeded as internal.timeout (was internal.errors_errorString) - Network errors (DNS, TLS, connection, EOF) as internal.network This reduces the unknown/internal error bucket by classifying common error patterns that previously fell through to the generic fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/cmd/errors.go | 45 +++++++++++ cli/azd/internal/cmd/errors_test.go | 114 ++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index 1c7c2472b03..1b5f20b1238 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -4,10 +4,14 @@ package cmd import ( + "context" + "crypto/tls" "encoding/json" "errors" "fmt" + "io" "log" + "net" "path/filepath" "reflect" "strings" @@ -163,6 +167,14 @@ func MapError(err error, span tracing.Span) { errCode = "service.aad.failed" } else if errors.Is(err, terminal.InterruptErr) { errCode = "user.canceled" + } else if errors.Is(err, context.Canceled) { + errCode = "user.canceled" + } else if errors.Is(err, context.DeadlineExceeded) { + errCode = "internal.timeout" + } else if isNetworkError(err) { + errCode = "internal.network" + errType := errorType(err) + span.SetAttributes(fields.ErrType.String(errType)) } else { errType := errorType(err) span.SetAttributes(fields.ErrType.String(errType)) @@ -259,6 +271,39 @@ func mapService(host string) (service string, hostDomain string) { return "other", "other" } +// isNetworkError returns true if the error is a network-related error such as +// DNS resolution failure, connection refused, TLS handshake failure, or connection reset. +func isNetworkError(err error) bool { + if err == nil { + return false + } + + // Check for DNS errors + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true + } + + // Check for network operation errors (connection refused, timeout, etc.) + var opErr *net.OpError + if errors.As(err, &opErr) { + return true + } + + // Check for TLS errors + var tlsRecordErr *tls.RecordHeaderError + if errors.As(err, &tlsRecordErr) { + return true + } + + // Check for EOF (connection closed unexpectedly) + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + + return false +} + func cmdAsName(cmd string) string { cmd = filepath.Base(cmd) if len(cmd) > 0 && cmd[0] == '.' { // hidden file, simply ignore the first period diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index 70866dca8d2..95438bf7075 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -4,8 +4,13 @@ package cmd import ( + "context" + "crypto/tls" "encoding/json" "errors" + "fmt" + "io" + "net" "net/http" "testing" @@ -198,6 +203,51 @@ func Test_MapError(t *testing.T) { fields.ErrorKey(fields.ServiceErrorCode.Key).String("RateLimitExceeded"), }, }, + { + name: "WithContextCanceled", + err: context.Canceled, + wantErrReason: "user.canceled", + wantErrDetails: nil, + }, + { + name: "WithContextDeadlineExceeded", + err: context.DeadlineExceeded, + wantErrReason: "internal.timeout", + wantErrDetails: nil, + }, + { + name: "WithDNSError", + err: &net.DNSError{ + Err: "no such host", + Name: "management.azure.com", + }, + wantErrReason: "internal.network", + wantErrDetails: []attribute.KeyValue{ + fields.ErrType.String("*net.DNSError"), + }, + }, + { + name: "WithWrappedContextCanceled", + err: fmt.Errorf("operation failed: %w", context.Canceled), + wantErrReason: "user.canceled", + wantErrDetails: nil, + }, + { + name: "WithEOFError", + err: io.EOF, + wantErrReason: "internal.network", + wantErrDetails: []attribute.KeyValue{ + fields.ErrType.String("*errors.errorString"), + }, + }, + { + name: "WithUnexpectedEOFError", + err: io.ErrUnexpectedEOF, + wantErrReason: "internal.network", + wantErrDetails: []attribute.KeyValue{ + fields.ErrType.String("*errors.errorString"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -336,3 +386,67 @@ func mustMarshalJson(v interface{}) string { } return string(b) } + +func Test_isNetworkError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "NilError", + err: nil, + want: false, + }, + { + name: "PlainError", + err: errors.New("something broke"), + want: false, + }, + { + name: "DNSError", + err: &net.DNSError{Err: "no such host", Name: "example.com"}, + want: true, + }, + { + name: "WrappedDNSError", + err: fmt.Errorf("request failed: %w", &net.DNSError{Err: "no such host", Name: "example.com"}), + want: true, + }, + { + name: "EOF", + err: io.EOF, + want: true, + }, + { + name: "UnexpectedEOF", + err: io.ErrUnexpectedEOF, + want: true, + }, + { + name: "WrappedEOF", + err: fmt.Errorf("reading response: %w", io.EOF), + want: true, + }, + { + name: "ContextCanceled", + err: context.Canceled, + want: false, + }, + { + name: "NetOpError", + err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}, + want: true, + }, + { + name: "TLSRecordHeaderError", + err: &tls.RecordHeaderError{Msg: "bad record"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isNetworkError(tt.err)) + }) + } +}