Skip to content

Commit 45a356c

Browse files
authored
Override INVALID_PARAMETER_VALUE on fetching non-existent job/cluster (#591)
## Changes Port of databricks/databricks-sdk-go#864 to the Python SDK. Most services use RESOURCE_DOES_NOT_EXIST error code with 404 status code to indicate that a resource doesn't exist. However, for legacy reasons, Jobs and Clusters services use INVALID_PARAMETER_VALUE error code with 400 status code instead. This makes tools like Terraform and UCX difficult to maintain, as these services need different error handling logic. However, we can't change these behaviors as customers already depend on the raw HTTP response status & contents. This PR corrects these errors in the SDK itself. SDK users can then do ``` try: w.jobs.get_by_id('abc') except ResourceDoesNotExist: pass ``` just as you would expect from other resources. Updated the README with more information about this as well. ## Tests <!-- How is this tested? Please see the checklist below and also describe any other relevant tests --> - [ ] `make test` run locally - [ ] `make fmt` applied - [ ] relevant integration tests applied
1 parent a17758d commit 45a356c

File tree

9 files changed

+162
-9
lines changed

9 files changed

+162
-9
lines changed

.codegen.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
},
99
"batch": {
1010
".codegen/__init__.py.tmpl": "databricks/sdk/__init__.py",
11-
".codegen/error_mapping.py.tmpl": "databricks/sdk/errors/platform.py"
11+
".codegen/error_mapping.py.tmpl": "databricks/sdk/errors/platform.py",
12+
".codegen/error_overrides.py.tmpl": "databricks/sdk/errors/overrides.py"
1213
},
1314
"samples": {
1415
".codegen/example.py.tmpl": "examples/{{.Service.SnakeName}}/{{.Method.SnakeName}}_{{.SnakeName}}.py"

.codegen/error_overrides.py.tmpl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Code generated from OpenAPI specs by Databricks SDK Generator. DO NOT EDIT.
2+
3+
from .base import _ErrorOverride
4+
from .platform import *
5+
import re
6+
7+
8+
_ALL_OVERRIDES = [
9+
{{ range .ErrorOverrides -}}
10+
_ErrorOverride(
11+
debug_name="{{.Name}}",
12+
path_regex=re.compile(r'{{.PathRegex}}'),
13+
verb="{{.Verb}}",
14+
status_code_matcher=re.compile(r'{{.StatusCodeMatcher}}'),
15+
error_code_matcher=re.compile(r'{{.ErrorCodeMatcher}}'),
16+
message_matcher=re.compile(r'{{.MessageMatcher}}'),
17+
custom_error={{.OverrideErrorCode.PascalName}},
18+
),
19+
{{- end }}
20+
]

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
databricks/sdk/__init__.py linguist-generated=true
2+
databricks/sdk/errors/overrides.py linguist-generated=true
23
databricks/sdk/errors/platform.py linguist-generated=true
34
databricks/sdk/service/billing.py linguist-generated=true
45
databricks/sdk/service/catalog.py linguist-generated=true

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The SDK's internal HTTP client is robust and handles failures on different level
3030
- [Long-running operations](#long-running-operations)
3131
- [Paginated responses](#paginated-responses)
3232
- [Single-sign-on with OAuth](#single-sign-on-sso-with-oauth)
33+
- [Error handling](#error-handling)
3334
- [Logging](#logging)
3435
- [Integration with `dbutils`](#interaction-with-dbutils)
3536
- [Interface stability](#interface-stability)
@@ -507,6 +508,23 @@ logging.info(f'Created new custom app: '
507508
f'--client_secret {custom_app.client_secret}')
508509
```
509510

511+
## Error handling<a id="error-handling"></a>
512+
513+
The Databricks SDK for Python provides a robust error-handling mechanism that allows developers to catch and handle API errors. When an error occurs, the SDK will raise an exception that contains information about the error, such as the HTTP status code, error message, and error details. Developers can catch these exceptions and handle them appropriately in their code.
514+
515+
```python
516+
from databricks.sdk import WorkspaceClient
517+
from databricks.sdk.errors import ResourceDoesNotExist
518+
519+
w = WorkspaceClient()
520+
try:
521+
w.clusters.get(cluster_id='1234-5678-9012')
522+
except ResourceDoesNotExist as e:
523+
print(f'Cluster not found: {e}')
524+
```
525+
526+
The SDK handles inconsistencies in error responses amongst the different services, providing a consistent interface for developers to work with. Simply catch the appropriate exception type and handle the error as needed. The errors returned by the Databricks API are defined in [databricks/sdk/errors/platform.py](https://github.com/databricks/databricks-sdk-py/blob/main/databricks/sdk/errors/platform.py).
527+
510528
## Logging<a id="logging"></a>
511529

512530
The Databricks SDK for Python seamlessly integrates with the standard [Logging facility for Python](https://docs.python.org/3/library/logging.html).

databricks/sdk/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ def _make_nicer_error(self, *, response: requests.Response, **kwargs) -> Databri
264264
if is_too_many_requests_or_unavailable:
265265
kwargs['retry_after_secs'] = self._parse_retry_after(response)
266266
kwargs['message'] = message
267-
return error_mapper(status_code, kwargs)
267+
return error_mapper(response, kwargs)
268268

269269
def _record_request_log(self, response: requests.Response, raw=False):
270270
if not logger.isEnabledFor(logging.DEBUG):

databricks/sdk/errors/base.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
from typing import Dict, List
1+
import re
2+
from dataclasses import dataclass
3+
from typing import Dict, List, Optional
4+
5+
import requests
26

37

48
class ErrorDetail:
@@ -63,3 +67,43 @@ def _get_details_by_type(self, error_type) -> List[ErrorDetail]:
6367
if self.details == None:
6468
return []
6569
return [detail for detail in self.details if detail.type == error_type]
70+
71+
72+
@dataclass
73+
class _ErrorOverride:
74+
# The name of the override. Used for logging purposes.
75+
debug_name: str
76+
77+
# A regex that must match the path of the request for this override to be applied.
78+
path_regex: re.Pattern
79+
80+
# The HTTP method of the request for the override to apply
81+
verb: str
82+
83+
# The custom error class to use for this override.
84+
custom_error: type
85+
86+
# A regular expression that must match the error code for this override to be applied. If None,
87+
# this field is ignored.
88+
status_code_matcher: Optional[re.Pattern] = None
89+
90+
# A regular expression that must match the error code for this override to be applied. If None,
91+
# this field is ignored.
92+
error_code_matcher: Optional[re.Pattern] = None
93+
94+
# A regular expression that must match the message for this override to be applied. If None,
95+
# this field is ignored.
96+
message_matcher: Optional[re.Pattern] = None
97+
98+
def matches(self, response: requests.Response, raw_error: dict):
99+
if response.request.method != self.verb:
100+
return False
101+
if not self.path_regex.match(response.request.path_url):
102+
return False
103+
if self.status_code_matcher and not self.status_code_matcher.match(str(response.status_code)):
104+
return False
105+
if self.error_code_matcher and not self.error_code_matcher.match(raw_error.get('error_code', '')):
106+
return False
107+
if self.message_matcher and not self.message_matcher.match(raw_error.get('message', '')):
108+
return False
109+
return True

databricks/sdk/errors/mapper.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import requests
2+
13
from databricks.sdk.errors import platform
24
from databricks.sdk.errors.base import DatabricksError
35

6+
from .overrides import _ALL_OVERRIDES
7+
48

5-
def error_mapper(status_code: int, raw: dict) -> DatabricksError:
9+
def error_mapper(response: requests.Response, raw: dict) -> DatabricksError:
10+
for override in _ALL_OVERRIDES:
11+
if override.matches(response, raw):
12+
return override.custom_error(**raw)
13+
status_code = response.status_code
614
error_code = raw.get('error_code', None)
715
if error_code in platform.ERROR_CODE_MAPPING:
816
# more specific error codes override more generic HTTP status codes

databricks/sdk/errors/overrides.py

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/test_errors.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,36 @@
11
import pytest
2+
import requests
23

34
from databricks.sdk import errors
45

56

7+
def fake_response(status_code: int) -> requests.Response:
8+
resp = requests.Response()
9+
resp.status_code = status_code
10+
resp.request = requests.Request('GET', 'https://databricks.com/api/2.0/service').prepare()
11+
return resp
12+
13+
614
def test_error_code_has_precedence_over_http_status():
7-
err = errors.error_mapper(400, {'error_code': 'INVALID_PARAMETER_VALUE', 'message': 'nope'})
15+
err = errors.error_mapper(fake_response(400), {
16+
'error_code': 'INVALID_PARAMETER_VALUE',
17+
'message': 'nope'
18+
})
819
assert errors.InvalidParameterValue == type(err)
920

1021

1122
def test_http_status_code_maps_fine():
12-
err = errors.error_mapper(400, {'error_code': 'MALFORMED_REQUEST', 'message': 'nope'})
23+
err = errors.error_mapper(fake_response(400), {'error_code': 'MALFORMED_REQUEST', 'message': 'nope'})
1324
assert errors.BadRequest == type(err)
1425

1526

1627
def test_other_errors_also_map_fine():
17-
err = errors.error_mapper(417, {'error_code': 'WHOOPS', 'message': 'nope'})
28+
err = errors.error_mapper(fake_response(417), {'error_code': 'WHOOPS', 'message': 'nope'})
1829
assert errors.DatabricksError == type(err)
1930

2031

2132
def test_missing_error_code():
22-
err = errors.error_mapper(522, {'message': 'nope'})
33+
err = errors.error_mapper(fake_response(522), {'message': 'nope'})
2334
assert errors.DatabricksError == type(err)
2435

2536

@@ -48,6 +59,31 @@ def test_missing_error_code():
4859
(444, ..., errors.DatabricksError), (444, ..., IOError), ])
4960
def test_subclasses(status_code, error_code, klass):
5061
try:
51-
raise errors.error_mapper(status_code, {'error_code': error_code, 'message': 'nope'})
62+
raise errors.error_mapper(fake_response(status_code), {'error_code': error_code, 'message': 'nope'})
5263
except klass:
5364
return
65+
66+
67+
@pytest.mark.parametrize('verb, path, status_code, error_code, message, expected_error',
68+
[[
69+
'GET', '/api/2.0/clusters/get', 400, 'INVALID_PARAMETER_VALUE',
70+
'Cluster abcde does not exist', errors.ResourceDoesNotExist
71+
],
72+
[
73+
'GET', '/api/2.0/jobs/get', 400, 'INVALID_PARAMETER_VALUE',
74+
'Job abcde does not exist', errors.ResourceDoesNotExist
75+
],
76+
[
77+
'GET', '/api/2.1/jobs/get', 400, 'INVALID_PARAMETER_VALUE',
78+
'Job abcde does not exist', errors.ResourceDoesNotExist
79+
],
80+
[
81+
'GET', '/api/2.1/jobs/get', 400, 'INVALID_PARAMETER_VALUE',
82+
'Invalid spark version', errors.InvalidParameterValue
83+
], ])
84+
def test_error_overrides(verb, path, status_code, error_code, message, expected_error):
85+
resp = requests.Response()
86+
resp.status_code = status_code
87+
resp.request = requests.Request(verb, f'https://databricks.com{path}').prepare()
88+
with pytest.raises(expected_error):
89+
raise errors.error_mapper(resp, {'error_code': error_code, 'message': message})

0 commit comments

Comments
 (0)