Skip to content

Commit 1256a1f

Browse files
devin-ai-integration[bot]bot_apk
andauthored
fix: retry transient network errors during OAuth token refresh (#987)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk <apk@cognition.ai>
1 parent 60346c9 commit 1256a1f

File tree

3 files changed

+99
-5
lines changed

3 files changed

+99
-5
lines changed

airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,18 @@ def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]:
179179
180180
:return: a tuple of (access_token, token_lifespan)
181181
"""
182-
response_json = self._make_handled_request()
182+
try:
183+
response_json = self._make_handled_request()
184+
except (
185+
requests.exceptions.ConnectionError,
186+
requests.exceptions.ConnectTimeout,
187+
requests.exceptions.ReadTimeout,
188+
) as e:
189+
raise AirbyteTracedException(
190+
message="OAuth access token refresh request failed due to a network error.",
191+
internal_message=f"Network error during OAuth token refresh after retries were exhausted: {e}",
192+
failure_type=FailureType.transient_error,
193+
) from e
183194
self._ensure_access_token_in_response(response_json)
184195

185196
return (
@@ -229,7 +240,12 @@ def _wrap_refresh_token_exception(
229240

230241
@backoff.on_exception(
231242
backoff.expo,
232-
DefaultBackoffException,
243+
(
244+
DefaultBackoffException,
245+
requests.exceptions.ConnectionError,
246+
requests.exceptions.ConnectTimeout,
247+
requests.exceptions.ReadTimeout,
248+
),
233249
on_backoff=lambda details: logger.info(
234250
f"Caught retryable error after {details['tries']} tries. Waiting {details['wait']} seconds then retrying..."
235251
),
@@ -295,7 +311,11 @@ def _make_handled_request(self) -> Any:
295311
)
296312
raise
297313
except Exception as e:
298-
raise Exception(f"Error while refreshing access token: {e}") from e
314+
raise AirbyteTracedException(
315+
message="OAuth access token refresh request failed.",
316+
internal_message=f"Unexpected error during OAuth token refresh: {e}",
317+
failure_type=FailureType.system_error,
318+
) from e
299319

300320
def _ensure_access_token_in_response(self, response_data: Mapping[str, Any]) -> None:
301321
"""

unit_tests/connector_builder/test_connector_builder_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1288,7 +1288,7 @@ def test_handle_read_external_requests(deployment_mode, url_base, expected_error
12881288
pytest.param(
12891289
"CLOUD",
12901290
"https://10.0.27.27/tokens/bearer",
1291-
"Error while refreshing access token",
1291+
"OAuth access token refresh request failed.",
12921292
id="test_cloud_read_with_private_endpoint",
12931293
),
12941294
pytest.param(

unit_tests/sources/declarative/auth/test_oauth.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@
77
import logging
88
from copy import deepcopy
99
from datetime import timedelta, timezone
10-
from unittest.mock import Mock
10+
from unittest.mock import Mock, patch
1111

1212
import freezegun
1313
import pytest
1414
import requests
1515
from requests import Response
1616

17+
from airbyte_cdk.models import FailureType
1718
from airbyte_cdk.sources.declarative.auth import DeclarativeOauth2Authenticator
1819
from airbyte_cdk.sources.declarative.auth.jwt import JwtAuthenticator
1920
from airbyte_cdk.test.mock_http import HttpMocker, HttpRequest, HttpResponse
21+
from airbyte_cdk.utils import AirbyteTracedException
2022
from airbyte_cdk.utils.airbyte_secrets_utils import filter_secrets
2123
from airbyte_cdk.utils.datetime_helpers import AirbyteDateTime, ab_datetime_now, ab_datetime_parse
2224

@@ -645,3 +647,75 @@ def mock_request(method, url, data, headers):
645647
raise Exception(
646648
f"Error while refreshing access token with request: {method}, {url}, {data}, {headers}"
647649
)
650+
651+
652+
class TestOauth2AuthenticatorTransientErrorHandling:
653+
"""Tests for transient network error handling during OAuth token refresh."""
654+
655+
def _create_authenticator(self):
656+
return DeclarativeOauth2Authenticator(
657+
token_refresh_endpoint="{{ config['refresh_endpoint'] }}",
658+
client_id="{{ config['client_id'] }}",
659+
client_secret="{{ config['client_secret'] }}",
660+
refresh_token="{{ parameters['refresh_token'] }}",
661+
config=config,
662+
token_expiry_date="{{ config['token_expiry_date'] }}",
663+
parameters=parameters,
664+
)
665+
666+
@pytest.mark.parametrize(
667+
"exception_class",
668+
[
669+
requests.exceptions.ConnectionError,
670+
requests.exceptions.ConnectTimeout,
671+
requests.exceptions.ReadTimeout,
672+
],
673+
ids=["ConnectionError", "ConnectTimeout", "ReadTimeout"],
674+
)
675+
def test_transient_network_error_wrapped_as_transient_error(self, exception_class):
676+
"""Transient network errors during OAuth refresh are wrapped in AirbyteTracedException with transient_error."""
677+
oauth = self._create_authenticator()
678+
with patch.object(
679+
oauth, "_make_handled_request", side_effect=exception_class("connection reset")
680+
):
681+
with pytest.raises(AirbyteTracedException) as exc_info:
682+
oauth.refresh_access_token()
683+
684+
assert exc_info.value.failure_type == FailureType.transient_error
685+
assert "network error" in exc_info.value.message.lower()
686+
687+
def test_connection_error_is_retried_before_raising(self, mocker):
688+
"""ConnectionError triggers backoff retries in _make_handled_request before propagating."""
689+
oauth = self._create_authenticator()
690+
691+
call_count = 0
692+
693+
def request_side_effect(**kwargs):
694+
nonlocal call_count
695+
call_count += 1
696+
if call_count < 3:
697+
raise requests.exceptions.ConnectionError("connection reset by peer")
698+
mock_response = Mock(spec=requests.Response)
699+
mock_response.ok = True
700+
mock_response.json.return_value = {"access_token": "token_value", "expires_in": 3600}
701+
return mock_response
702+
703+
mocker.patch("requests.request", side_effect=request_side_effect)
704+
# Patch backoff to avoid actual delays in tests
705+
mocker.patch("time.sleep")
706+
707+
token, _ = oauth.refresh_access_token()
708+
assert token == "token_value"
709+
assert call_count == 3
710+
711+
def test_generic_exception_wrapped_as_system_error(self, mocker):
712+
"""Generic exceptions during OAuth refresh are wrapped in AirbyteTracedException with system_error."""
713+
oauth = self._create_authenticator()
714+
mocker.patch("requests.request", side_effect=ValueError("unexpected parsing error"))
715+
mocker.patch("time.sleep")
716+
717+
with pytest.raises(AirbyteTracedException) as exc_info:
718+
oauth.refresh_access_token()
719+
720+
assert exc_info.value.failure_type == FailureType.system_error
721+
assert "OAuth access token refresh request failed" in exc_info.value.message

0 commit comments

Comments
 (0)