Skip to content
Open
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies = [
"python-odata>=0.7.0",
"cloudevents>=2.2.0,<3",
"cryptography>=48.0.1,<51",
"cachetools~=5.5.2",
]

[project.optional-dependencies]
Expand Down
66 changes: 32 additions & 34 deletions src/sap_cloud_sdk/core/protocol/http/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,29 @@
from __future__ import annotations

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
from typing import Any, Callable, Optional

import requests
from oauthlib.oauth2 import BackendApplicationClient
from oauthlib.oauth2.rfc6749.errors import FatalClientError
from requests_oauthlib import OAuth2Session

from sap_cloud_sdk.core.resilience.cache._cache import Cache
from sap_cloud_sdk.core.resilience.cache._config import CacheConfig
from sap_cloud_sdk.core.resilience.cache._isolation import IsolationStrategy
from sap_cloud_sdk.core._tenant import (
_validate_tenant_subdomain,
_derive_tenant_token_url,
_validate_tenant_subdomain,
)

logger = logging.getLogger(__name__)

_TOKEN_EXPIRY_BUFFER_SECONDS = 60
_DEFAULT_TIMEOUT = 30.0
_TOKEN_NAMESPACE = "xsuaa_token"
_TOKEN_FETCH_MAX_ATTEMPTS = 3
_TOKEN_FETCH_BACKOFF_BASE = 0.5 # seconds; delay = base * 2^attempt

Expand Down Expand Up @@ -61,7 +63,8 @@ def close(self) -> None:
class XsuaaAuthProvider(AuthProvider):
"""OAuth2 client-credentials auth provider for XSUAA.

Caches tokens per tenant subdomain with expiry-aware eviction.
Caches tokens per tenant subdomain with expiry-aware eviction backed by the
SDK :class:`~sap_cloud_sdk.core.resilience.cache._cache.Cache` module.
Re-reads credentials from the binding on every token refresh so that
rotated secrets are picked up automatically.
Detects rotation proactively by checking the secret directory mtime before
Expand All @@ -83,22 +86,22 @@ def __init__(
self._config_factory = config_factory
self._config = config_factory()
self._timeout = timeout
self._cache: dict[Optional[str], tuple[OAuth2Session, datetime]] = {}
self._lock = threading.Lock()
self._cache: Cache = Cache(
CacheConfig(isolation_strategy=IsolationStrategy.TENANT),
module="core.protocol.http",
)

def get_session(self, tenant_subdomain: Optional[str] = None) -> OAuth2Session:
has_changed = getattr(self._config_factory, "has_changed", None)
if callable(has_changed) and has_changed():
with self._lock:
logger.debug("Invalidating token cache due to secret rotation")
self._invalidate_all_unsafe()
logger.debug("Invalidating token cache due to secret rotation")
self._cache.reset()

with self._lock:
cached = self._cache.get(tenant_subdomain)
if cached is not None:
oauth, expires_at = cached
if datetime.now() < expires_at:
return oauth
cached: OAuth2Session | None = self._cache.get(
_TOKEN_NAMESPACE, _token_key(tenant_subdomain)
)
if cached is not None:
return cached

return self._fetch_token(tenant_subdomain)

Expand Down Expand Up @@ -157,15 +160,14 @@ def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session:
) from last_exc

expires_in: int = token.get("expires_in", 3600)
expires_at = datetime.now() + timedelta(
seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS
)
ttl = max(expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS, 1)

with self._lock:
existing = self._cache.get(tenant_subdomain)
if existing is not None:
existing[0].close()
self._cache[tenant_subdomain] = (oauth, expires_at)
key = _token_key(tenant_subdomain)
# Close the stale session before overwriting to release its connection pool.
stale: OAuth2Session | None = self._cache.get(_TOKEN_NAMESPACE, key)
if stale is not None:
stale.close()
self._cache.set(_TOKEN_NAMESPACE, key, oauth, ttl_seconds=ttl)

logger.debug(
"Obtained OAuth2 token for tenant=%r (expires in %ds)",
Expand All @@ -175,18 +177,10 @@ def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session:
return oauth

def invalidate(self, tenant_subdomain: Optional[str] = None) -> None:
with self._lock:
self._cache.pop(tenant_subdomain, None)
self._cache.evict(_TOKEN_NAMESPACE, _token_key(tenant_subdomain))

def invalidate_all(self) -> None:
with self._lock:
self._invalidate_all_unsafe()

def _invalidate_all_unsafe(self) -> None:
"""Invalidate all cached sessions. Caller must hold ``self._lock``."""
for oauth, _ in self._cache.values():
oauth.close()
self._cache.clear()
self._cache.reset()

def close(self) -> None:
self.invalidate_all()
Expand All @@ -200,3 +194,7 @@ def base_url(self) -> Optional[str]:
expose a ``base_url`` attribute (e.g. the destination module uses ``url``).
"""
return getattr(self._config, "base_url", None)


def _token_key(tenant_subdomain: Optional[str]) -> str:
return tenant_subdomain if tenant_subdomain is not None else "__provider__"
Empty file.
57 changes: 57 additions & 0 deletions src/sap_cloud_sdk/core/resilience/cache/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""SAP Cloud SDK for Python - Cache module.

Provides a domain-agnostic, pluggable cache layer shared across all SDK
modules. Supports tenant and tenant-user isolation, configurable TTL and
expiry buffers, LRU eviction, and custom backends for multi-instance
deployments.

Global configuration example::

from sap_cloud_sdk.core.resilience.cache import CacheConfig, configure_cache

configure_cache(CacheConfig(
default_ttl_seconds=600,
expiry_buffer_seconds=60,
max_size=2000,
))

Disabling the cache for a specific client::

from sap_cloud_sdk.destination import create_client
from sap_cloud_sdk.core.resilience.cache import CacheConfig

client = create_client(cache_config=CacheConfig(enabled=False))

Custom backend example (Redis)::

from sap_cloud_sdk.core.resilience.cache import CacheBackend, CacheConfig, configure_cache

class RedisCacheBackend(CacheBackend):
def get(self, key): ...
def set(self, key, value, ttl_seconds): ...
def delete(self, key): ...
def clear(self): ...

configure_cache(CacheConfig(backend=RedisCacheBackend(...)))
"""

from sap_cloud_sdk.core.resilience.cache._backend import CacheBackend
from sap_cloud_sdk.core.resilience.cache._config import (
CacheConfig,
configure_cache,
get_cache_config,
)
from sap_cloud_sdk.core.resilience.cache._isolation import IsolationStrategy
from sap_cloud_sdk.core.resilience.cache._lru_backend import InMemoryLRUBackend
from sap_cloud_sdk.core.resilience.cache.exceptions import BackendError, CacheError

__all__ = [
"CacheBackend",
"CacheConfig",
"configure_cache",
"get_cache_config",
"IsolationStrategy",
"InMemoryLRUBackend",
"CacheError",
"BackendError",
]
35 changes: 35 additions & 0 deletions src/sap_cloud_sdk/core/resilience/cache/_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Abstract cache backend interface."""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any


class CacheBackend(ABC):
"""Domain-agnostic key/value cache backend.

The backend has no awareness of tenants, TTL policy, namespaces, or domain
types. All of that is handled by the :class:`~sap_cloud_sdk.core.resilience.cache._cache.Cache`
façade before keys and values reach the backend.

Implement this to plug in any shared cache (Redis, Memcached, etc.) for
multi-instance deployments (Kyma ``replicas > 1``, Cloud Foundry
``instances > 1``).
"""

@abstractmethod
def get(self, key: str) -> Any | None:
"""Return the value for *key*, or ``None`` if absent or expired."""

@abstractmethod
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
"""Store *value* under *key* with a time-to-live in seconds."""

@abstractmethod
def delete(self, key: str) -> None:
"""Remove the entry for *key* (no-op if absent)."""

@abstractmethod
def clear(self) -> None:
"""Remove all entries."""
Loading
Loading