diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 9c12df319..7ab392c10 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -9,6 +9,8 @@ ### Documentation ### Internal Changes + +* Refactor `DatabricksError` to expose different types of error details ([#912](https://github.com/databricks/databricks-sdk-py/pull/912)). * Update Jobs ListJobs API to support paginated responses ([#896](https://github.com/databricks/databricks-sdk-py/pull/896)) * Update Jobs ListRuns API to support paginated responses ([#890](https://github.com/databricks/databricks-sdk-py/pull/890)) * Introduce automated tagging ([#888](https://github.com/databricks/databricks-sdk-py/pull/888)) diff --git a/databricks/sdk/errors/base.py b/databricks/sdk/errors/base.py index be59517c4..908172576 100644 --- a/databricks/sdk/errors/base.py +++ b/databricks/sdk/errors/base.py @@ -5,7 +5,10 @@ import requests +from . import details as errdetails + +# Deprecated. class ErrorDetail: def __init__( self, @@ -22,17 +25,18 @@ def __init__( @classmethod def from_dict(cls, d: Dict[str, any]) -> "ErrorDetail": - if "@type" in d: - d["type"] = d["@type"] - return cls(**d) + # Key "@type" is not a valid keyword argument name in Python. Rename + # it to "type" to avoid conflicts. + safe_args = {} + for k, v in d.items(): + safe_args[k if k != "@type" else "type"] = v + + return cls(**safe_args) class DatabricksError(IOError): """Generic error from Databricks REST API""" - # Known ErrorDetail types - _error_info_type = "type.googleapis.com/google.rpc.ErrorInfo" - def __init__( self, message: str = None, @@ -54,11 +58,11 @@ def __init__( :param status: [Deprecated] :param scimType: [Deprecated] :param error: [Deprecated] - :param retry_after_secs: + :param retry_after_secs: [Deprecated] :param details: :param kwargs: """ - # SCIM-specific parameters are deprecated + # SCIM-specific parameters are deprecated. if detail: warnings.warn( "The 'detail' parameter of DatabricksError is deprecated and will be removed in a future version." @@ -72,12 +76,18 @@ def __init__( "The 'status' parameter of DatabricksError is deprecated and will be removed in a future version." ) - # API 1.2-specific parameters are deprecated + # API 1.2-specific parameters are deprecated. if error: warnings.warn( "The 'error' parameter of DatabricksError is deprecated and will be removed in a future version." ) + # Retry-after is deprecated. + if retry_after_secs: + warnings.warn( + "The 'retry_after_secs' parameter of DatabricksError is deprecated and will be removed in a future version." + ) + if detail: # Handle SCIM error message details # @see https://tools.ietf.org/html/rfc7644#section-3.7.3 @@ -88,20 +98,32 @@ def __init__( # add more context from SCIM responses message = f"{scimType} {message}".strip(" ") error_code = f"SCIM_{status}" + super().__init__(message if message else error) self.error_code = error_code self.retry_after_secs = retry_after_secs - self.details = [ErrorDetail.from_dict(detail) for detail in details] if details else [] + self._error_details = errdetails.parse_error_details(details) self.kwargs = kwargs + # Deprecated. + self.details = [] + if details: + for d in details: + if not isinstance(d, dict): + continue + self.details.append(ErrorDetail.from_dict(d)) + def get_error_info(self) -> List[ErrorDetail]: - return self._get_details_by_type(DatabricksError._error_info_type) + return self._get_details_by_type(errdetails._ERROR_INFO_TYPE) def _get_details_by_type(self, error_type) -> List[ErrorDetail]: - if self.details == None: + if self.details is None: return [] return [detail for detail in self.details if detail.type == error_type] + def get_error_details(self) -> errdetails.ErrorDetails: + return self._error_details + @dataclass class _ErrorOverride: diff --git a/databricks/sdk/errors/details.py b/databricks/sdk/errors/details.py new file mode 100644 index 000000000..c7e7ca1a2 --- /dev/null +++ b/databricks/sdk/errors/details.py @@ -0,0 +1,417 @@ +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class ErrorInfo: + """Describes the cause of the error with structured details.""" + + reason: str + domain: str + metadata: Dict[str, str] + + +@dataclass +class RequestInfo: + """ + Contains metadata about the request that clients can attach when + filing a bug or providing other forms of feedback. + """ + + request_id: str + serving_data: str + + +@dataclass +class RetryInfo: + """ + Describes when the clients can retry a failed request. Clients could + ignore the recommendation here or retry when this information is missing + from error responses. + + It's always recommended that clients should use exponential backoff + when retrying. + + Clients should wait until `retry_delay` amount of time has passed since + receiving the error response before retrying. If retrying requests also + fail, clients should use an exponential backoff scheme to gradually + increase the delay between retries based on `retry_delay`, until either + a maximum number of retries have been reached or a maximum retry delay + cap has been reached. + """ + + retry_delay_seconds: float + + +@dataclass +class DebugInfo: + """Describes additional debugging info.""" + + stack_entries: List[str] + detail: str + + +@dataclass +class QuotaFailureViolation: + """Describes a single quota violation.""" + + subject: str + description: str + + +@dataclass +class QuotaFailure: + """ + Describes how a quota check failed. + + For example if a daily limit was exceeded for the calling project, a + service could respond with a QuotaFailure detail containing the project + id and the description of the quota limit that was exceeded. If the + calling project hasn't enabled the service in the developer console, + then a service could respond with the project id and set + `service_disabled` to true. + + Also see RetryInfo and Help types for other details about handling a + quota failure. + """ + + violations: List[QuotaFailureViolation] + + +@dataclass +class PreconditionFailureViolation: + """Describes a single precondition violation.""" + + type: str + subject: str + description: str + + +@dataclass +class PreconditionFailure: + """Describes what preconditions have failed.""" + + violations: List[PreconditionFailureViolation] + + +@dataclass +class BadRequestFieldViolation: + """Describes a single field violation in a bad request.""" + + field: str + description: str + + +@dataclass +class BadRequest: + """ + Describes violations in a client request. This error type + focuses on the syntactic aspects of the request. + """ + + field_violations: List[BadRequestFieldViolation] + + +@dataclass +class ResourceInfo: + """Describes the resource that is being accessed.""" + + resource_type: str + resource_name: str + owner: str + description: str + + +@dataclass +class HelpLink: + """Describes a single help link.""" + + description: str + url: str + + +@dataclass +class Help: + """ + Provides links to documentation or for performing an out of + band action. + + For example, if a quota check failed with an error indicating + the calling project hasn't enabled the accessed service, this + can contain a URL pointing directly to the right place in the + developer console to flip the bit. + """ + + links: List[HelpLink] + + +@dataclass +class ErrorDetails: + """ + ErrorDetails contains the error details of an API error. It + is the union of known error details types and unknown details. + """ + + error_info: Optional[ErrorInfo] = None + request_info: Optional[RequestInfo] = None + retry_info: Optional[RetryInfo] = None + debug_info: Optional[DebugInfo] = None + quota_failure: Optional[QuotaFailure] = None + precondition_failure: Optional[PreconditionFailure] = None + bad_request: Optional[BadRequest] = None + resource_info: Optional[ResourceInfo] = None + help: Optional[Help] = None + unknown_details: List[Any] = field(default_factory=list) + + +# Supported error details proto types. +_ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo" +_REQUEST_INFO_TYPE = "type.googleapis.com/google.rpc.RequestInfo" +_RETRY_INFO_TYPE = "type.googleapis.com/google.rpc.RetryInfo" +_DEBUG_INFO_TYPE = "type.googleapis.com/google.rpc.DebugInfo" +_QUOTA_FAILURE_TYPE = "type.googleapis.com/google.rpc.QuotaFailure" +_PRECONDITION_FAILURE_TYPE = "type.googleapis.com/google.rpc.PreconditionFailure" +_BAD_REQUEST_TYPE = "type.googleapis.com/google.rpc.BadRequest" +_RESOURCE_INFO_TYPE = "type.googleapis.com/google.rpc.ResourceInfo" +_HELP_TYPE = "type.googleapis.com/google.rpc.Help" + + +def parse_error_details(details: List[Any]) -> ErrorDetails: + ed = ErrorDetails() + + if not details: + return ed + + for d in details: + pd = _parse_json_error_details(d) + + if isinstance(pd, ErrorInfo): + ed.error_info = pd + elif isinstance(pd, RequestInfo): + ed.request_info = pd + elif isinstance(pd, RetryInfo): + ed.retry_info = pd + elif isinstance(pd, DebugInfo): + ed.debug_info = pd + elif isinstance(pd, QuotaFailure): + ed.quota_failure = pd + elif isinstance(pd, PreconditionFailure): + ed.precondition_failure = pd + elif isinstance(pd, BadRequest): + ed.bad_request = pd + elif isinstance(pd, ResourceInfo): + ed.resource_info = pd + elif isinstance(pd, Help): + ed.help = pd + else: + ed.unknown_details.append(pd) + + return ed + + +def _parse_json_error_details(value: Any) -> Any: + """ + Attempts to parse an error details type from the given JSON value. If the + value is not a known error details type, it returns the input as is. + + :param value: The JSON value to parse. + :return: The parsed error details type or the input value if it is not + a known error details type. + """ + + if not isinstance(value, dict): + return value # not a JSON object + + t = value.get("@type") + if not isinstance(t, str): + return value # JSON object with no @type field + + try: + if t == _ERROR_INFO_TYPE: + return _parse_error_info(value) + elif t == _REQUEST_INFO_TYPE: + return _parse_req_info(value) + elif t == _RETRY_INFO_TYPE: + return _parse_retry_info(value) + elif t == _DEBUG_INFO_TYPE: + return _parse_debug_info(value) + elif t == _QUOTA_FAILURE_TYPE: + return _parse_quota_failure(value) + elif t == _PRECONDITION_FAILURE_TYPE: + return _parse_precondition_failure(value) + elif t == _BAD_REQUEST_TYPE: + return _parse_bad_request(value) + elif t == _RESOURCE_INFO_TYPE: + return _parse_resource_info(value) + elif t == _HELP_TYPE: + return _parse_help(value) + else: # unknown type + return value + except (TypeError, ValueError): + return value # not a valid known type + except Exception: + return value + + +def _parse_error_info(d: Dict[str, Any]) -> ErrorInfo: + return ErrorInfo( + domain=_parse_string(d.get("domain", "")), + reason=_parse_string(d.get("reason", "")), + metadata=_parse_dict(d.get("metadata", {})), + ) + + +def _parse_req_info(d: Dict[str, Any]) -> RequestInfo: + return RequestInfo( + request_id=_parse_string(d.get("request_id", "")), + serving_data=_parse_string(d.get("serving_data", "")), + ) + + +def _parse_retry_info(d: Dict[str, Any]) -> RetryInfo: + delay = 0.0 + if "retry_delay" in d: + delay = _parse_seconds(d["retry_delay"]) + + return RetryInfo( + retry_delay_seconds=delay, + ) + + +def _parse_debug_info(d: Dict[str, Any]) -> DebugInfo: + di = DebugInfo( + stack_entries=[], + detail=_parse_string(d.get("detail", "")), + ) + + if "stack_entries" not in d: + return di + + if not isinstance(d["stack_entries"], list): + raise ValueError(f"Expected list, got {d['stack_entries']!r}") + for entry in d["stack_entries"]: + di.stack_entries.append(_parse_string(entry)) + + return di + + +def _parse_quota_failure_violation(d: Dict[str, Any]) -> QuotaFailureViolation: + return QuotaFailureViolation( + subject=_parse_string(d.get("subject", "")), + description=_parse_string(d.get("description", "")), + ) + + +def _parse_quota_failure(d: Dict[str, Any]) -> QuotaFailure: + violations = [] + if "violations" in d: + if not isinstance(d["violations"], list): + raise ValueError(f"Expected list, got {d['violations']!r}") + for violation in d["violations"]: + if not isinstance(violation, dict): + raise ValueError(f"Expected dict, got {violation!r}") + violations.append(_parse_quota_failure_violation(violation)) + return QuotaFailure(violations=violations) + + +def _parse_precondition_failure_violation(d: Dict[str, Any]) -> PreconditionFailureViolation: + return PreconditionFailureViolation( + type=_parse_string(d.get("type", "")), + subject=_parse_string(d.get("subject", "")), + description=_parse_string(d.get("description", "")), + ) + + +def _parse_precondition_failure(d: Dict[str, Any]) -> PreconditionFailure: + violations = [] + if "violations" in d: + if not isinstance(d["violations"], list): + raise ValueError(f"Expected list, got {d['violations']!r}") + for v in d["violations"]: + if not isinstance(v, dict): + raise ValueError(f"Expected dict, got {v!r}") + violations.append(_parse_precondition_failure_violation(v)) + return PreconditionFailure(violations=violations) + + +def _parse_bad_request_field_violation(d: Dict[str, Any]) -> BadRequestFieldViolation: + return BadRequestFieldViolation( + field=_parse_string(d.get("field", "")), + description=_parse_string(d.get("description", "")), + ) + + +def _parse_bad_request(d: Dict[str, Any]) -> BadRequest: + field_violations = [] + if "field_violations" in d: + if not isinstance(d["field_violations"], list): + raise ValueError(f"Expected list, got {d['field_violations']!r}") + for violation in d["field_violations"]: + if not isinstance(violation, dict): + raise ValueError(f"Expected dict, got {violation!r}") + field_violations.append(_parse_bad_request_field_violation(violation)) + return BadRequest(field_violations=field_violations) + + +def _parse_resource_info(d: Dict[str, Any]) -> ResourceInfo: + return ResourceInfo( + resource_type=_parse_string(d.get("resource_type", "")), + resource_name=_parse_string(d.get("resource_name", "")), + owner=_parse_string(d.get("owner", "")), + description=_parse_string(d.get("description", "")), + ) + + +def _parse_help_link(d: Dict[str, Any]) -> HelpLink: + return HelpLink( + description=_parse_string(d.get("description", "")), + url=_parse_string(d.get("url", "")), + ) + + +def _parse_help(d: Dict[str, Any]) -> Help: + links = [] + if "links" in d: + if not isinstance(d["links"], list): + raise ValueError(f"Expected list, got {d['links']!r}") + for link in d["links"]: + if not isinstance(link, dict): + raise ValueError(f"Expected dict, got {link!r}") + links.append(_parse_help_link(link)) + return Help(links=links) + + +def _parse_string(a: Any) -> str: + if isinstance(a, str): + return a + raise ValueError(f"Expected string, got {a!r}") + + +def _parse_dict(a: Any) -> Dict[str, str]: + if not isinstance(a, dict): + raise ValueError(f"Expected Dict[str, str], got {a!r}") + for key, value in a.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError(f"Expected Dict[str, str], got {a!r}") + return a + + +def _parse_seconds(a: Any) -> float: + """ + Parse a duration string into a float representing the number of seconds. + + The duration type is encoded as a string rather than an where the string + ends in the suffix "s" (indicating seconds) and is preceded by a decimal + number of seconds. For example, "3.000000001s", represents a duration of + 3 seconds and 1 nanosecond. + """ + + if not isinstance(a, str): + raise ValueError(f"Expected string, got {a!r}") + + match = re.match(r"^(\d+(\.\d+)?)s$", a) + if match: + return float(match.group(1)) + + raise ValueError(f"Expected duration string, got {a!r}") diff --git a/databricks/sdk/errors/parser.py b/databricks/sdk/errors/parser.py index 5861df770..64d83de05 100644 --- a/databricks/sdk/errors/parser.py +++ b/databricks/sdk/errors/parser.py @@ -91,3 +91,5 @@ def get_api_error(self, response: requests.Response) -> Optional[DatabricksError # is successful, but the response is not what we expect. We need to handle this case separately. if _is_private_link_redirect(response): return _get_private_link_validation_error(response.url) + + return None diff --git a/tests/test_base_client.py b/tests/test_base_client.py index 820c52c11..8b3501d49 100644 --- a/tests/test_base_client.py +++ b/tests/test_base_client.py @@ -70,7 +70,7 @@ def test_streaming_response_read_closes(config): "message": "errorMessage", "details": [ { - "type": DatabricksError._error_info_type, + "type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "error reason", "domain": "error domain", "metadata": {"etag": "error etag"}, @@ -87,7 +87,7 @@ def test_streaming_response_read_closes(config): "errorMessage", details=[ { - "type": DatabricksError._error_info_type, + "type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "error reason", "domain": "error domain", "metadata": {"etag": "error etag"}, @@ -553,7 +553,7 @@ def test_rewind_seekable_stream(test_case: RetryTestCase, failure: Tuple[Callabl client._session = session def do(): - client.do("POST", f"test.com/foo", data=data) + client.do("POST", "test.com/foo", data=data) if test_case._expected_failure: expected_attempts_made = 1 diff --git a/tests/test_errors.py b/tests/test_errors.py index 0e775bd50..e0032900f 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,11 +1,13 @@ import http.client import json -from typing import List, Optional, Tuple +from dataclasses import dataclass, field +from typing import Any, List, Optional import pytest import requests from databricks.sdk import errors +from databricks.sdk.errors import details def fake_response( @@ -39,10 +41,14 @@ def fake_valid_response( error_code: str, message: str, path: Optional[str] = None, + details: List[Any] = [], ) -> requests.Response: body = {"message": message} if error_code: body["error_code"] = error_code + if len(details) > 0: + body["details"] = details + return fake_response(method, status_code, json.dumps(body), path) @@ -55,176 +61,313 @@ def make_private_link_response() -> requests.Response: return resp -# This should be `(int, str, type)` but doesn't work in Python 3.7-3.8. -base_subclass_test_cases: List[Tuple[int, str, type]] = [ - (400, "", errors.BadRequest), - (400, "INVALID_PARAMETER_VALUE", errors.BadRequest), - (400, "INVALID_PARAMETER_VALUE", errors.InvalidParameterValue), - (400, "REQUEST_LIMIT_EXCEEDED", errors.TooManyRequests), - (400, "", IOError), - (401, "", errors.Unauthenticated), - (401, "", IOError), - (403, "", errors.PermissionDenied), - (403, "", IOError), - (404, "", errors.NotFound), - (404, "", IOError), - (409, "", errors.ResourceConflict), - (409, "ABORTED", errors.Aborted), - (409, "ABORTED", errors.ResourceConflict), - (409, "ALREADY_EXISTS", errors.AlreadyExists), - (409, "ALREADY_EXISTS", errors.ResourceConflict), - (409, "", IOError), - (429, "", errors.TooManyRequests), - (429, "REQUEST_LIMIT_EXCEEDED", errors.TooManyRequests), - (429, "REQUEST_LIMIT_EXCEEDED", errors.RequestLimitExceeded), - (429, "RESOURCE_EXHAUSTED", errors.TooManyRequests), - (429, "RESOURCE_EXHAUSTED", errors.ResourceExhausted), - (429, "", IOError), - (499, "", errors.Cancelled), - (499, "", IOError), - (500, "", errors.InternalError), - (500, "UNKNOWN", errors.InternalError), - (500, "UNKNOWN", errors.Unknown), - (500, "DATA_LOSS", errors.InternalError), - (500, "DATA_LOSS", errors.DataLoss), - (500, "", IOError), - (501, "", errors.NotImplemented), - (501, "", IOError), - (503, "", errors.TemporarilyUnavailable), - (503, "", IOError), - (504, "", errors.DeadlineExceeded), - (504, "", IOError), - (444, "", errors.DatabricksError), - (444, "", IOError), -] +@dataclass +class TestCase: + name: str + response: requests.Response + want_err_type: type + want_message: str = "" + want_details: details.ErrorDetails = field(default_factory=details.ErrorDetails) -subclass_test_cases = [(fake_valid_response("GET", x[0], x[1], "nope"), x[2], "nope") for x in base_subclass_test_cases] +_basic_test_cases_no_details = [ + TestCase( + name=f'{x[0]} "{x[1]}" {x[2]}', + response=fake_valid_response("GET", x[0], x[1], "nope"), + want_err_type=x[2], + want_message="nope", + ) + for x in [ + (400, "", errors.BadRequest), + (400, "INVALID_PARAMETER_VALUE", errors.BadRequest), + (400, "INVALID_PARAMETER_VALUE", errors.InvalidParameterValue), + (400, "REQUEST_LIMIT_EXCEEDED", errors.TooManyRequests), + (400, "", IOError), + (401, "", errors.Unauthenticated), + (401, "", IOError), + (403, "", errors.PermissionDenied), + (403, "", IOError), + (404, "", errors.NotFound), + (404, "", IOError), + (409, "", errors.ResourceConflict), + (409, "ABORTED", errors.Aborted), + (409, "ABORTED", errors.ResourceConflict), + (409, "ALREADY_EXISTS", errors.AlreadyExists), + (409, "ALREADY_EXISTS", errors.ResourceConflict), + (409, "", IOError), + (429, "", errors.TooManyRequests), + (429, "REQUEST_LIMIT_EXCEEDED", errors.TooManyRequests), + (429, "REQUEST_LIMIT_EXCEEDED", errors.RequestLimitExceeded), + (429, "RESOURCE_EXHAUSTED", errors.TooManyRequests), + (429, "RESOURCE_EXHAUSTED", errors.ResourceExhausted), + (429, "", IOError), + (499, "", errors.Cancelled), + (499, "", IOError), + (500, "", errors.InternalError), + (500, "UNKNOWN", errors.InternalError), + (500, "UNKNOWN", errors.Unknown), + (500, "DATA_LOSS", errors.InternalError), + (500, "DATA_LOSS", errors.DataLoss), + (500, "", IOError), + (501, "", errors.NotImplemented), + (501, "", IOError), + (503, "", errors.TemporarilyUnavailable), + (503, "", IOError), + (504, "", errors.DeadlineExceeded), + (504, "", IOError), + (444, "", errors.DatabricksError), + (444, "", IOError), + ] +] -@pytest.mark.parametrize( - "response, expected_error, expected_message", - subclass_test_cases - + [ - (fake_response("GET", 400, ""), errors.BadRequest, "Bad Request"), - ( - fake_valid_response("GET", 417, "WHOOPS", "nope"), - errors.DatabricksError, - "nope", - ), - ( - fake_valid_response("GET", 522, "", "nope"), - errors.DatabricksError, - "nope", + +_test_case_with_details = [ + TestCase( + name="all_error_details", + response=fake_valid_response( + method="GET", + status_code=404, + error_code="NOT_FOUND", + message="test message", + details=[ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "reason", + "domain": "domain", + "metadata": {"k1": "v1", "k2": "v2"}, + }, + { + "@type": "type.googleapis.com/google.rpc.RequestInfo", + "request_id": "req42", + "serving_data": "data", + }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retry_delay": "42.000000001s", + }, + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "stack_entries": ["entry1", "entry2"], + "detail": "detail", + }, + { + "@type": "type.googleapis.com/google.rpc.QuotaFailure", + "violations": [{"subject": "subject", "description": "description"}], + }, + { + "@type": "type.googleapis.com/google.rpc.PreconditionFailure", + "violations": [{"type": "type", "subject": "subject", "description": "description"}], + }, + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + "field_violations": [{"field": "field", "description": "description"}], + }, + { + "@type": "type.googleapis.com/google.rpc.ResourceInfo", + "resource_type": "resource_type", + "resource_name": "resource_name", + "owner": "owner", + "description": "description", + }, + { + "@type": "type.googleapis.com/google.rpc.Help", + "links": [{"description": "description", "url": "url"}], + }, + ], ), - ( - make_private_link_response(), - errors.PrivateLinkValidationError, - ( - "The requested workspace has AWS PrivateLink enabled and is not accessible from the current network. " - "Ensure that AWS PrivateLink is properly configured and that your device has access to the AWS VPC " - "endpoint. For more information, see " - "https://docs.databricks.com/en/security/network/classic/privatelink.html." + want_err_type=errors.NotFound, + want_message="test message", + want_details=details.ErrorDetails( + error_info=details.ErrorInfo( + reason="reason", + domain="domain", + metadata={"k1": "v1", "k2": "v2"}, ), - ), - ( - fake_valid_response( - "GET", - 400, - "INVALID_PARAMETER_VALUE", - "Cluster abcde does not exist", - "/api/2.0/clusters/get", + request_info=details.RequestInfo( + request_id="req42", + serving_data="data", ), - errors.ResourceDoesNotExist, - "Cluster abcde does not exist", - ), - ( - fake_valid_response( - "GET", - 400, - "INVALID_PARAMETER_VALUE", - "Job abcde does not exist", - "/api/2.0/jobs/get", + retry_info=details.RetryInfo( + retry_delay_seconds=42.000000001, ), - errors.ResourceDoesNotExist, - "Job abcde does not exist", - ), - ( - fake_valid_response( - "GET", - 400, - "INVALID_PARAMETER_VALUE", - "Job abcde does not exist", - "/api/2.1/jobs/get", + debug_info=details.DebugInfo( + stack_entries=["entry1", "entry2"], + detail="detail", ), - errors.ResourceDoesNotExist, - "Job abcde does not exist", - ), - ( - fake_valid_response( - "GET", - 400, - "INVALID_PARAMETER_VALUE", - "Invalid spark version", - "/api/2.1/jobs/get", + quota_failure=details.QuotaFailure( + violations=[ + details.QuotaFailureViolation( + subject="subject", + description="description", + ) + ], ), - errors.InvalidParameterValue, - "Invalid spark version", - ), - ( - fake_response( - "GET", - 400, - "MALFORMED_REQUEST: vpc_endpoints malformed parameters: VPC Endpoint ... with use_case ... cannot be attached in ... list", + precondition_failure=details.PreconditionFailure( + violations=[ + details.PreconditionFailureViolation( + type="type", + subject="subject", + description="description", + ) + ], + ), + bad_request=details.BadRequest( + field_violations=[ + details.BadRequestFieldViolation( + field="field", + description="description", + ) + ], ), - errors.BadRequest, - "vpc_endpoints malformed parameters: VPC Endpoint ... with use_case ... cannot be attached in ... list", + resource_info=details.ResourceInfo( + resource_type="resource_type", + resource_name="resource_name", + owner="owner", + description="description", + ), + help=details.Help( + links=[ + details.HelpLink( + description="description", + url="url", + ) + ], + ), + unknown_details=[], ), - ( - fake_response("GET", 400, "
Worker environment not ready"), - errors.BadRequest, - "Worker environment not ready", + ), + TestCase( + name="unknown_error_details", + response=fake_valid_response( + method="GET", + status_code=404, + error_code="NOT_FOUND", + message="test message", + details=[ + 1, + "foo", + ["foo", "bar"], + { + "@type": "type.googleapis.com/google.rpc.FooBar", + "reason": "reason", + "domain": "domain", + }, + ], ), - ( - fake_response("GET", 400, "this is not a real response"), - errors.BadRequest, - ( - "unable to parse response. This is likely a bug in the Databricks SDK for Python or the underlying API. " - "Please report this issue with the following debugging information to the SDK issue tracker at " - "https://github.com/databricks/databricks-sdk-go/issues. Request log:```GET /api/2.0/service\n" - "< 400 Bad Request\n" - "< this is not a real response```" - ), + want_err_type=errors.NotFound, + want_message="test message", + want_details=details.ErrorDetails( + unknown_details=[ + 1, + "foo", + ["foo", "bar"], + { + "@type": "type.googleapis.com/google.rpc.FooBar", + "reason": "reason", + "domain": "domain", + }, + ], ), - ( - fake_response( - "GET", - 404, - json.dumps( - { - "detail": "Group with id 1234 is not found", - "status": "404", - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - } - ), + ), +] + +_test_case_other_errors = [ + TestCase( + name="private_link_validation_error", + response=make_private_link_response(), + want_err_type=errors.PrivateLinkValidationError, + want_message=( + "The requested workspace has AWS PrivateLink enabled and is not accessible from the current network. " + "Ensure that AWS PrivateLink is properly configured and that your device has access to the AWS VPC " + "endpoint. For more information, see " + "https://docs.databricks.com/en/security/network/classic/privatelink.html." + ), + ), + TestCase( + name="malformed_request", + response=fake_response( + method="GET", + status_code=400, + response_body="MALFORMED_REQUEST: vpc_endpoints malformed parameters: VPC Endpoint ... with use_case ... cannot be attached in ... list", + ), + want_err_type=errors.BadRequest, + want_message="vpc_endpoints malformed parameters: VPC Endpoint ... with use_case ... cannot be attached in ... list", + ), + TestCase( + name="worker_environment_not_ready", + response=fake_response( + method="GET", + status_code=400, + response_body="
Worker environment not ready", + ), + want_err_type=errors.BadRequest, + want_message="Worker environment not ready", + ), + TestCase( + name="unable_to_parse_response", + response=fake_response( + method="GET", + status_code=400, + response_body="this is not a real response", + ), + want_err_type=errors.BadRequest, + want_message=( + "unable to parse response. This is likely a bug in the Databricks SDK for Python or the underlying API. " + "Please report this issue with the following debugging information to the SDK issue tracker at " + "https://github.com/databricks/databricks-sdk-go/issues. Request log:```GET /api/2.0/service\n" + "< 400 Bad Request\n" + "< this is not a real response```" + ), + ), + TestCase( + name="group_not_found", + response=fake_response( + method="GET", + status_code=404, + response_body=json.dumps( + { + "detail": "Group with id 1234 is not found", + "status": "404", + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + } ), - errors.NotFound, - "None Group with id 1234 is not found", ), - ( - fake_response("GET", 404, json.dumps("This is JSON but not a dictionary")), - errors.NotFound, - 'unable to parse response. This is likely a bug in the Databricks SDK for Python or the underlying API. Please report this issue with the following debugging information to the SDK issue tracker at https://github.com/databricks/databricks-sdk-go/issues. Request log:```GET /api/2.0/service\n< 404 Not Found\n< "This is JSON but not a dictionary"```', + want_err_type=errors.NotFound, + want_message="None Group with id 1234 is not found", + ), + TestCase( + name="unable_to_parse_response2", + response=fake_response( + method="GET", + status_code=404, + response_body=json.dumps("This is JSON but not a dictionary"), ), - ( - fake_raw_response("GET", 404, b"\x80"), - errors.NotFound, - "unable to parse response. This is likely a bug in the Databricks SDK for Python or the underlying API. Please report this issue with the following debugging information to the SDK issue tracker at https://github.com/databricks/databricks-sdk-go/issues. Request log:```GET /api/2.0/service\n< 404 Not Found\n< �```", + want_err_type=errors.NotFound, + want_message='unable to parse response. This is likely a bug in the Databricks SDK for Python or the underlying API. Please report this issue with the following debugging information to the SDK issue tracker at https://github.com/databricks/databricks-sdk-go/issues. Request log:```GET /api/2.0/service\n< 404 Not Found\n< "This is JSON but not a dictionary"```', + ), + TestCase( + name="unable_to_parse_response3", + response=fake_raw_response( + method="GET", + status_code=404, + response_body=b"\x80", ), - ], -) -def test_get_api_error(response, expected_error, expected_message): + want_err_type=errors.NotFound, + want_message="unable to parse response. This is likely a bug in the Databricks SDK for Python or the underlying API. Please report this issue with the following debugging information to the SDK issue tracker at https://github.com/databricks/databricks-sdk-go/issues. Request log:```GET /api/2.0/service\n< 404 Not Found\n< �```", + ), +] + + +_all_test_cases = _basic_test_cases_no_details + _test_case_with_details + _test_case_other_errors + + +@pytest.mark.parametrize("test_case", [pytest.param(x, id=x.name) for x in _all_test_cases]) +def test_get_api_error(test_case: TestCase): parser = errors._Parser() + with pytest.raises(errors.DatabricksError) as e: - raise parser.get_api_error(response) - assert isinstance(e.value, expected_error) - assert str(e.value) == expected_message + raise parser.get_api_error(test_case.response) + + assert isinstance(e.value, test_case.want_err_type) + assert str(e.value) == test_case.want_message + assert e.value.get_error_details() == test_case.want_details