Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions src/sap_cloud_sdk/core/protocol/http/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

import logging
import threading
import time
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
from enum import Enum

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 (
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Comment thread
NicoleMGomes marked this conversation as resolved.
Resolved
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(
Expand Down
93 changes: 92 additions & 1 deletion tests/core/unit/test_http_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Loading