diff --git a/ads/aqua/constants.py b/ads/aqua/constants.py index 0f7a501ba..09f2e2cfc 100644 --- a/ads/aqua/constants.py +++ b/ads/aqua/constants.py @@ -40,6 +40,7 @@ HF_METADATA_FOLDER = ".cache/" HF_LOGIN_DEFAULT_TIMEOUT = 2 MODEL_NAME_DELIMITER = ";" +AQUA_TROUBLESHOOTING_LINK = "https://github.com/oracle-samples/oci-data-science-ai-samples/blob/main/ai-quick-actions/troubleshooting-tips.md" TRAINING_METRICS_FINAL = "training_metrics_final" VALIDATION_METRICS_FINAL = "validation_metrics_final" @@ -85,3 +86,27 @@ "--host", } TEI_CONTAINER_DEFAULT_HOST = "8080" + +OCI_OPERATION_FAILURES = { + "list_model_deployments": "Unable to list model deployments. See tips for troubleshooting: ", + "list_models": "Unable to list models. See tips for troubleshooting: ", + "get_namespace": "Unable to access specified Object Storage Bucket. See tips for troubleshooting: ", + "list_log_groups":"Unable to access logs. See tips for troubleshooting: " , + "list_buckets": "Unable to list Object Storage Bucket. See tips for troubleshooting: ", + "put_object": "Unable to access or find Object Storage Bucket. See tips for troubleshooting: ", + "list_model_version_sets": "Unable to create or fetch model version set. See tips for troubleshooting:", + "update_model": "Unable to update model. See tips for troubleshooting: ", + "list_data_science_private_endpoints": "Unable to access private endpoint. See tips for troubleshooting: ", + "create_model" : "Unable to register model. See tips for troubleshooting: ", + "create_deployment": "Unable to create deployment. See tips for troubleshooting: ", + "create_model_version_sets" : "Unable to create model version set. See tips for troubleshooting: ", + "create_job": "Unable to create job. See tips for troubleshooting: ", + "create_job_run": "Unable to create job run. See tips for troubleshooting: ", +} + +STATUS_CODE_MESSAGES = { + "400": "Could not process your request due to invalid input.", + "403": "We're having trouble processing your request with the information provided.", + "404": "Authorization Failed: The resource you're looking for isn't accessible.", + "408": "Server is taking too long to respond, please try again.", +} diff --git a/ads/aqua/extension/aqua_ws_msg_handler.py b/ads/aqua/extension/aqua_ws_msg_handler.py index 1fcbbf946..13454efc7 100644 --- a/ads/aqua/extension/aqua_ws_msg_handler.py +++ b/ads/aqua/extension/aqua_ws_msg_handler.py @@ -3,17 +3,10 @@ # Copyright (c) 2024, 2025 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ -import traceback -import uuid from abc import abstractmethod -from http.client import responses from typing import List -from tornado.web import HTTPError - -from ads.aqua import logger from ads.aqua.common.decorator import handle_exceptions -from ads.aqua.extension.base_handler import AquaAPIhandler from ads.aqua.extension.models.ws_models import ( AquaWsError, BaseRequest, @@ -21,6 +14,7 @@ ErrorResponse, RequestResponseType, ) +from ads.aqua.extension.utils import construct_error from ads.config import AQUA_TELEMETRY_BUCKET, AQUA_TELEMETRY_BUCKET_NS from ads.telemetry.client import TelemetryClient @@ -55,48 +49,24 @@ def process(self) -> BaseResponse: def write_error(self, status_code, **kwargs): """AquaWSMSGhandler errors are JSON, not human pages.""" - reason = kwargs.get("reason") + service_payload = kwargs.get("service_payload", {}) - default_msg = responses.get(status_code, "Unknown HTTP Error") - message = AquaAPIhandler.get_default_error_messages( - service_payload, str(status_code), kwargs.get("message", default_msg) - ) - reply = { - "status": status_code, - "message": message, - "service_payload": service_payload, - "reason": reason, - "request_id": str(uuid.uuid4()), - } - exc_info = kwargs.get("exc_info") - if exc_info: - logger.error( - f"Error Request ID: {reply['request_id']}\n" - f"Error: {''.join(traceback.format_exception(*exc_info))}" - ) - e = exc_info[1] - if isinstance(e, HTTPError): - reply["message"] = e.log_message or message - reply["reason"] = e.reason + reply_details = construct_error(status_code, **kwargs) - logger.error( - f"Error Request ID: {reply['request_id']}\n" - f"Error: {reply['message']} {reply['reason']}" - ) # telemetry may not be present if there is an error while initializing if hasattr(self, "telemetry"): aqua_api_details = kwargs.get("aqua_api_details", {}) self.telemetry.record_event_async( category="aqua/error", action=str(status_code), - value=reason, + value=reply_details.reason, **aqua_api_details, ) response = AquaWsError( status=status_code, - message=message, + message=reply_details.message, service_payload=service_payload, - reason=reason, + reason=reply_details.reason, ) base_message = BaseRequest.from_json(self.message, ignore_unknown=True) return ErrorResponse( diff --git a/ads/aqua/extension/base_handler.py b/ads/aqua/extension/base_handler.py index f56e4bf36..dc6219422 100644 --- a/ads/aqua/extension/base_handler.py +++ b/ads/aqua/extension/base_handler.py @@ -2,20 +2,16 @@ # Copyright (c) 2024, 2025 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ - import json -import traceback -import uuid from dataclasses import asdict, is_dataclass -from http.client import responses from typing import Any from notebook.base.handlers import APIHandler from tornado import httputil -from tornado.web import Application, HTTPError +from tornado.web import Application -from ads.aqua import logger from ads.aqua.common.utils import is_pydantic_model +from ads.aqua.extension.utils import construct_error from ads.config import AQUA_TELEMETRY_BUCKET, AQUA_TELEMETRY_BUCKET_NS from ads.telemetry.client import TelemetryClient @@ -75,37 +71,11 @@ def finish(self, payload=None): # pylint: disable=W0221 def write_error(self, status_code, **kwargs): """AquaAPIhandler errors are JSON, not human pages.""" - self.set_header("Content-Type", "application/json") - reason = kwargs.get("reason") - self.set_status(status_code, reason=reason) - service_payload = kwargs.get("service_payload", {}) - default_msg = responses.get(status_code, "Unknown HTTP Error") - message = self.get_default_error_messages( - service_payload, str(status_code), kwargs.get("message", default_msg) - ) - - reply = { - "status": status_code, - "message": message, - "service_payload": service_payload, - "reason": reason, - "request_id": str(uuid.uuid4()), - } - exc_info = kwargs.get("exc_info") - if exc_info: - logger.error( - f"Error Request ID: {reply['request_id']}\n" - f"Error: {''.join(traceback.format_exception(*exc_info))}" - ) - e = exc_info[1] - if isinstance(e, HTTPError): - reply["message"] = e.log_message or message - reply["reason"] = e.reason if e.reason else reply["reason"] - logger.error( - f"Error Request ID: {reply['request_id']}\n" - f"Error: {reply['message']} {reply['reason']}" - ) + reply_details = construct_error(status_code, **kwargs) + + self.set_header("Content-Type", "application/json") + self.set_status(status_code, reason=reply_details.reason) # telemetry may not be present if there is an error while initializing if hasattr(self, "telemetry"): @@ -113,40 +83,8 @@ def write_error(self, status_code, **kwargs): self.telemetry.record_event_async( category="aqua/error", action=str(status_code), - value=reason, + value=reply_details.reason, **aqua_api_details, ) - self.finish(json.dumps(reply)) - - @staticmethod - def get_default_error_messages( - service_payload: dict, - status_code: str, - default_msg: str = "Unknown HTTP Error.", - ): - """Method that maps the error messages based on the operation performed or the status codes encountered.""" - - messages = { - "400": "Something went wrong with your request.", - "403": "We're having trouble processing your request with the information provided.", - "404": "Authorization Failed: The resource you're looking for isn't accessible.", - "408": "Server is taking too long to response, please try again.", - "create": "Authorization Failed: Could not create resource.", - "get": "Authorization Failed: The resource you're looking for isn't accessible.", - } - - if service_payload and "operation_name" in service_payload: - operation_name = service_payload["operation_name"] - if operation_name: - if operation_name.startswith("create"): - return messages["create"] + f" Operation Name: {operation_name}." - elif operation_name.startswith("list") or operation_name.startswith( - "get" - ): - return messages["get"] + f" Operation Name: {operation_name}." - - if status_code in messages: - return messages[status_code] - else: - return default_msg + self.finish(reply_details) diff --git a/ads/aqua/extension/errors.py b/ads/aqua/extension/errors.py index 9829ff9e4..04c935e5d 100644 --- a/ads/aqua/extension/errors.py +++ b/ads/aqua/extension/errors.py @@ -1,7 +1,16 @@ #!/usr/bin/env python # Copyright (c) 2024 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +import uuid +from typing import Any, Dict, List, Optional +from pydantic import Field + +from ads.aqua.config.utils.serializer import Serializable + +from ads.aqua.constants import ( + AQUA_TROUBLESHOOTING_LINK +) class Errors(str): INVALID_INPUT_DATA_FORMAT = "Invalid format of input data." @@ -9,3 +18,13 @@ class Errors(str): MISSING_REQUIRED_PARAMETER = "Missing required parameter: '{}'" MISSING_ONEOF_REQUIRED_PARAMETER = "Either '{}' or '{}' is required." INVALID_VALUE_OF_PARAMETER = "Invalid value of parameter: '{}'" + +class ReplyDetails(Serializable): + """Structured reply to be returned to the client.""" + status: int + troubleshooting_tips: str = Field(f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + description="GitHub Link for troubleshooting documentation") + message: str = Field("Unknown HTTP Error.", description="GitHub Link for troubleshooting documentation") + service_payload: Optional[Dict[str, Any]] = Field(default_factory=dict) + reason: str = Field("Unknown error", description="Reason for Error") + request_id: str = Field(str(uuid.uuid4()), description="Unique ID for tracking the error.") diff --git a/ads/aqua/extension/utils.py b/ads/aqua/extension/utils.py index 90787beb6..2f0a95728 100644 --- a/ads/aqua/extension/utils.py +++ b/ads/aqua/extension/utils.py @@ -1,16 +1,26 @@ #!/usr/bin/env python # Copyright (c) 2024 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + +import re +import traceback +import uuid from dataclasses import fields from datetime import datetime, timedelta +from http.client import responses from typing import Dict, Optional from cachetools import TTLCache, cached from tornado.web import HTTPError -from ads.aqua import ODSC_MODEL_COMPARTMENT_OCID +from ads.aqua import ODSC_MODEL_COMPARTMENT_OCID, logger from ads.aqua.common.utils import fetch_service_compartment -from ads.aqua.extension.errors import Errors +from ads.aqua.constants import ( + AQUA_TROUBLESHOOTING_LINK, + OCI_OPERATION_FAILURES, + STATUS_CODE_MESSAGES, +) +from ads.aqua.extension.errors import Errors, ReplyDetails def validate_function_parameters(data_class, input_data: Dict): @@ -32,3 +42,105 @@ def ui_compatability_check(): fetched from the configuration. The cached result is returned when multiple calls are made in quick succession from the UI to avoid multiple config file loads.""" return ODSC_MODEL_COMPARTMENT_OCID or fetch_service_compartment() + + +def get_default_error_messages( + service_payload: dict, + status_code: str, + default_msg: str = "Unknown HTTP Error.", +)-> str: + """Method that maps the error messages based on the operation performed or the status codes encountered.""" + + if service_payload and "operation_name" in service_payload: + operation_name = service_payload.get("operation_name") + + if operation_name and status_code in STATUS_CODE_MESSAGES: + return f"{STATUS_CODE_MESSAGES[status_code]}\n{service_payload.get('message')}\nOperation Name: {operation_name}." + + return STATUS_CODE_MESSAGES.get(status_code, default_msg) + + +def get_documentation_link(key: str) -> str: + """Generates appropriate GitHub link to AQUA Troubleshooting Documentation per the user's error.""" + github_header = re.sub(r"_", "-", key) + return f"{AQUA_TROUBLESHOOTING_LINK}#{github_header}" + + +def get_troubleshooting_tips(service_payload: dict, + status_code: str) -> str: + """Maps authorization errors to potential solutions on Troubleshooting Page per Aqua Documentation on oci-data-science-ai-samples""" + + tip = f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}" + + if status_code in (404, 400): + failed_operation = service_payload.get('operation_name') + + if failed_operation in OCI_OPERATION_FAILURES: + link = get_documentation_link(failed_operation) + tip = OCI_OPERATION_FAILURES[failed_operation] + link + + return tip + + +def construct_error(status_code: int, **kwargs) -> ReplyDetails: + """ + Formats an error response based on the provided status code and optional details. + + Args: + status_code (int): The HTTP status code of the error. + **kwargs: Additional optional parameters: + - reason (str, optional): A brief reason for the error. + - service_payload (dict, optional): Contextual error data from OCI SDK methods + - message (str, optional): A custom error message, from error raised from failed AQUA methods calling OCI SDK methods + - exc_info (tuple, optional): Exception information (e.g., from `sys.exc_info()`), used for logging. + + Returns: + ReplyDetails: A Pydantic object containing details about the formatted error response. + kwargs: + - "status" (int): The HTTP status code. + - "troubleshooting_tips" (str): a GitHub link to AQUA troubleshooting docs, may be linked to a specific header. + - "message" (str): error message. + - "service_payload" (Dict[str, Any], optional) : Additional context from OCI Python SDK call. + - "reason" (str): The reason for the error. + - "request_id" (str): A unique identifier for tracking the error. + + Logs: + - Logs the error details with a unique request ID. + - If `exc_info` is provided and contains an `HTTPError`, updates the response message and reason accordingly. + + """ + reason = kwargs.get("reason", "Unknown Error") + service_payload = kwargs.get("service_payload", {}) + default_msg = responses.get(status_code, "Unknown HTTP Error") + message = get_default_error_messages( + service_payload, str(status_code), kwargs.get("message", default_msg) + ) + + tips = get_troubleshooting_tips(service_payload, status_code) + + + reply = ReplyDetails( + status = status_code, + troubleshooting_tips = tips, + message = message, + service_payload = service_payload, + reason = reason, + request_id = str(uuid.uuid4()), + ) + + exc_info = kwargs.get("exc_info") + if exc_info: + logger.error( + f"Error Request ID: {reply.request_id}\n" + f"Error: {''.join(traceback.format_exception(*exc_info))}" + ) + e = exc_info[1] + if isinstance(e, HTTPError): + reply.message = e.log_message or message + reply.reason = e.reason if e.reason else reply.reason + + logger.error( + f"Error Request ID: {reply.request_id}\n" + f"Error: {reply.message} {reply.reason}" + ) + return reply diff --git a/tests/unitary/with_extras/aqua/test_decorator.py b/tests/unitary/with_extras/aqua/test_decorator.py index 992bf68f2..b7c053b72 100644 --- a/tests/unitary/with_extras/aqua/test_decorator.py +++ b/tests/unitary/with_extras/aqua/test_decorator.py @@ -23,7 +23,13 @@ from tornado.web import HTTPError from ads.aqua.common.errors import AquaError +from ads.aqua.constants import ( + AQUA_TROUBLESHOOTING_LINK, + OCI_OPERATION_FAILURES, + STATUS_CODE_MESSAGES, +) from ads.aqua.extension.base_handler import AquaAPIhandler +from ads.aqua.extension.errors import ReplyDetails class TestDataset: @@ -40,6 +46,7 @@ def setUp(self, ipython_init_mock) -> None: self.test_instance.finish = MagicMock() self.test_instance.set_header = MagicMock() self.test_instance.set_status = MagicMock() + self.test_instance.set_service_payload = MagicMock() @parameterized.expand( [ @@ -53,6 +60,7 @@ def setUp(self, ipython_init_mock) -> None: ), { "status": 500, + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", "message": "An internal error occurred.", "service_payload": { "target_service": None, @@ -70,13 +78,134 @@ def setUp(self, ipython_init_mock) -> None: "reason": "An internal error occurred.", "request_id": TestDataset.mock_request_id, }, + ], + [ + "oci ServiceError", + ServiceError( + status=400, + code="InvalidParameter", + message="Invalid tags", + operation_name= "create_model", + headers={}, + ), + { + "status": 400, + "troubleshooting_tips": f"{OCI_OPERATION_FAILURES['create_model']}{AQUA_TROUBLESHOOTING_LINK}#create-model", + "message": f"{STATUS_CODE_MESSAGES['400']}\nInvalid tags\nOperation Name: create_model.", + "service_payload": { + "target_service": None, + "status": 400, + "code": "InvalidParameter", + "opc-request-id": None, + "message": "Invalid tags", + "operation_name": "create_model", + "timestamp": None, + "client_version": None, + "request_endpoint": None, + "logging_tips": "To get more info on the failing request, refer to https://docs.oracle.com/en-us/iaas/tools/python/latest/logging.html for ways to log the request/response details.", + "troubleshooting_tips": "See https://docs.oracle.com/iaas/Content/API/References/apierrors.htm#apierrors_400__400_invalidparameter for more information about resolving this error. If you are unable to resolve this None issue, please contact Oracle support and provide them this full error message.", + }, + "reason": "Invalid tags", + "request_id": TestDataset.mock_request_id, + }, + ], + [ + "oci ServiceError", + ServiceError( + status=400, + code="InvalidParameter", + message="Invalid tags", + operation_name= "update_model", + headers={}, + ), + { + "status": 400, + "troubleshooting_tips": f"{OCI_OPERATION_FAILURES['update_model']}{AQUA_TROUBLESHOOTING_LINK}#update-model", + "message": f"{STATUS_CODE_MESSAGES['400']}\nInvalid tags\nOperation Name: update_model.", + "service_payload": { + "target_service": None, + "status": 400, + "code": "InvalidParameter", + "opc-request-id": None, + "message": "Invalid tags", + "operation_name": "update_model", + "timestamp": None, + "client_version": None, + "request_endpoint": None, + "logging_tips": "To get more info on the failing request, refer to https://docs.oracle.com/en-us/iaas/tools/python/latest/logging.html for ways to log the request/response details.", + "troubleshooting_tips": "See https://docs.oracle.com/iaas/Content/API/References/apierrors.htm#apierrors_400__400_invalidparameter for more information about resolving this error. If you are unable to resolve this None issue, please contact Oracle support and provide them this full error message.", + }, + "reason": "Invalid tags", + "request_id": TestDataset.mock_request_id, + }, + ], + [ + "oci ServiceError", + ServiceError( + status=400, + code="InvalidParameter", + message="Invalid tags", + operation_name= "update_model", + headers={}, + ), + { + "status": 400, + "troubleshooting_tips": f"{OCI_OPERATION_FAILURES['update_model']}{AQUA_TROUBLESHOOTING_LINK}#update-model", + "message": f"{STATUS_CODE_MESSAGES['400']}\nInvalid tags\nOperation Name: update_model.", + "service_payload": { + "target_service": None, + "status": 400, + "code": "InvalidParameter", + "opc-request-id": None, + "message": "Invalid tags", + "operation_name": "update_model", + "timestamp": None, + "client_version": None, + "request_endpoint": None, + "logging_tips": "To get more info on the failing request, refer to https://docs.oracle.com/en-us/iaas/tools/python/latest/logging.html for ways to log the request/response details.", + "troubleshooting_tips": "See https://docs.oracle.com/iaas/Content/API/References/apierrors.htm#apierrors_400__400_invalidparameter for more information about resolving this error. If you are unable to resolve this None issue, please contact Oracle support and provide them this full error message.", + }, + "reason": "Invalid tags", + "request_id": TestDataset.mock_request_id, + }, + ], + [ + "oci ServiceError", + ServiceError( + status=404, + code="BucketNotFound", + message="Either the bucket named 'xxx' does not exist in the namespace 'xxx' or you are not authorized to access it", + operation_name= "put_object", + headers={}, + ), + { + "status": 404, + "troubleshooting_tips": f"{OCI_OPERATION_FAILURES['put_object']}{AQUA_TROUBLESHOOTING_LINK}#put-object", + "message": f"{STATUS_CODE_MESSAGES['404']}\nEither the bucket named 'xxx' does not exist in the namespace 'xxx' or you are not authorized to access it\nOperation Name: put_object.", + "service_payload": { + "target_service": None, + "status": 404, + "code": "BucketNotFound", + "opc-request-id": None, + "message": "Either the bucket named 'xxx' does not exist in the namespace 'xxx' or you are not authorized to access it", + "operation_name": "put_object", + "timestamp": None, + "client_version": None, + "request_endpoint": None, + "logging_tips": "To get more info on the failing request, refer to https://docs.oracle.com/en-us/iaas/tools/python/latest/logging.html for ways to log the request/response details.", + "troubleshooting_tips": "See https://docs.oracle.com/iaas/Content/API/References/apierrors.htm#apierrors_404__404_bucketnotfound for more information about resolving this error. If you are unable to resolve this None issue, please contact Oracle support and provide them this full error message.", + }, + "reason": "Either the bucket named 'xxx' does not exist in the namespace 'xxx' or you are not authorized to access it" , + "request_id": TestDataset.mock_request_id, + }, ], [ "oci ClientError", ConfigFileNotFound("Could not find config file at the given path."), { "status": 400, - "message": "Something went wrong with your request.", + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + "message": STATUS_CODE_MESSAGES["400"], "service_payload": {}, "reason": "ConfigFileNotFound: Could not find config file at the given path.", "request_id": TestDataset.mock_request_id, @@ -89,7 +218,8 @@ def setUp(self, ipython_init_mock) -> None: ), { "status": 400, - "message": "Something went wrong with your request.", + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + "message": STATUS_CODE_MESSAGES["400"], "service_payload": {}, "reason": "MissingEndpointForNonRegionalServiceClientError: An endpoint must be provided for a non-regional service client", "request_id": TestDataset.mock_request_id, @@ -100,7 +230,8 @@ def setUp(self, ipython_init_mock) -> None: RequestException("An exception occurred when making the request"), { "status": 400, - "message": "Something went wrong with your request.", + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + "message": STATUS_CODE_MESSAGES["400"], "service_payload": {}, "reason": "RequestException: An exception occurred when making the request", "request_id": TestDataset.mock_request_id, @@ -113,7 +244,8 @@ def setUp(self, ipython_init_mock) -> None: ), { "status": 408, - "message": "Server is taking too long to response, please try again.", + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + "message": "Server is taking too long to respond, please try again.", "service_payload": {}, "reason": "ConnectTimeout: The request timed out while trying to connect to the remote server.", "request_id": TestDataset.mock_request_id, @@ -124,6 +256,7 @@ def setUp(self, ipython_init_mock) -> None: MultipartUploadError(), { "status": 500, + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", "message": "Internal Server Error", "service_payload": {}, "reason": f"MultipartUploadError: MultipartUploadError exception has occurred. {UPLOAD_MANAGER_DEBUG_INFORMATION_LOG}", @@ -135,6 +268,7 @@ def setUp(self, ipython_init_mock) -> None: CompositeOperationError(), { "status": 500, + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", "message": "Internal Server Error", "service_payload": {}, "reason": "CompositeOperationError: ", @@ -146,6 +280,7 @@ def setUp(self, ipython_init_mock) -> None: AquaError(reason="Mocking AQUA error.", status=403, service_payload={}), { "status": 403, + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", "message": "We're having trouble processing your request with the information provided.", "service_payload": {}, "reason": "Mocking AQUA error.", @@ -157,6 +292,7 @@ def setUp(self, ipython_init_mock) -> None: HTTPError(400, "The request `/test` is invalid."), { "status": 400, + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", "message": "The request `/test` is invalid.", "service_payload": {}, "reason": "The request `/test` is invalid.", @@ -168,6 +304,7 @@ def setUp(self, ipython_init_mock) -> None: ValueError("Mocking ADS internal error."), { "status": 500, + "troubleshooting_tips": f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", "message": "Internal Server Error", "service_payload": {}, "reason": "ValueError: Mocking ADS internal error.", @@ -182,7 +319,7 @@ def test_handle_exceptions(self, name, error, expected_reply, mock_uuid): from ads.aqua.common.decorator import handle_exceptions mock_uuid.return_value = TestDataset.mock_request_id - expected_call = json.dumps(expected_reply) + expected_call = ReplyDetails(**expected_reply) @handle_exceptions def mock_function(self): diff --git a/tests/unitary/with_extras/aqua/test_handlers.py b/tests/unitary/with_extras/aqua/test_handlers.py index 6cbffe23e..d0c68d94d 100644 --- a/tests/unitary/with_extras/aqua/test_handlers.py +++ b/tests/unitary/with_extras/aqua/test_handlers.py @@ -22,6 +22,10 @@ import ads.aqua.extension.common_handler import ads.config from ads.aqua.common.errors import AquaError +from ads.aqua.constants import ( + AQUA_TROUBLESHOOTING_LINK, + STATUS_CODE_MESSAGES, +) from ads.aqua.data import AquaResourceIdentifier from ads.aqua.evaluation import AquaEvaluationApp from ads.aqua.extension.base_handler import AquaAPIhandler @@ -38,7 +42,7 @@ from ads.aqua.extension.model_handler import AquaModelHandler, AquaModelLicenseHandler from ads.aqua.model import AquaModelApp from tests.unitary.with_extras.aqua.utils import HandlerTestDataset as TestDataset - +from ads.aqua.extension.errors import ReplyDetails class TestBaseHandlers(unittest.TestCase): """Contains test cases for base handler.""" @@ -84,6 +88,7 @@ def test_finish(self, name, payload, expected_call, mock_super_finish): "HTTPError", dict( status_code=400, + reason = "Unknown Error", exc_info=(None, HTTPError(400, "Bad Request"), None), ), "Bad Request", @@ -113,11 +118,11 @@ def test_finish(self, name, payload, expected_call, mock_super_finish): None, ), ), - "Authorization Failed: Could not create resource. Operation Name: create_resources.", + f"{STATUS_CODE_MESSAGES['404']}\nThe required information to complete authentication was not provided or was incorrect.\nOperation Name: create_resources.", ], [ "oci ServiceError", - dict( + dict( # noqa: C408 status_code=404, reason="Testing ServiceError happen when get_job_run.", service_payload=TestDataset.mock_service_payload_get, @@ -139,11 +144,11 @@ def test_finish(self, name, payload, expected_call, mock_super_finish): ], ), ), - "Authorization Failed: The resource you're looking for isn't accessible. Operation Name: get_job_run.", + f"{STATUS_CODE_MESSAGES['404']}\nThe required information to complete authentication was not provided or was incorrect.\nOperation Name: get_job_run.", ], ] ) - @patch("ads.aqua.extension.base_handler.logger") + @patch("ads.aqua.extension.utils.logger") @patch("uuid.uuid4") def test_write_error(self, name, input, expected_msg, mock_uuid, mock_logger): """Tests AquaAPIhandler.write_error""" @@ -160,15 +165,19 @@ def test_write_error(self, name, input, expected_msg, mock_uuid, mock_logger): self.test_instance.set_status.assert_called_once_with( input.get("status_code"), reason=input.get("reason") ) - expected_reply = { - "status": input.get("status_code"), - "message": expected_msg, - "service_payload": input.get("service_payload", {}), - "reason": input.get("reason"), - "request_id": "1234", - } - self.test_instance.finish.assert_called_once_with(json.dumps(expected_reply)) + expected_reply = ReplyDetails( + status = input.get("status_code"), + troubleshooting_tips = f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + message = expected_msg, + service_payload = input.get("service_payload", {}), + reason = input.get("reason", "Unknown Error"), + request_id = "1234", + ) + + + self.test_instance.finish.assert_called_once_with(expected_reply) aqua_api_details = input.get("aqua_api_details", {}) + self.test_instance.telemetry.record_event_async.assert_called_with( category="aqua/error", action=str( @@ -177,10 +186,12 @@ def test_write_error(self, name, input, expected_msg, mock_uuid, mock_logger): value=input.get("reason"), **aqua_api_details, ) + error_message = ( - f"Error Request ID: {expected_reply['request_id']}\n" - f"Error: {expected_reply['message']} {expected_reply['reason']}" + f"Error Request ID: {expected_reply.request_id}\n" + f"Error: {expected_reply.message} {expected_reply.reason}" ) + mock_logger.error.assert_called_with(error_message) diff --git a/tests/unitary/with_extras/aqua/test_model_handler.py b/tests/unitary/with_extras/aqua/test_model_handler.py index d14424990..818638cea 100644 --- a/tests/unitary/with_extras/aqua/test_model_handler.py +++ b/tests/unitary/with_extras/aqua/test_model_handler.py @@ -9,11 +9,16 @@ import pytest from huggingface_hub.hf_api import HfApi, ModelInfo from huggingface_hub.utils import GatedRepoError -from notebook.base.handlers import IPythonHandler, HTTPError +from notebook.base.handlers import HTTPError, IPythonHandler from parameterized import parameterized from ads.aqua.common.errors import AquaRuntimeError from ads.aqua.common.utils import get_hf_model_info +from ads.aqua.constants import ( + AQUA_TROUBLESHOOTING_LINK, + STATUS_CODE_MESSAGES, +) +from ads.aqua.extension.errors import ReplyDetails from ads.aqua.extension.model_handler import ( AquaHuggingFaceHandler, AquaModelHandler, @@ -355,7 +360,15 @@ def test_post_negative(self, mock_uuid, mock_format_hf_custom_error_message): self.mock_handler.get_json_body = MagicMock(side_effect=ValueError()) self.mock_handler.post() self.mock_handler.finish.assert_called_with( - '{"status": 400, "message": "Invalid format of input data.", "service_payload": {}, "reason": "Invalid format of input data.", "request_id": "###"}' + ReplyDetails( + status=400, + troubleshooting_tips= f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + message= "Invalid format of input data.", + service_payload = {}, + reason = "Invalid format of input data.", + request_id = "###" + + ) ) get_hf_model_info.cache_clear() @@ -363,8 +376,15 @@ def test_post_negative(self, mock_uuid, mock_format_hf_custom_error_message): self.mock_handler.get_json_body = MagicMock(return_value={}) self.mock_handler.post() self.mock_handler.finish.assert_called_with( - '{"status": 400, "message": "No input data provided.", "service_payload": {}, ' - '"reason": "No input data provided.", "request_id": "###"}' + ReplyDetails( + status=400, + troubleshooting_tips= f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + message= "No input data provided.", + service_payload = {}, + reason = "No input data provided.", + request_id = "###" + + ) ) get_hf_model_info.cache_clear() @@ -372,8 +392,15 @@ def test_post_negative(self, mock_uuid, mock_format_hf_custom_error_message): self.mock_handler.get_json_body = MagicMock(return_value={"some_field": None}) self.mock_handler.post() self.mock_handler.finish.assert_called_with( - '{"status": 400, "message": "Missing required parameter: \'model_id\'", ' - '"service_payload": {}, "reason": "Missing required parameter: \'model_id\'", "request_id": "###"}' + ReplyDetails( + status=400, + troubleshooting_tips= f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + message= "Missing required parameter: \'model_id\'", + service_payload = {}, + reason = "Missing required parameter: \'model_id\'", + request_id = "###" + + ) ) get_hf_model_info.cache_clear() @@ -389,8 +416,15 @@ def test_post_negative(self, mock_uuid, mock_format_hf_custom_error_message): mock_model_info.side_effect = GatedRepoError(message="test message") self.mock_handler.post() self.mock_handler.finish.assert_called_with( - '{"status": 400, "message": "Something went wrong with your request.", ' - '"service_payload": {}, "reason": "test error message", "request_id": "###"}' + ReplyDetails( + status=400, + troubleshooting_tips= f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + message= STATUS_CODE_MESSAGES["400"], + service_payload = {}, + reason = "test error message", + request_id = "###" + + ) ) get_hf_model_info.cache_clear() @@ -401,11 +435,17 @@ def test_post_negative(self, mock_uuid, mock_format_hf_custom_error_message): with patch.object(HfApi, "model_info") as mock_model_info: mock_model_info.return_value = MagicMock(disabled=True, id="test_model_id") self.mock_handler.post() + self.mock_handler.finish.assert_called_with( - '{"status": 400, "message": "Something went wrong with your request.", "service_payload": {}, ' - '"reason": "The chosen model \'test_model_id\' is currently disabled and cannot be ' - "imported into AQUA. Please verify the model's status on the Hugging Face Model " - 'Hub or select a different model.", "request_id": "###"}' + ReplyDetails( + status=400, + troubleshooting_tips= f"For general tips on troubleshooting: {AQUA_TROUBLESHOOTING_LINK}", + message= STATUS_CODE_MESSAGES["400"], + service_payload = {}, + reason = "The chosen model \'test_model_id\' is currently disabled and cannot be imported into AQUA. Please verify the model\'s status on the Hugging Face Model Hub or select a different model.", + request_id = "###" + + ) ) get_hf_model_info.cache_clear()