diff --git a/src/sap_cloud_sdk/core/protocol/http/models.py b/src/sap_cloud_sdk/core/protocol/http/models.py index f5e0e09f..76fffc6a 100644 --- a/src/sap_cloud_sdk/core/protocol/http/models.py +++ b/src/sap_cloud_sdk/core/protocol/http/models.py @@ -4,6 +4,7 @@ import logging import threading +import time from abc import ABC, abstractmethod from datetime import datetime, timedelta from typing import Any, Callable, Optional @@ -11,6 +12,7 @@ import requests from oauthlib.oauth2 import BackendApplicationClient +from oauthlib.oauth2.rfc6749.errors import FatalClientError from requests_oauthlib import OAuth2Session from sap_cloud_sdk.core._tenant import ( @@ -22,6 +24,8 @@ _TOKEN_EXPIRY_BUFFER_SECONDS = 60 _DEFAULT_TIMEOUT = 30.0 +_TOKEN_FETCH_MAX_ATTEMPTS = 3 +_TOKEN_FETCH_BACKOFF_BASE = 0.5 # seconds; delay = base * 2^attempt class HttpMethod(Enum): @@ -121,16 +125,36 @@ def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: client = BackendApplicationClient(client_id=str(self._config.client_id)) oauth = OAuth2Session(client=client) - try: - token = oauth.fetch_token( - token_url=token_url, - client_id=str(self._config.client_id), - client_secret=str(self._config.client_secret), - include_client_id=True, - timeout=self._timeout, - ) - except Exception as exc: - raise RuntimeError(f"Failed to obtain OAuth2 token: {exc}") from exc + last_exc: Exception | None = None + for attempt in range(_TOKEN_FETCH_MAX_ATTEMPTS): + try: + token = oauth.fetch_token( + token_url=token_url, + client_id=str(self._config.client_id), + client_secret=str(self._config.client_secret), + include_client_id=True, + timeout=self._timeout, + ) + last_exc = None + break + except FatalClientError: + raise + except Exception as exc: + last_exc = exc + if attempt < _TOKEN_FETCH_MAX_ATTEMPTS - 1: + delay = _TOKEN_FETCH_BACKOFF_BASE * (2**attempt) + logger.warning( + "OAuth2 token fetch failed (attempt %d/%d), retrying in %.1fs: %s", + attempt + 1, + _TOKEN_FETCH_MAX_ATTEMPTS, + delay, + exc, + ) + time.sleep(delay) + if last_exc is not None: + raise RuntimeError( + f"Failed to obtain OAuth2 token after {_TOKEN_FETCH_MAX_ATTEMPTS} attempts: {last_exc}" + ) from last_exc expires_in: int = token.get("expires_in", 3600) expires_at = datetime.now() + timedelta( diff --git a/tests/core/unit/test_http_client.py b/tests/core/unit/test_http_client.py index 83d4c997..a2e2836f 100644 --- a/tests/core/unit/test_http_client.py +++ b/tests/core/unit/test_http_client.py @@ -1,6 +1,8 @@ """Tests for HttpClient and XsuaaAuthProvider.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch + +from oauthlib.oauth2.rfc6749.errors import InvalidClientError import pytest import requests @@ -306,3 +308,92 @@ def test_identityzone_in_path_is_not_replaced(self): call_kwargs = mock_session.fetch_token.call_args[1] # Only the first hostname label must be replaced; path segment untouched assert call_kwargs["token_url"] == "https://tenant-abc.authentication.region/provider-zone/token" + + +class TestXsuaaAuthProviderTokenFetchRetry: + """Retry + backoff on transient OAuth2 token-fetch failures (issue #6810).""" + + def _make_provider(self) -> XsuaaAuthProvider: + cfg = MagicMock() + cfg.token_url = "https://provider.authentication.region/oauth/token" + cfg.identityzone = "provider" + cfg.client_id = "cid" + cfg.client_secret = "csecret" + cfg.base_url = None + factory = MagicMock(return_value=cfg) + factory.has_changed = MagicMock(return_value=False) + return XsuaaAuthProvider(factory) + + def test_succeeds_on_first_attempt(self): + provider = self._make_provider() + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_cls, \ + patch("sap_cloud_sdk.core.protocol.http.models.time.sleep") as mock_sleep: + mock_session = MagicMock() + mock_cls.return_value = mock_session + mock_session.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} + provider._fetch_token(None) + assert mock_session.fetch_token.call_count == 1 + mock_sleep.assert_not_called() + + def test_retries_on_transient_failure_then_succeeds(self): + provider = self._make_provider() + transient = RuntimeError("missing_token") + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_cls, \ + patch("sap_cloud_sdk.core.protocol.http.models.time.sleep") as mock_sleep: + mock_session = MagicMock() + mock_cls.return_value = mock_session + mock_session.fetch_token.side_effect = [ + transient, + {"access_token": "tok", "expires_in": 3600}, + ] + provider._fetch_token(None) + assert mock_session.fetch_token.call_count == 2 + mock_sleep.assert_called_once_with(0.5) + + def test_retries_exhaust_and_raises_runtime_error(self): + provider = self._make_provider() + err = Exception("IAS token endpoint unavailable") + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_cls, \ + patch("sap_cloud_sdk.core.protocol.http.models.time.sleep"): + mock_session = MagicMock() + mock_cls.return_value = mock_session + mock_session.fetch_token.side_effect = err + with pytest.raises(RuntimeError, match="Failed to obtain OAuth2 token after 3 attempts"): + provider._fetch_token(None) + assert mock_session.fetch_token.call_count == 3 + + def test_backoff_delays_are_exponential(self): + provider = self._make_provider() + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_cls, \ + patch("sap_cloud_sdk.core.protocol.http.models.time.sleep") as mock_sleep: + mock_session = MagicMock() + mock_cls.return_value = mock_session + mock_session.fetch_token.side_effect = Exception("transient") + with pytest.raises(RuntimeError): + provider._fetch_token(None) + assert mock_sleep.call_args_list == [call(0.5), call(1.0)] + + def test_no_sleep_after_final_attempt(self): + """The last attempt must not be followed by a sleep before raising.""" + provider = self._make_provider() + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_cls, \ + patch("sap_cloud_sdk.core.protocol.http.models.time.sleep") as mock_sleep: + mock_session = MagicMock() + mock_cls.return_value = mock_session + mock_session.fetch_token.side_effect = Exception("transient") + with pytest.raises(RuntimeError): + provider._fetch_token(None) + assert mock_sleep.call_count == 2 # only after attempts 1 and 2, not 3 + + def test_fatal_client_error_raises_immediately_without_retry(self): + provider = self._make_provider() + fatal = InvalidClientError() + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_cls, \ + patch("sap_cloud_sdk.core.protocol.http.models.time.sleep") as mock_sleep: + mock_session = MagicMock() + mock_cls.return_value = mock_session + mock_session.fetch_token.side_effect = fatal + with pytest.raises(InvalidClientError): + provider._fetch_token(None) + assert mock_session.fetch_token.call_count == 1 + mock_sleep.assert_not_called()