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
151 changes: 120 additions & 31 deletions cli/azd/internal/cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,7 @@ func MapError(err error, span tracing.Span) {
errCode = "auth.login_required"
} else if errWithSuggestion, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok {
errCode = "error.suggestion"
inner := errWithSuggestion.Unwrap()
if code := classifySentinel(inner); code != "" {
span.SetAttributes(fields.ErrType.String(code))
} else {
span.SetAttributes(
fields.ErrType.String(errorType(inner)))
}
span.SetAttributes(fields.ErrType.String(classifySuggestionType(errWithSuggestion.Unwrap())))
} else if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok {
serviceName := "other"
statusCode := -1
Expand Down Expand Up @@ -190,30 +184,8 @@ func MapError(err error, span tracing.Span) {
fields.ServiceCorrelationId.String(authFailedErr.Parsed.CorrelationId))
}
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 errors.Is(err, auth.ErrNoCurrentUser) {
errCode = "auth.not_logged_in"
} else if errors.Is(err, consent.ErrToolExecutionDenied) {
errCode = "user.tool_denied"
} else if errors.Is(err, git.ErrNotRepository) {
errCode = "internal.not_git_repo"
} else if errors.Is(err, azapi.ErrPreviewNotSupported) {
errCode = "internal.preview_not_supported"
} else if errors.Is(err, provisioning.ErrBindMountOperationDisabled) {
errCode = "internal.bind_mount_disabled"
} else if errors.Is(err, update.ErrNeedsElevation) {
errCode = "update.elevationRequired"
} else if errors.Is(err, pipeline.ErrRemoteHostIsNotAzDo) {
errCode = "internal.remote_not_azdo"
} else if errors.Is(err, internal.ErrExtensionNotFound) {
errCode = "internal.extension_not_found"
} else if errors.Is(err, internal.ErrExtensionTokenFailed) {
errCode = "internal.extension_error"
} else if code := classifySentinel(err); code != "" {
errCode = code
} else if isNetworkError(err) {
errCode = "internal.network"
errType := errorType(err)
Expand Down Expand Up @@ -291,11 +263,128 @@ func classifySentinel(err error) string {
return "internal.resource_not_found"
case errors.Is(err, internal.ErrOperationCancelled):
return "internal.operation_cancelled"
case errors.Is(err, terminal.InterruptErr),
errors.Is(err, context.Canceled):
return "user.canceled"
case errors.Is(err, context.DeadlineExceeded):
return "internal.timeout"
case errors.Is(err, auth.ErrNoCurrentUser):
return "auth.not_logged_in"
case errors.Is(err, consent.ErrToolExecutionDenied):
return "user.tool_denied"
case errors.Is(err, git.ErrNotRepository):
return "internal.not_git_repo"
case errors.Is(err, azapi.ErrPreviewNotSupported):
return "internal.preview_not_supported"
case errors.Is(err, provisioning.ErrBindMountOperationDisabled):
return "internal.bind_mount_disabled"
case errors.Is(err, update.ErrNeedsElevation):
return "update.elevationRequired"
case errors.Is(err, pipeline.ErrRemoteHostIsNotAzDo):
return "internal.remote_not_azdo"
default:
return ""
}
}

// classifySuggestionType returns a telemetry error type string for an inner error wrapped by ErrorWithSuggestion.
// It preserves the suggestion result code while improving the error.type attribute when the inner error is structured.
//
// The check order here should match MapError to ensure consistent classification.
// Structured error types are checked first, then sentinels, then network errors, then fallback.
func classifySuggestionType(err error) string {
if updateErr, ok := errors.AsType[*update.UpdateError](err); ok {
return updateErr.Code
}

if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok {
return "auth.login_required"
}

if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok {
serviceName := "other"
statusCode := -1

if respErr.RawResponse != nil {
statusCode = respErr.RawResponse.StatusCode
if respErr.RawResponse.Request != nil {
serviceName, _ = mapService(respErr.RawResponse.Request.Host)
}
}

return fmt.Sprintf("service.%s.%d", serviceName, statusCode)
}

if armDeployErr, ok := errors.AsType[*azapi.AzureDeploymentError](err); ok {
operationName := armDeployErr.Operation
if operationName == azapi.DeploymentOperationDeploy {
operationName = "deployment"
}

return fmt.Sprintf("service.arm.%s.failed", operationName)
}

if extServiceErr, ok := errors.AsType[*azdext.ServiceError](err); ok {
serviceName := ""
if extServiceErr.ServiceName != "" {
serviceName, _ = mapService(extServiceErr.ServiceName)
}

switch {
case extServiceErr.ErrorCode != "":
return fmt.Sprintf("ext.service.%s", normalizeCodeSegment(extServiceErr.ErrorCode, "failed"))
case extServiceErr.StatusCode > 0 && serviceName != "":
return fmt.Sprintf("ext.service.%s.%d", serviceName, extServiceErr.StatusCode)
case extServiceErr.StatusCode > 0:
return fmt.Sprintf("ext.service.unknown.%d", extServiceErr.StatusCode)
default:
return "ext.service.unknown.failed"
}
}

if extLocalErr, ok := errors.AsType[*azdext.LocalError](err); ok {
domain := string(azdext.NormalizeLocalErrorCategory(extLocalErr.Category))
code := normalizeCodeSegment(extLocalErr.Code, "failed")

return fmt.Sprintf("ext.%s.%s", domain, code)
}

if _, ok := errors.AsType[*extensions.ExtensionRunError](err); ok {
return "ext.run.failed"
}

if toolExecErr, ok := errors.AsType[*exec.ExitError](err); ok {
toolName := "other"
if cmdName := cmdAsName(toolExecErr.Cmd); cmdName != "" {
toolName = cmdName
}

return fmt.Sprintf("tool.%s.failed", toolName)
}

if toolCheckErr, ok := errors.AsType[*tools.MissingToolErrors](err); ok {
if len(toolCheckErr.ToolNames) == 1 {
return fmt.Sprintf("tool.%s.missing", toolCheckErr.ToolNames[0])
}

return "tool.multiple.missing"
}

if _, ok := errors.AsType[*auth.AuthFailedError](err); ok {
return "service.aad.failed"
}

if code := classifySentinel(err); code != "" {
return code
}

if isNetworkError(err) {
return "internal.network"
}

return errorType(err)
}

// errorType returns the type name of the given error, unwrapping as needed to find the root cause(s).
func errorType(err error) string {
if err == nil {
Expand Down
159 changes: 156 additions & 3 deletions cli/azd/internal/cmd/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/pipeline"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/git"
"github.com/azure/azure-dev/cli/azd/test/mocks/mocktracing"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -403,7 +405,26 @@ func Test_MapError(t *testing.T) {
},
wantErrReason: "error.suggestion",
wantErrDetails: []attribute.KeyValue{
fields.ErrType.String("*exported.ResponseError"),
fields.ErrType.String("service.arm.429"),
},
},
{
name: "WithSuggestionWrappingArmDeploymentError",
err: &internal.ErrorWithSuggestion{
Err: &azapi.AzureDeploymentError{
Operation: azapi.DeploymentOperationDeploy,
Details: &azapi.DeploymentErrorLine{
Code: "Conflict",
Inner: []*azapi.DeploymentErrorLine{
{Code: "OutOfCapacity"},
},
},
},
Suggestion: "Retry in another region.",
},
wantErrReason: "error.suggestion",
wantErrDetails: []attribute.KeyValue{
fields.ErrType.String("service.arm.deployment.failed"),
},
},
{
Expand Down Expand Up @@ -815,7 +836,7 @@ func TestMapError_ErrorWithSuggestionSetsErrorType(t *testing.T) {
wantErrType: "internal.key_not_found",
},
{
name: "ResponseError_falls_back_to_go_type",
name: "ResponseErrorUsesStructuredCategory",
err: &internal.ErrorWithSuggestion{
Err: &azcore.ResponseError{
ErrorCode: "QuotaExceeded",
Expand All @@ -831,7 +852,24 @@ func TestMapError_ErrorWithSuggestionSetsErrorType(t *testing.T) {
Suggestion: "Request a quota increase.",
},
wantErrCode: "error.suggestion",
wantErrType: "*exported.ResponseError",
wantErrType: "service.arm.429",
},
{
name: "ArmDeploymentErrorUsesStructuredCategory",
err: &internal.ErrorWithSuggestion{
Err: &azapi.AzureDeploymentError{
Operation: azapi.DeploymentOperationDeploy,
Details: &azapi.DeploymentErrorLine{
Code: "Conflict",
Inner: []*azapi.DeploymentErrorLine{
{Code: "OutOfCapacity"},
},
},
},
Suggestion: "Retry in another region.",
},
wantErrCode: "error.suggestion",
wantErrType: "service.arm.deployment.failed",
},
{
name: "PlainError_falls_back_to_go_type",
Expand Down Expand Up @@ -859,6 +897,121 @@ func TestMapError_ErrorWithSuggestionSetsErrorType(t *testing.T) {
}
}

// Test_ClassifySuggestionType_MatchesMapError verifies that classifySuggestionType produces
// the same errCode as MapError for every structured error type and sentinel.
// This catches drift if someone updates the classification logic in one function but not the other.
func Test_ClassifySuggestionType_MatchesMapError(t *testing.T) {
tests := []struct {
name string
err error
}{
{
name: "ResponseError",
err: &azcore.ResponseError{
ErrorCode: "QuotaExceeded",
StatusCode: 429,
RawResponse: &http.Response{
StatusCode: 429,
Request: &http.Request{
Method: "POST",
Host: "management.azure.com",
},
},
},
},
{
name: "ArmDeploymentError",
err: &azapi.AzureDeploymentError{
Operation: azapi.DeploymentOperationDeploy,
Details: &azapi.DeploymentErrorLine{
Code: "Conflict",
},
},
},
{
name: "ArmValidationError",
err: &azapi.AzureDeploymentError{
Operation: azapi.DeploymentOperationValidate,
Details: &azapi.DeploymentErrorLine{Code: "InvalidTemplate"},
},
},
{
name: "ExtServiceError",
err: &azdext.ServiceError{
Message: "Rate limit",
ErrorCode: "create_agent.RateLimitExceeded",
StatusCode: 429,
ServiceName: "openai.azure.com",
},
},
{
name: "ExtLocalError",
err: &azdext.LocalError{
Message: "invalid config",
Code: "Invalid-Config",
Category: azdext.LocalErrorCategoryValidation,
},
},
{
name: "ExtensionRunError",
err: &extensions.ExtensionRunError{ExtensionId: "test", Err: errors.New("fail")},
},
{
name: "ExitError",
err: &exec.ExitError{Cmd: "docker", ExitCode: 1},
},
{
name: "MissingToolErrors_single",
err: &tools.MissingToolErrors{ToolNames: []string{"docker"}},
},
{
name: "MissingToolErrors_multiple",
err: &tools.MissingToolErrors{ToolNames: []string{"docker", "kubectl"}},
},
{
name: "AuthFailedError",
err: &auth.AuthFailedError{
Parsed: &auth.AadErrorResponse{Error: "invalid_grant"},
},
},
// Sentinels
{name: "context.Canceled", err: context.Canceled},
{name: "context.DeadlineExceeded", err: context.DeadlineExceeded},
{name: "ErrNoCurrentUser", err: auth.ErrNoCurrentUser},
{name: "ErrNoProject", err: azdcontext.ErrNoProject},
{name: "ErrNotFound", err: environment.ErrNotFound},
{name: "ErrToolExecutionDenied", err: consent.ErrToolExecutionDenied},
{name: "ErrNotRepository", err: git.ErrNotRepository},
{name: "ErrPreviewNotSupported", err: azapi.ErrPreviewNotSupported},
{name: "ErrBindMountDisabled", err: provisioning.ErrBindMountOperationDisabled},
{name: "ErrRemoteHostIsNotAzDo", err: pipeline.ErrRemoteHostIsNotAzDo},
{name: "ErrInfraNotProvisioned", err: internal.ErrInfraNotProvisioned},
{name: "ErrKeyNotFound", err: internal.ErrKeyNotFound},
{name: "ErrExtensionNotFound", err: internal.ErrExtensionNotFound},
{name: "ErrOperationCancelled", err: internal.ErrOperationCancelled},
// Network error
{
name: "DNSError",
err: &net.DNSError{Err: "no such host", Name: "example.com"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Get errCode from MapError
span := &mocktracing.Span{}
MapError(tt.err, span)
mapErrorCode := span.Status.Description

// Get code from classifySuggestionType
suggestionCode := classifySuggestionType(tt.err)

require.Equal(t, mapErrorCode, suggestionCode,
"classifySuggestionType and MapError must produce the same code for %T", tt.err)
})
}
}

func Test_cmdAsName(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading