Skip to content

Commit 61c07e8

Browse files
authored
feat(airbyte-cdk): Have better fallback error message on HTTP error (#43399)
1 parent f2b2a63 commit 61c07e8

File tree

8 files changed

+145
-22
lines changed

8 files changed

+145
-22
lines changed

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/composite_error_handler.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import requests
99
from airbyte_cdk.sources.streams.http.error_handlers import ErrorHandler
10-
from airbyte_cdk.sources.streams.http.error_handlers.response_models import DEFAULT_ERROR_RESOLUTION, ErrorResolution, ResponseAction
10+
from airbyte_cdk.sources.streams.http.error_handlers.response_models import (
11+
ErrorResolution,
12+
ResponseAction,
13+
create_fallback_error_resolution,
14+
)
1115

1216

1317
@dataclass
@@ -69,4 +73,4 @@ def interpret_response(self, response_or_exception: Optional[Union[requests.Resp
6973
if matched_error_resolution:
7074
return matched_error_resolution
7175

72-
return DEFAULT_ERROR_RESOLUTION
76+
return create_fallback_error_resolution(response_or_exception)

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/default_error_handler.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
from airbyte_cdk.sources.declarative.requesters.error_handlers.default_http_response_filter import DefaultHttpResponseFilter
1010
from airbyte_cdk.sources.declarative.requesters.error_handlers.http_response_filter import HttpResponseFilter
1111
from airbyte_cdk.sources.streams.http.error_handlers import BackoffStrategy, ErrorHandler
12-
from airbyte_cdk.sources.streams.http.error_handlers.response_models import DEFAULT_ERROR_RESOLUTION, SUCCESS_RESOLUTION, ErrorResolution
12+
from airbyte_cdk.sources.streams.http.error_handlers.response_models import (
13+
SUCCESS_RESOLUTION,
14+
ErrorResolution,
15+
create_fallback_error_resolution,
16+
)
1317
from airbyte_cdk.sources.types import Config
1418

1519

@@ -114,7 +118,11 @@ def interpret_response(self, response_or_exception: Optional[Union[requests.Resp
114118
default_reponse_filter = DefaultHttpResponseFilter(parameters={}, config=self.config)
115119
default_response_filter_resolution = default_reponse_filter.matches(response_or_exception)
116120

117-
return default_response_filter_resolution if default_response_filter_resolution else DEFAULT_ERROR_RESOLUTION
121+
return (
122+
default_response_filter_resolution
123+
if default_response_filter_resolution
124+
else create_fallback_error_resolution(response_or_exception)
125+
)
118126

119127
def backoff_time(
120128
self, response_or_exception: Optional[Union[requests.Response, requests.RequestException]], attempt_count: int = 0

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/default_http_response_filter.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import requests
88
from airbyte_cdk.sources.declarative.requesters.error_handlers.http_response_filter import HttpResponseFilter
99
from airbyte_cdk.sources.streams.http.error_handlers.default_error_mapping import DEFAULT_ERROR_MAPPING
10-
from airbyte_cdk.sources.streams.http.error_handlers.response_models import DEFAULT_ERROR_RESOLUTION, ErrorResolution
10+
from airbyte_cdk.sources.streams.http.error_handlers.response_models import ErrorResolution, create_fallback_error_resolution
1111

1212

1313
class DefaultHttpResponseFilter(HttpResponseFilter):
@@ -25,4 +25,6 @@ def matches(self, response_or_exception: Optional[Union[requests.Response, Excep
2525

2626
default_mapped_error_resolution = DEFAULT_ERROR_MAPPING.get(mapped_key)
2727

28-
return default_mapped_error_resolution if default_mapped_error_resolution else DEFAULT_ERROR_RESOLUTION
28+
return (
29+
default_mapped_error_resolution if default_mapped_error_resolution else create_fallback_error_resolution(response_or_exception)
30+
)

airbyte-cdk/python/airbyte_cdk/sources/streams/http/error_handlers/response_models.py

+33-6
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
from dataclasses import dataclass
44
from enum import Enum
5-
from typing import Optional
5+
from typing import Optional, Union
66

7+
import requests
78
from airbyte_cdk.models import FailureType
9+
from airbyte_cdk.utils.airbyte_secrets_utils import filter_secrets
10+
from requests import HTTPError
811

912

1013
class ResponseAction(Enum):
@@ -22,10 +25,34 @@ class ErrorResolution:
2225
error_message: Optional[str] = None
2326

2427

25-
DEFAULT_ERROR_RESOLUTION = ErrorResolution(
26-
response_action=ResponseAction.RETRY,
27-
failure_type=FailureType.system_error,
28-
error_message="The request failed due to an unknown error.",
29-
)
28+
def _format_exception_error_message(exception: Exception) -> str:
29+
return f"{type(exception).__name__}: {str(exception)}"
30+
31+
32+
def _format_response_error_message(response: requests.Response) -> str:
33+
try:
34+
response.raise_for_status()
35+
except HTTPError as exception:
36+
return filter_secrets(f"Response was not ok: `{str(exception)}`. Response content is: {response.text}")
37+
# We purposefully do not add the response.content because the response is "ok" so there might be sensitive information in the payload.
38+
# Feel free the
39+
return f"Unexpected response with HTTP status {response.status_code}"
40+
41+
42+
def create_fallback_error_resolution(response_or_exception: Optional[Union[requests.Response, Exception]]) -> ErrorResolution:
43+
if response_or_exception is None:
44+
# We do not expect this case to happen but if it does, it would be good to understand the cause and improve the error message
45+
error_message = "Error handler did not receive a valid response or exception. This is unexpected please contact Airbyte Support"
46+
elif isinstance(response_or_exception, Exception):
47+
error_message = _format_exception_error_message(response_or_exception)
48+
else:
49+
error_message = _format_response_error_message(response_or_exception)
50+
51+
return ErrorResolution(
52+
response_action=ResponseAction.RETRY,
53+
failure_type=FailureType.system_error,
54+
error_message=error_message,
55+
)
56+
3057

3158
SUCCESS_RESOLUTION = ErrorResolution(response_action=ResponseAction.SUCCESS, failure_type=None, error_message=None)

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/test_composite_error_handler.py

+19
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from airbyte_cdk.sources.declarative.requesters.error_handlers.composite_error_handler import CompositeErrorHandler
1111
from airbyte_cdk.sources.declarative.requesters.error_handlers.default_error_handler import DefaultErrorHandler
1212
from airbyte_cdk.sources.streams.http.error_handlers.response_models import ErrorResolution, ResponseAction
13+
from airbyte_protocol.models import FailureType
1314

1415
SOME_BACKOFF_TIME = 60
1516

@@ -97,6 +98,24 @@ def test_composite_error_handler(test_name, first_handler_behavior, second_handl
9798
assert retrier.interpret_response(response_mock) == expected_behavior
9899

99100

101+
def test_given_unmatched_response_or_exception_then_return_default_error_resolution():
102+
composite_error_handler = CompositeErrorHandler(
103+
error_handlers=[
104+
DefaultErrorHandler(
105+
response_filters=[],
106+
parameters={},
107+
config={},
108+
)
109+
],
110+
parameters={},
111+
)
112+
113+
error_resolution = composite_error_handler.interpret_response(ValueError("Any error"))
114+
115+
assert error_resolution.response_action == ResponseAction.RETRY
116+
assert error_resolution.failure_type == FailureType.system_error
117+
118+
100119
def test_composite_error_handler_no_handlers():
101120
try:
102121
CompositeErrorHandler(error_handlers=[], parameters={})

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/test_default_error_handler.py

+5-8
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,7 @@
1212
)
1313
from airbyte_cdk.sources.declarative.requesters.error_handlers.default_error_handler import DefaultErrorHandler, HttpResponseFilter
1414
from airbyte_cdk.sources.streams.http.error_handlers.default_error_mapping import DEFAULT_ERROR_MAPPING
15-
from airbyte_cdk.sources.streams.http.error_handlers.response_models import (
16-
DEFAULT_ERROR_RESOLUTION,
17-
ErrorResolution,
18-
FailureType,
19-
ResponseAction,
20-
)
15+
from airbyte_cdk.sources.streams.http.error_handlers.response_models import ErrorResolution, FailureType, ResponseAction
2116

2217
SOME_BACKOFF_TIME = 60
2318

@@ -55,7 +50,7 @@
5550
ErrorResolution(
5651
response_action=ResponseAction.RETRY,
5752
failure_type=FailureType.system_error,
58-
error_message="The request failed due to an unknown error.",
53+
error_message="Unexpected response with HTTP status 418",
5954
),
6055
)
6156
],
@@ -246,7 +241,9 @@ def test_default_error_handler_with_unmapped_http_code():
246241
response_mock.ok = False
247242
response_mock.headers = {}
248243
actual_error_resolution = error_handler.interpret_response(response_mock)
249-
assert actual_error_resolution == DEFAULT_ERROR_RESOLUTION
244+
assert actual_error_resolution
245+
assert actual_error_resolution.failure_type == FailureType.system_error
246+
assert actual_error_resolution.response_action == ResponseAction.RETRY
250247

251248

252249
def test_predicate_takes_precedent_over_default_mapped_error():

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/test_default_http_response_filter.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import pytest
88
from airbyte_cdk.sources.declarative.requesters.error_handlers.default_http_response_filter import DefaultHttpResponseFilter
99
from airbyte_cdk.sources.streams.http.error_handlers.default_error_mapping import DEFAULT_ERROR_MAPPING
10-
from airbyte_cdk.sources.streams.http.error_handlers.response_models import DEFAULT_ERROR_RESOLUTION
10+
from airbyte_cdk.sources.streams.http.error_handlers.response_models import ResponseAction
11+
from airbyte_protocol.models import FailureType
1112
from requests import RequestException, Response
1213

1314

@@ -69,4 +70,6 @@ def test_unmapped_http_status_code_returns_default_error_resolution():
6970
)
7071

7172
actual_error_resolution = response_filter.matches(response)
72-
assert actual_error_resolution == DEFAULT_ERROR_RESOLUTION
73+
assert actual_error_resolution
74+
assert actual_error_resolution.failure_type == FailureType.system_error
75+
assert actual_error_resolution.response_action == ResponseAction.RETRY
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
3+
from unittest import TestCase
4+
5+
import requests
6+
import requests_mock
7+
from airbyte_cdk.sources.streams.http.error_handlers.response_models import ResponseAction, create_fallback_error_resolution
8+
from airbyte_cdk.utils.airbyte_secrets_utils import update_secrets
9+
from airbyte_protocol.models import FailureType
10+
11+
_A_SECRET = "a-secret"
12+
_A_URL = "https://a-url.com"
13+
14+
15+
class DefaultErrorResolutionTest(TestCase):
16+
17+
def setUp(self) -> None:
18+
update_secrets([_A_SECRET])
19+
20+
def tearDown(self) -> None:
21+
# to avoid other tests being impacted by added secrets
22+
update_secrets([])
23+
24+
def test_given_none_when_create_fallback_error_resolution_then_return_error_resolution(self) -> None:
25+
error_resolution = create_fallback_error_resolution(None)
26+
27+
assert error_resolution.failure_type == FailureType.system_error
28+
assert error_resolution.response_action == ResponseAction.RETRY
29+
assert error_resolution.error_message == "Error handler did not receive a valid response or exception. This is unexpected please contact Airbyte Support"
30+
31+
def test_given_exception_when_create_fallback_error_resolution_then_return_error_resolution(self) -> None:
32+
exception = ValueError("This is an exception")
33+
34+
error_resolution = create_fallback_error_resolution(exception)
35+
36+
assert error_resolution.failure_type == FailureType.system_error
37+
assert error_resolution.response_action == ResponseAction.RETRY
38+
assert error_resolution.error_message
39+
assert "ValueError" in error_resolution.error_message
40+
assert str(exception) in error_resolution.error_message
41+
42+
def test_given_response_can_raise_for_status_when_create_fallback_error_resolution_then_error_resolution(self) -> None:
43+
response = self._create_response(512)
44+
45+
error_resolution = create_fallback_error_resolution(response)
46+
47+
assert error_resolution.failure_type == FailureType.system_error
48+
assert error_resolution.response_action == ResponseAction.RETRY
49+
assert error_resolution.error_message and "512 Server Error: None for url: https://a-url.com/" in error_resolution.error_message
50+
51+
def test_given_response_is_ok_when_create_fallback_error_resolution_then_error_resolution(self) -> None:
52+
response = self._create_response(205)
53+
54+
error_resolution = create_fallback_error_resolution(response)
55+
56+
assert error_resolution.failure_type == FailureType.system_error
57+
assert error_resolution.response_action == ResponseAction.RETRY
58+
assert error_resolution.error_message and str(response.status_code) in error_resolution.error_message
59+
60+
def _create_response(self, status_code: int) -> requests.Response:
61+
with requests_mock.Mocker() as http_mocker:
62+
http_mocker.get(_A_URL, status_code=status_code)
63+
return requests.get(_A_URL)

0 commit comments

Comments
 (0)