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
235 changes: 179 additions & 56 deletions src/azure-cli-core/azure/cli/core/azclierror.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,85 +4,208 @@
# --------------------------------------------------------------------------------------------

import sys
from enum import Enum

import azure.cli.core.telemetry as telemetry
from knack.util import CLIError
from knack.log import get_logger

logger = get_logger(__name__)
# pylint: disable=unnecessary-pass


class AzCLIErrorType(Enum):
""" AzureCLI error types """

# userfaults
CommandNotFoundError = 'CommandNotFoundError'
ArgumentParseError = 'ArgumentParseError'
ValidationError = 'ValidationError'
ManualInterrupt = 'ManualInterrupt'
# service side error
ServiceError = 'ServiceError'
# client side error
ClientError = 'ClientError'
# unexpected error
UnexpectedError = 'UnexpectedError'
# Error types in AzureCLI are from different sources, and there are many general error types like CLIError, AzureError.
# Besides, many error types with different names are actually showing the same kind of error.
# For example, CloudError, CLIError and ValidtionError all could be a resource-not-found error.
# Therefore, here we define the new error classes to map and categorize all of the error types from different sources.


# region: Base Layer
# Base class for all the AzureCLI defined error classes.
# DO NOT raise the error class here directly in your codes.
class AzCLIError(CLIError):
""" AzureCLI error definition """

def __init__(self, error_type, error_msg, raw_exception=None, command=None):
"""
:param error_type: The name of the AzureCLI error type.
:type error_type: azure.cli.core.util.AzCLIErrorType
:param error_msg: The error message detail.
:type error_msg: str
:param raw_exception: The raw exception.
:type raw_exception: Exception
:param command: The command which brings the error.
:type command: str
:param recommendations: The recommendations to resolve the error.
:type recommendations: list
"""
self.error_type = error_type
""" Base class for all the AzureCLI defined error classes. """

def __init__(self, error_msg, recommendation=None):
# error message
self.error_msg = error_msg
self.raw_exception = raw_exception
self.command = command

# set recommendations to fix the error if the message is not actionable,
# they will be printed to users after the error message, one recommendation per line
self.recommendations = []
if isinstance(recommendation, str):
self.recommendations = [recommendation]
elif isinstance(recommendation, list):
self.recommendations = recommendation

# exception trace for the error
self.exception_trace = None
super().__init__(error_msg)

def set_recommendation(self, recommendation):
self.recommendations.append(recommendation)

def set_raw_exception(self, raw_exception):
self.raw_exception = raw_exception
def set_exception_trace(self, exception_trace):
self.exception_trace = exception_trace

def print_error(self):
from azure.cli.core.azlogging import CommandLoggerContext
with CommandLoggerContext(logger):
message = '{}: {}'.format(self.error_type.value, self.error_msg)
# print error type and error message
message = '{}: {}'.format(self.__class__.__name__, self.error_msg)
logger.error(message)
if self.raw_exception:
logger.exception(self.raw_exception)
# print exception trace if there is
if self.exception_trace:
logger.exception(self.exception_trace)
# print recommendations to action
if self.recommendations:
for recommendation in self.recommendations:
print(recommendation, file=sys.stderr)

def send_telemetry(self):
import azure.cli.core.telemetry as telemetry
telemetry.set_error_type(self.error_type.value)

# For userfaults
if self.error_type in [AzCLIErrorType.CommandNotFoundError,
AzCLIErrorType.ArgumentParseError,
AzCLIErrorType.ValidationError,
AzCLIErrorType.ManualInterrupt]:
telemetry.set_user_fault(self.error_msg)

# For failures: service side error, client side error, unexpected error
else:
telemetry.set_failure(self.error_msg)

# For unexpected error
if self.raw_exception:
telemetry.set_exception(self.raw_exception, '')
telemetry.set_error_type(self.__class__.__name__)
# endregion


# region: Second Layer
# Main categories of the AzureCLI error types, used for Telemetry analysis
# DO NOT raise the error classes here directly in your codes.
class UserFault(AzCLIError):
Copy link
Member

Choose a reason for hiding this comment

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

good point from @qwordy . how about _UserFault with _ prefix to hint user not to use those?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

I don't it is revolved. Reopen the conversation.

""" Users should be responsible for the errors. """
def send_telemetry(self):
super().send_telemetry()
telemetry.set_user_fault(self.error_msg)


class ServiceError(AzCLIError):
""" Azure Services should be responsible for the errors. """
def send_telemetry(self):
super().send_telemetry()
telemetry.set_failure(self.error_msg)


class ClientError(AzCLIError):
""" AzureCLI should be responsible for the errors. """
def send_telemetry(self):
super().send_telemetry()
telemetry.set_failure(self.error_msg)
if self.exception_trace:
telemetry.set_exception(self.exception_trace, '')
# endregion


# region: Third Layer
# Sub-categories of the AzureCLI error types, shown to users
# Raise the error classes here in your codes
# Avoid using fallback error classes unless you can not find a proper one
# Command related error types
class CommandNotFoundError(UserFault):
""" Command is misspelled or not recognized by AzureCLI. """
pass


# Argument related error types
class UnrecognizedArgumentError(UserFault):
""" Argument is misspelled or not recognized by AzureCLI. """
pass


class RequiredArgumentMissingError(UserFault):
""" Required argument is not specified. """
pass


class MutuallyExclusiveArgumentError(UserFault):
""" Arguments can not be specfied together. """
pass


class InvalidArgumentValueError(UserFault):
""" Argument value is not valid. """
pass


class ArgumentParseError(UserFault):
""" Fallback of the argument parsing related errors.
Avoid using this class unless the error can not be classified
into the above Argument related error types. """
pass


# Response related error types
class BadRequestError(UserFault):
""" Bad request from client: 400 error """
pass


class UnauthorizedError(UserFault):
""" Unauthorized request: 401 error """


class ForbiddenError(UserFault):
""" Service refuse to response: 403 error """


class ResourceNotFoundError(UserFault):
""" Can not find Azure resources: 404 error """
pass


class AzureInternalError(ServiceError):
""" Azure service internal error: 5xx error """
pass


class AzureResponseError(UserFault):
""" Fallback of the response related errors.
Avoid using this class unless the error can not be classified
into the above Response related error types. """


# Request related error types
class AzureConnectionError(UserFault):
""" Connection issues like connection timeout, aborted or broken. """
pass


class ClientRequestError(UserFault):
""" Fallback of the request related errors. Error occurs while attempting
to make a request to the service. No request is sent.
Avoid using this class unless the error can not be classified
into the above Request related errors types. """


# Validation related error types
class ValidationError(UserFault):
""" Fallback of the errors in validation functions.
Avoid using this class unless the error can not be classified into
the Argument, Request and Response related error types above. """
pass


# CLI internal error type
class CLIInternalError(ClientError):
""" AzureCLI internal error """
pass


# Keyboard interrupt error type
class ManualInterrupt(UserFault):
""" Keyboard interrupt. """
pass


# Unknow error type
class UnknownError(UserFault):
""" Reserved for the errors which can not be categorized into the error types above.
Usually for the very general error type like CLIError, AzureError.
Error type info will not printed to users for this class. """
def print_error(self):
from azure.cli.core.azlogging import CommandLoggerContext
with CommandLoggerContext(logger):
# print only error message (no error type)
logger.error(self.error_msg)
# print recommendations to action
if self.recommendations:
for recommendation in self.recommendations:
print(recommendation, file=sys.stderr)

# endregion
1 change: 0 additions & 1 deletion src/azure-cli-core/azure/cli/core/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,6 @@ def _validate_cmd_level(self, ns, cmd_validator): # pylint: disable=no-self-use
pass

def _validate_arg_level(self, ns, **_): # pylint: disable=no-self-use
from azure.cli.core.azclierror import AzCLIErrorType
from azure.cli.core.azclierror import AzCLIError
for validator in getattr(ns, '_argument_validators', []):
try:
Expand Down
5 changes: 2 additions & 3 deletions src/azure-cli-core/azure/cli/core/commands/arm.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,10 +762,9 @@ def show_exception_handler(ex):
if getattr(getattr(ex, 'response', ex), 'status_code', None) == 404:
import sys
from azure.cli.core.azlogging import CommandLoggerContext
from azure.cli.core.azclierror import AzCLIErrorType
from azure.cli.core.azclierror import AzCLIError
from azure.cli.core.azclierror import ResourceNotFoundError
with CommandLoggerContext(logger):
az_error = AzCLIError(AzCLIErrorType.ValidationError, getattr(ex, 'message', ex))
az_error = ResourceNotFoundError(getattr(ex, 'message', ex))
az_error.print_error()
az_error.send_telemetry()
sys.exit(3)
Expand Down
25 changes: 18 additions & 7 deletions src/azure-cli-core/azure/cli/core/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@
from azure.cli.core.commands import ExtensionCommandSource
from azure.cli.core.commands import AzCliCommandInvoker
from azure.cli.core.commands.events import EVENT_INVOKER_ON_TAB_COMPLETION
from azure.cli.core.azclierror import AzCLIErrorType
from azure.cli.core.azclierror import AzCLIError
from azure.cli.core.command_recommender import CommandRecommender
from azure.cli.core.azclierror import UnrecognizedArgumentError
from azure.cli.core.azclierror import RequiredArgumentMissingError
from azure.cli.core.azclierror import InvalidArgumentValueError
from azure.cli.core.azclierror import ArgumentParseError
from azure.cli.core.azclierror import CommandNotFoundError
from azure.cli.core.azclierror import ValidationError

from knack.log import get_logger
from knack.parser import CLICommandParser
Expand Down Expand Up @@ -155,7 +159,7 @@ def load_command_table(self, command_loader):
_parser=command_parser)

def validation_error(self, message):
az_error = AzCLIError(AzCLIErrorType.ValidationError, message, command=self.prog)
az_error = ValidationError(message)
az_error.print_error()
az_error.send_telemetry()
self.exit(2)
Expand All @@ -168,7 +172,14 @@ def error(self, message):
recommender.set_help_examples(self.get_examples(self.prog))
recommendation = recommender.recommend_a_command()

az_error = AzCLIError(AzCLIErrorType.ArgumentParseError, message, command=self.prog)
az_error = ArgumentParseError(message)
if 'unrecognized arguments' in message:
az_error = UnrecognizedArgumentError(message)
elif 'arguments are required' in message:
az_error = RequiredArgumentMissingError(message)
elif 'invalid' in message:
az_error = InvalidArgumentValueError(message)

if '--query' in message:
from azure.cli.core.util import QUERY_REFERENCE
az_error.set_recommendation(QUERY_REFERENCE)
Expand Down Expand Up @@ -457,15 +468,15 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t
if not error_msg:
# parser has no `command_source`, value is part of command itself
error_msg = "'{value}' is misspelled or not recognized by the system.".format(value=value)
az_error = AzCLIError(AzCLIErrorType.CommandNotFoundError, error_msg, command=self.prog)
az_error = CommandNotFoundError(error_msg)

else:
# `command_source` indicates command values have been parsed, value is an argument
parameter = action.option_strings[0] if action.option_strings else action.dest
error_msg = "{prog}: '{value}' is not a valid value for '{param}'.".format(
prog=self.prog, value=value, param=parameter)
candidates = difflib.get_close_matches(value, action.choices, cutoff=0.7)
az_error = AzCLIError(AzCLIErrorType.ArgumentParseError, error_msg, command=self.prog)
az_error = InvalidArgumentValueError(error_msg)

command_arguments = self._get_failure_recovery_arguments(action)
if candidates:
Expand All @@ -479,7 +490,7 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t
az_error.set_recommendation("Try this: '{}'".format(recommended_command))

# remind user to check extensions if we can not find a command to recommend
if az_error.error_type == AzCLIErrorType.CommandNotFoundError \
if isinstance(az_error, CommandNotFoundError) \
and not az_error.recommendations and self.prog == 'az' \
and use_dynamic_install == 'no':
az_error.set_recommendation(EXTENSION_REFERENCE)
Expand Down
2 changes: 1 addition & 1 deletion src/azure-cli-core/azure/cli/core/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def __init__(self, correlation_id=None, application=None):
self.extension_management_detail = None
self.raw_command = None
self.mode = 'default'
# The AzCLIErrorType
# The AzCLIError sub-class name
self.error_type = 'None'
# The class name of the raw exception
self.exception_name = 'None'
Expand Down
2 changes: 1 addition & 1 deletion src/azure-cli-core/azure/cli/core/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ def test_handle_exception_clouderror(self, mock_logger_error):
# test behavior
self.assertTrue(mock_logger_error.called)
self.assertIn(mock_cloud_error.args[0], mock_logger_error.call_args.args[0])
self.assertEqual(ex_result, mock_cloud_error.args[1])
self.assertEqual(ex_result, 1)

@mock.patch('azure.cli.core.azclierror.logger.error', autospec=True)
def test_handle_exception_httpoperationerror_typical_response_error(self, mock_logger_error):
Expand Down
Loading