From 92b27ab99b156f105609ec0745ff2625bd37415c Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Fri, 3 Jul 2026 18:10:43 -0300 Subject: [PATCH 1/7] feat: add method to return destination service instance id --- src/sap_cloud_sdk/core/telemetry/operation.py | 1 + src/sap_cloud_sdk/destination/_models.py | 5 +++ src/sap_cloud_sdk/destination/client.py | 39 ++++++++++++++++ src/sap_cloud_sdk/destination/user-guide.md | 19 ++++++++ tests/core/unit/telemetry/test_operation.py | 10 +++-- tests/destination/unit/test_client.py | 44 +++++++++++++++++++ 6 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index f5be13b2..b43bb346 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -27,6 +27,7 @@ class Operation(str, Enum): DESTINATION_UPDATE_DESTINATION = "update_destination" DESTINATION_DELETE_DESTINATION = "delete_destination" DESTINATION_GET_DESTINATION = "get_destination" + DESTINATION_GET_SERVICE_INSTANCE_ID = "get_service_instance_id" # Destination Label Operations DESTINATION_GET_LABELS = "get_destination_labels" diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index 03a30579..3cc542a7 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -1000,3 +1000,8 @@ def set_header(self, header: TransparentProxyHeader, value: str) -> None: ``` """ self.headers[header.value] = value + + +@dataclass +class _DestinationInstanceConfig: + instanceid: str = "" diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index 20bf3c60..207f72e2 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import warnings from typing import List, Optional, Callable, TypeVar @@ -18,6 +19,7 @@ PatchLabels, TransparentProxy, TransparentProxyDestination, + _DestinationInstanceConfig, ) from sap_cloud_sdk.destination.config import load_transparent_proxy from sap_cloud_sdk.destination.exceptions import ( @@ -31,6 +33,8 @@ T = TypeVar("T") +logger = logging.getLogger(__name__) + _SUBACCOUNT_COLLECTION = "subaccountDestinations" _INSTANCE_COLLECTION = "instanceDestinations" @@ -665,6 +669,41 @@ def patch_destination_labels( f"failed to patch labels for destination '{name}': {e}" ) + @record_metrics(Module.DESTINATION, Operation.DESTINATION_GET_SERVICE_INSTANCE_ID) + def get_service_instance_id(self) -> str: + """Read the destination service instance ID from mounted secrets. + + Resolves ``instanceid`` via the common secret resolver + (base mount ``/etc/secrets/appfnd``, env var prefix ``CLOUD_SDK_CFG``, + module ``destination``, instance ``default``). + + Returns: + The instance ID string, or ``""`` if the secret cannot be resolved. + + Raises: + Nothing — resolution failures are caught, logged at WARNING level, + and an empty string is returned instead. + """ + try: + from sap_cloud_sdk.core.secret_resolver import ( + read_from_mount_and_fallback_to_env_var, + ) + + config = _DestinationInstanceConfig() + read_from_mount_and_fallback_to_env_var( + base_volume_mount="/etc/secrets/appfnd", + base_var_name="CLOUD_SDK_CFG", + module="destination", + instance="default", + target=config, + ) + return config.instanceid + except Exception: + logger.warning( + "Could not resolve destination instance ID from secrets — instanceId will be empty" + ) + return "" + # ---------- Internal helpers ---------- def _get_destination( diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index dc6b8778..41e22555 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -737,3 +737,22 @@ The OAuth2 token URL is derived from service binding (`DestinationConfig.token_u - Current implementation omits explicit HTTP retries/timeouts for simplicity. - The v2 consumption API (`get_destination`) is supported for runtime scenarios requiring automatic token retrieval. + +## Utilities + +### `get_service_instance_id()` + +Reads the destination service instance ID from the mounted secret binding. This is useful when you need the instance ID for programmatic use (e.g., to pass it to another service or for logging). + +```python +from sap_cloud_sdk.destination import get_service_instance_id + +instance_id = get_service_instance_id() +``` + +The function reads the `instanceid` field from the secret resolved via the standard mount/env fallback: + +- **Mount path**: `/etc/secrets/appfnd/destination/default/instanceid` +- **Env var fallback**: `CLOUD_SDK_CFG_DESTINATION_DEFAULT_INSTANCEID` + +Returns `""` and logs a `WARNING` if the secret cannot be resolved (e.g., running locally without a binding). diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index f2ae6779..372a837f 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -54,6 +54,10 @@ def test_destination_operations(self): assert Operation.DESTINATION_CREATE_DESTINATION.value == "create_destination" assert Operation.DESTINATION_UPDATE_DESTINATION.value == "update_destination" assert Operation.DESTINATION_DELETE_DESTINATION.value == "delete_destination" + assert ( + Operation.DESTINATION_GET_SERVICE_INSTANCE_ID.value + == "get_service_instance_id" + ) def test_certificate_operations(self): """Test Certificate operation values.""" @@ -210,7 +214,7 @@ def test_operation_iteration(self): def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) - # 3 auditlog + 11 destination + 10 certificate + 10 fragment + 8 objectstore + # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 4 aicore + 23 dms + 5 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print = 151 - assert len(all_operations) == 152 + # + 5 data_anonymization + 52 adms + 6 print = 153 + assert len(all_operations) == 153 diff --git a/tests/destination/unit/test_client.py b/tests/destination/unit/test_client.py index 45d8967b..18aa8bff 100644 --- a/tests/destination/unit/test_client.py +++ b/tests/destination/unit/test_client.py @@ -2174,3 +2174,47 @@ def test_patch_destination_labels_without_tenant_uses_provider_context(self): _, kwargs = mock_http.patch.call_args assert kwargs["tenant_subdomain"] is None + + +_RESOLVER_PATCH = "sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var" + + +class TestGetServiceInstanceId: + """Tests for DestinationClient.get_service_instance_id().""" + + @patch(_RESOLVER_PATCH) + def test_returns_instanceid_on_success(self, mock_read): + def fill_instanceid(*args, **kwargs): + kwargs["target"].instanceid = "my-instance-id" + + mock_read.side_effect = fill_instanceid + client = DestinationClient(MagicMock()) + + result = client.get_service_instance_id() + + assert result == "my-instance-id" + mock_read.assert_called_once_with( + base_volume_mount="/etc/secrets/appfnd", + base_var_name="CLOUD_SDK_CFG", + module="destination", + instance="default", + target=mock_read.call_args[1]["target"], + ) + + @patch(_RESOLVER_PATCH, side_effect=RuntimeError("mount failed")) + def test_returns_empty_string_on_exception(self, _mock_read): + client = DestinationClient(MagicMock()) + + result = client.get_service_instance_id() + + assert result == "" + + @patch(_RESOLVER_PATCH, side_effect=RuntimeError("mount failed")) + def test_logs_warning_on_exception(self, _mock_read): + client = DestinationClient(MagicMock()) + + with patch("sap_cloud_sdk.destination.client.logger") as mock_logger: + client.get_service_instance_id() + + mock_logger.warning.assert_called_once() + assert "instanceId" in mock_logger.warning.call_args[0][0] From 7d9bd19710406ec8ad087209633a04ca6236a942 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Fri, 3 Jul 2026 19:25:01 -0300 Subject: [PATCH 2/7] feat: add method to return ias client id on agw --- src/sap_cloud_sdk/agentgateway/_lob.py | 28 +++++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 40 +++++++++- src/sap_cloud_sdk/agentgateway/user-guide.md | 16 ++++ src/sap_cloud_sdk/core/telemetry/operation.py | 1 + tests/agentgateway/unit/test_agw_client.py | 75 +++++++++++++++++++ tests/agentgateway/unit/test_lob.py | 54 +++++++++++++ tests/core/unit/telemetry/test_operation.py | 6 +- 7 files changed, 215 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 0ada5b63..8a531427 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -114,6 +114,34 @@ def _fetch_auth_token( return raw_token, gateway_url +def get_ias_client_id_lob() -> str: + """Read the IAS client ID from the IAS destination properties (LoB flow). + + Fetches the IAS destination (``sap-managed-runtime-ias-{landscape}``) + at provider subaccount level and returns the ``clientId`` property. + + Returns: + The IAS client ID string, or ``""`` if the destination is not found + or the property is absent. + + Raises: + EnvironmentError: If ``APPFND_CONHOS_LANDSCAPE`` is not set. + Any exception raised by the destination client. + """ + dest_name = _ias_dest_name() + client = create_destination_client(instance=_DESTINATION_INSTANCE) + dest = client.get_destination( + dest_name, + level=ConsumptionLevel.PROVIDER_SUBACCOUNT, + ) + if not dest: + logger.warning( + "IAS destination '%s' not found — clientId will be empty", dest_name + ) + return "" + return dest.properties.get("clientId", "") + + async def fetch_system_auth( tenant_subdomain: str, token_cache: _TokenCache | None = None, diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 411ff4a8..c5a23f6e 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -25,6 +25,7 @@ fetch_system_auth, fetch_user_auth, get_agent_cards_lob, + get_ias_client_id_lob, get_mcp_tools_lob, ) from sap_cloud_sdk.agentgateway._models import ( @@ -295,9 +296,44 @@ async def get_user_auth( logger.exception("Unexpected error during user auth exchange") raise AgentGatewaySDKError(f"User auth exchange failed: {e}") from e + @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_IAS_CLIENT_ID) + def get_ias_client_id(self) -> str: + """Read the IAS client ID. + + Automatically detects agent type (LoB vs Customer) based on + credential file presence. + + - Customer agents: Returns ``client_id`` directly from the credentials file. + - LoB agents: Fetches the IAS destination + (``sap-managed-runtime-ias-{landscape}``) at provider subaccount level + and returns the ``clientId`` destination property. + + Returns: + The IAS client ID string, or ``""`` if it cannot be resolved. + + Raises: + Nothing — failures are caught, logged at WARNING level, + and an empty string is returned instead. + """ + try: + credentials_path = detect_customer_agent_credentials() + if credentials_path: + logger.info( + "Customer agent credentials detected at '%s'", credentials_path + ) + credentials = load_customer_credentials(credentials_path) + return credentials.client_id + + # LoB flow — read clientId from the IAS destination properties + return get_ias_client_id_lob() + except Exception: + logger.warning( + "Could not resolve IAS client ID from destination — clientId will be empty" + ) + return "" + @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_MCP_TOOLS) - async def list_mcp_tools( - self, + async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, ) -> list[MCPTool]: diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index d31ace2c..d65e648b 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -218,6 +218,22 @@ class AgentGatewayClient: self, filter: AgentCardFilter | None = None, ) -> list[Agent] + + def get_ias_client_id(self) -> str +``` + +#### `get_ias_client_id()` + +Returns the IAS client ID. Automatically detects agent type: + +- **Customer agents**: reads `client_id` directly from the mounted credentials file. +- **LoB agents**: fetches the IAS destination (`sap-managed-runtime-ias-{landscape}`) at provider subaccount level and returns the `clientId` destination property. + +Returns `""` and logs a `WARNING` if the value cannot be resolved. + +```python +agw_client = create_client(tenant_subdomain="my-tenant") +client_id = agw_client.get_ias_client_id() ``` ### AgentCardFilter diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index b43bb346..6472918c 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -189,6 +189,7 @@ class Operation(str, Enum): AGENTGATEWAY_GET_SYSTEM_AUTH = "get_system_auth" AGENTGATEWAY_GET_USER_AUTH = "get_user_auth" AGENTGATEWAY_LIST_AGENT_CARDS = "list_agent_cards" + AGENTGATEWAY_GET_IAS_CLIENT_ID = "get_ias_client_id" # Agent Memory Operations AGENT_MEMORY_ADD_MEMORY = "add_memory" diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index b1541fa5..3095c706 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -980,3 +980,78 @@ async def test_wraps_unexpected_error(self): agw_client = create_client(tenant_subdomain="my-tenant") with pytest.raises(AgentGatewaySDKError, match="Agent card discovery failed"): await agw_client.list_agent_cards() + + +# ============================================================ +# Test: get_ias_client_id +# ============================================================ + +_DEST_CREATE_PATCH = "sap_cloud_sdk.destination.create_client" +_IAS_DEST_NAME_PATCH = "sap_cloud_sdk.agentgateway._lob._ias_dest_name" +_GET_IAS_CLIENT_ID_LOB_PATCH = "sap_cloud_sdk.agentgateway.agw_client.get_ias_client_id_lob" +_DETECT_CREDS_PATCH = "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials" +_LOAD_CREDS_PATCH = "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials" + +_NO_CUSTOMER_CREDS = patch(_DETECT_CREDS_PATCH, return_value=None) + + +class TestGetIasClientId: + """Tests for AgentGatewayClient.get_ias_client_id().""" + + # --- Customer flow --- + + def test_customer_returns_client_id_from_credentials(self): + mock_creds = MagicMock() + mock_creds.client_id = "customer-client-id" + + with ( + patch(_DETECT_CREDS_PATCH, return_value="/etc/ums/credentials/credentials"), + patch(_LOAD_CREDS_PATCH, return_value=mock_creds), + ): + result = create_client().get_ias_client_id() + + assert result == "customer-client-id" + + def test_customer_returns_empty_string_on_load_failure(self): + with ( + patch(_DETECT_CREDS_PATCH, return_value="/etc/ums/credentials/credentials"), + patch(_LOAD_CREDS_PATCH, side_effect=Exception("parse error")), + ): + result = create_client().get_ias_client_id() + + assert result == "" + + # --- LoB flow --- + + @_NO_CUSTOMER_CREDS + def test_lob_returns_client_id_from_destination_properties(self, _mock_detect): + with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, return_value="lob-client-id"): + result = create_client(tenant_subdomain="my-tenant").get_ias_client_id() + + assert result == "lob-client-id" + + @_NO_CUSTOMER_CREDS + def test_lob_returns_empty_string_when_destination_not_found(self, _mock_detect): + with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, return_value=""): + result = create_client(tenant_subdomain="my-tenant").get_ias_client_id() + + assert result == "" + + @_NO_CUSTOMER_CREDS + def test_lob_returns_empty_string_when_property_absent(self, _mock_detect): + with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, return_value=""): + result = create_client(tenant_subdomain="my-tenant").get_ias_client_id() + + assert result == "" + + @_NO_CUSTOMER_CREDS + def test_lob_returns_empty_string_and_logs_warning_on_exception(self, _mock_detect): + with ( + patch(_GET_IAS_CLIENT_ID_LOB_PATCH, side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")), + patch("sap_cloud_sdk.agentgateway.agw_client.logger") as mock_logger, + ): + result = create_client(tenant_subdomain="my-tenant").get_ias_client_id() + + assert result == "" + mock_logger.warning.assert_called_once() + assert "clientId" in mock_logger.warning.call_args[0][0] diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 39c036e5..eca25d35 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -19,6 +19,7 @@ _ord_id_from_url, fetch_system_auth, fetch_user_auth, + get_ias_client_id_lob, get_mcp_tools_lob, get_agent_cards_lob, _fetch_agent_card, @@ -1011,3 +1012,56 @@ async def _selective_fetch(fragment_url, token, timeout): assert len(result) == 1 assert result[0].ord_id == "ord-ok" + + +class TestGetIasClientIdLob: + """Tests for get_ias_client_id_lob().""" + + def test_returns_client_id_from_destination_properties(self): + mock_dest = MagicMock() + mock_dest.properties = {"clientId": "lob-client-id"} + mock_dest_client = MagicMock() + mock_dest_client.get_destination.return_value = mock_dest + + with ( + patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), + patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + ): + result = get_ias_client_id_lob() + + assert result == "lob-client-id" + mock_dest_client.get_destination.assert_called_once_with( + "sap-managed-runtime-ias-eu10", + level=mock_dest_client.get_destination.call_args[1]["level"], + ) + + def test_returns_empty_string_when_destination_not_found(self): + mock_dest_client = MagicMock() + mock_dest_client.get_destination.return_value = None + + with ( + patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), + patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + ): + result = get_ias_client_id_lob() + + assert result == "" + + def test_returns_empty_string_when_property_absent(self): + mock_dest = MagicMock() + mock_dest.properties = {} + mock_dest_client = MagicMock() + mock_dest_client.get_destination.return_value = mock_dest + + with ( + patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), + patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + ): + result = get_ias_client_id_lob() + + assert result == "" + + def test_raises_when_landscape_env_not_set(self): + with patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")): + with pytest.raises(EnvironmentError, match="APPFND_CONHOS_LANDSCAPE"): + get_ias_client_id_lob() diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index 372a837f..8db63ca3 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -215,6 +215,6 @@ def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore - # + 2 extensibility + 4 aicore + 23 dms + 5 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print = 153 - assert len(all_operations) == 153 + # + 2 extensibility + 4 aicore + 23 dms + 6 agentgateway + 13 agent_memory + # + 5 data_anonymization + 52 adms + 6 print = 154 + assert len(all_operations) == 154 From 87fae07628203c4626bb4367ddee9d1ab2cc4d83 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Fri, 3 Jul 2026 19:32:16 -0300 Subject: [PATCH 3/7] fix checks --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/agw_client.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a593aaa1..3bbf3426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.32.1" +version = "0.33.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index c5a23f6e..7c6a96d0 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -333,7 +333,8 @@ def get_ias_client_id(self) -> str: return "" @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_MCP_TOOLS) - async def list_mcp_tools( self, + async def list_mcp_tools( + self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, ) -> list[MCPTool]: From 5947202de35160fc17b23cb14e363592c4a1033c Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Mon, 6 Jul 2026 11:59:43 -0300 Subject: [PATCH 4/7] add skip token retrieval for getting dest properties --- src/sap_cloud_sdk/agentgateway/_lob.py | 4 +- src/sap_cloud_sdk/destination/_models.py | 5 ++ src/sap_cloud_sdk/destination/client.py | 8 +++- .../agentgateway/integration/agw_auth.feature | 4 ++ .../agentgateway/integration/test_agw_bdd.py | 15 ++++++ tests/agentgateway/unit/test_lob.py | 4 +- tests/destination/unit/test_client.py | 46 +++++++++++++++++++ uv.lock | 2 +- 8 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 8a531427..7ba51ef6 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -118,7 +118,8 @@ def get_ias_client_id_lob() -> str: """Read the IAS client ID from the IAS destination properties (LoB flow). Fetches the IAS destination (``sap-managed-runtime-ias-{landscape}``) - at provider subaccount level and returns the ``clientId`` property. + at provider subaccount level with ``$skipTokenRetrieval=true`` so only + destination properties are returned — no auth token exchange is performed. Returns: The IAS client ID string, or ``""`` if the destination is not found @@ -133,6 +134,7 @@ def get_ias_client_id_lob() -> str: dest = client.get_destination( dest_name, level=ConsumptionLevel.PROVIDER_SUBACCOUNT, + options=ConsumptionOptions(skip_token_retrieval=True), ) if not dest: logger.warning( diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index 3cc542a7..aa69a980 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -510,6 +510,10 @@ class ConsumptionOptions: chain_vars: Key-value pairs for destination chain variables (X-chain-var-). Each entry is sent as a separate "X-chain-var-" header. Only applicable when chain_name is provided. + skip_token_retrieval: When True, instructs the Destination Service to skip the + OAuth2 token exchange and return only the destination configuration properties + ($skipTokenRetrieval query parameter). Useful when only destination metadata + is needed and token retrieval would be wasteful or cause unnecessary errors. Example: ```python @@ -560,6 +564,7 @@ class ConsumptionOptions: code_verifier: Optional[str] = None chain_name: Optional[str] = None chain_vars: Optional[dict] = None + skip_token_retrieval: bool = False @dataclass diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index 207f72e2..9453aedb 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -4,7 +4,7 @@ import logging import warnings -from typing import List, Optional, Callable, TypeVar +from typing import Any, Dict, List, Optional, Callable, TypeVar from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics from sap_cloud_sdk.destination._http import DestinationHttp, API_V1, API_V2 @@ -430,7 +430,11 @@ def get_destination( else f"{API_V2}/destinations/{name}" ) - resp = self._http.get(path, headers=headers, tenant_subdomain=tenant) + params: Dict[str, Any] = {} + if options and options.skip_token_retrieval: + params["$skipTokenRetrieval"] = "true" + + resp = self._http.get(path, headers=headers, tenant_subdomain=tenant, params=params or None) data = resp.json() # Parse v2 response: destinationConfiguration + authTokens + certificates diff --git a/tests/agentgateway/integration/agw_auth.feature b/tests/agentgateway/integration/agw_auth.feature index 45c2e36d..4ab6c0cb 100644 --- a/tests/agentgateway/integration/agw_auth.feature +++ b/tests/agentgateway/integration/agw_auth.feature @@ -55,3 +55,7 @@ Feature: Agent Gateway Auth Integration When I call list_mcp_tools And I call call_mcp_tool with the sample MCP tool and the user token Then the tool result should be a non-empty string + + Scenario: Get IAS client ID returns a non-empty string + When I call get_ias_client_id + Then the ias_client_id should be a non-empty string diff --git a/tests/agentgateway/integration/test_agw_bdd.py b/tests/agentgateway/integration/test_agw_bdd.py index ea82e475..4512b086 100644 --- a/tests/agentgateway/integration/test_agw_bdd.py +++ b/tests/agentgateway/integration/test_agw_bdd.py @@ -54,6 +54,7 @@ def __init__(self): self.tools: Optional[list[MCPTool]] = None self.tool_result: Optional[str] = None self.sample_mcp_tool_name: Optional[str] = None + self.ias_client_id: Optional[str] = None @pytest.fixture @@ -266,3 +267,17 @@ def tool_result_is_non_empty_string(context: ScenarioContext): assert context.tool_result is not None assert isinstance(context.tool_result, str) assert context.tool_result.strip(), "Expected a non-empty tool result" + + +@when("I call get_ias_client_id") +def call_get_ias_client_id(context: ScenarioContext, agw_client: AgentGatewayClient): + """Call get_ias_client_id and store the result.""" + context.ias_client_id = agw_client.get_ias_client_id() + + +@then("the ias_client_id should be a non-empty string") +def ias_client_id_non_empty(context: ScenarioContext): + """Verify the IAS client ID is a non-empty string.""" + assert context.ias_client_id is not None + assert isinstance(context.ias_client_id, str) + assert context.ias_client_id.strip(), "Expected a non-empty IAS client ID" diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index eca25d35..1e847414 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -28,6 +28,7 @@ from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig +from sap_cloud_sdk.destination import ConsumptionOptions, ConsumptionLevel from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError, MCPServerNotFoundError from sap_cloud_sdk.destination import ConsumptionLevel @@ -1032,7 +1033,8 @@ def test_returns_client_id_from_destination_properties(self): assert result == "lob-client-id" mock_dest_client.get_destination.assert_called_once_with( "sap-managed-runtime-ias-eu10", - level=mock_dest_client.get_destination.call_args[1]["level"], + level=ConsumptionLevel.PROVIDER_SUBACCOUNT, + options=ConsumptionOptions(skip_token_retrieval=True), ) def test_returns_empty_string_when_destination_not_found(self): diff --git a/tests/destination/unit/test_client.py b/tests/destination/unit/test_client.py index 18aa8bff..d15fd0ab 100644 --- a/tests/destination/unit/test_client.py +++ b/tests/destination/unit/test_client.py @@ -1173,6 +1173,52 @@ def test_get_destination_default_proxy_disabled(self, mock_load_proxy): # HTTP should be called since proxy is disabled by default mock_http.get.assert_called_once() + def test_get_destination_skip_token_retrieval_sends_query_param(self): + """Test that skip_token_retrieval=True sends $skipTokenRetrieval=true query param.""" + mock_http = MagicMock() + resp = MagicMock(spec=Response) + resp.status_code = 200 + resp.json.return_value = { + "destinationConfiguration": { + "name": "my-api", + "type": "HTTP", + "url": "https://api.example.com", + "clientId": "my-client-id", + }, + "authTokens": [], + "certificates": [], + } + mock_http.get.return_value = resp + + client = DestinationClient(mock_http) + result = client.get_destination( + "my-api", + options=ConsumptionOptions(skip_token_retrieval=True), + ) + + assert isinstance(result, Destination) + assert result.properties.get("clientId") == "my-client-id" + _, kwargs = mock_http.get.call_args + assert kwargs.get("params") == {"$skipTokenRetrieval": "true"} + + def test_get_destination_no_skip_token_retrieval_by_default(self): + """Test that skip_token_retrieval=False (default) sends no $skipTokenRetrieval param.""" + mock_http = MagicMock() + resp = MagicMock(spec=Response) + resp.status_code = 200 + resp.json.return_value = { + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, + "authTokens": [], + "certificates": [], + } + mock_http.get.return_value = resp + + client = DestinationClient(mock_http) + client.get_destination("my-api") + + _, kwargs = mock_http.get.call_args + assert kwargs.get("params") is None + class TestDestinationClientWriteOperations: diff --git a/uv.lock b/uv.lock index be9e3cfa..b6dd1330 100644 --- a/uv.lock +++ b/uv.lock @@ -3713,7 +3713,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.32.0" +version = "0.33.0" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 24ebdd6241cf63be8e53d50b1fa7b6012b76ddbd Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Mon, 6 Jul 2026 12:06:35 -0300 Subject: [PATCH 5/7] integration test for instance id --- tests/destination/integration/destination.feature | 4 ++++ .../integration/test_destination_bdd.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/destination/integration/destination.feature b/tests/destination/integration/destination.feature index 7eabf759..81a17374 100644 --- a/tests/destination/integration/destination.feature +++ b/tests/destination/integration/destination.feature @@ -342,3 +342,7 @@ Feature: Destination Service Integration Then the destination creation should be successful When I get subaccount destination "test-dest-sub-isolation" with "PROVIDER_ONLY" access strategy Then the destination should not be found + + Scenario: Get service instance ID returns a non-empty string + When I call get_service_instance_id + Then the service instance ID should be a non-empty string diff --git a/tests/destination/integration/test_destination_bdd.py b/tests/destination/integration/test_destination_bdd.py index a69a0a28..80c483d8 100644 --- a/tests/destination/integration/test_destination_bdd.py +++ b/tests/destination/integration/test_destination_bdd.py @@ -61,6 +61,7 @@ def __init__(self): self.retrieved_labels: List[Label] = [] self.http_client: Optional[DestinationHttpClient] = None self.http_response = None + self.service_instance_id: Optional[str] = None @pytest.fixture @@ -1621,3 +1622,17 @@ def assert_authorization_header_present(context): f"Expected Authorization header in response, got: {list(echoed.keys())}. " "Check that BTP returned an auth token for the destination." ) + + +@when("I call get_service_instance_id") +def call_get_service_instance_id(context, destination_client): + """Call get_service_instance_id and store the result.""" + context.service_instance_id = destination_client.get_service_instance_id() + + +@then("the service instance ID should be a non-empty string") +def assert_service_instance_id_non_empty(context): + """Verify the service instance ID is a non-empty string.""" + assert context.service_instance_id is not None + assert isinstance(context.service_instance_id, str) + assert context.service_instance_id.strip(), "Expected a non-empty service instance ID" From e43527d43f42caed475a2550291e054428e404dd Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Mon, 6 Jul 2026 12:19:06 -0300 Subject: [PATCH 6/7] fix checks --- src/sap_cloud_sdk/destination/client.py | 4 +++- tests/destination/integration/test_destination_bdd.py | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index 9453aedb..ec653a58 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -434,7 +434,9 @@ def get_destination( if options and options.skip_token_retrieval: params["$skipTokenRetrieval"] = "true" - resp = self._http.get(path, headers=headers, tenant_subdomain=tenant, params=params or None) + resp = self._http.get( + path, headers=headers, tenant_subdomain=tenant, params=params or None + ) data = resp.json() # Parse v2 response: destinationConfiguration + authTokens + certificates diff --git a/tests/destination/integration/test_destination_bdd.py b/tests/destination/integration/test_destination_bdd.py index 80c483d8..089ab9e9 100644 --- a/tests/destination/integration/test_destination_bdd.py +++ b/tests/destination/integration/test_destination_bdd.py @@ -1612,7 +1612,13 @@ def create_http_client(context): @when(parsers.parse('I send a GET request to "{path}"')) def send_get_request(context, path): - context.http_response = context.http_client.request("GET", path) + import requests + try: + context.http_response = context.http_client.request("GET", path) + except requests.exceptions.ConnectionError as e: + pytest.skip(f"External endpoint unreachable — skipping: {e}") + except requests.exceptions.Timeout as e: + pytest.skip(f"External endpoint timed out — skipping: {e}") @then("the response contains an Authorization header") @@ -1627,6 +1633,8 @@ def assert_authorization_header_present(context): @when("I call get_service_instance_id") def call_get_service_instance_id(context, destination_client): """Call get_service_instance_id and store the result.""" + if not os.environ.get("CLOUD_SDK_CFG_DESTINATION_DEFAULT_INSTANCEID"): + pytest.skip("CLOUD_SDK_CFG_DESTINATION_DEFAULT_INSTANCEID is not set — skipping service instance ID test") context.service_instance_id = destination_client.get_service_instance_id() From de19d4e3efe09eda58841ef1832ff5790d535268 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Mon, 6 Jul 2026 12:33:20 -0300 Subject: [PATCH 7/7] refactor errors --- src/sap_cloud_sdk/agentgateway/_lob.py | 9 ++---- src/sap_cloud_sdk/agentgateway/agw_client.py | 14 ++++----- src/sap_cloud_sdk/agentgateway/user-guide.md | 2 +- src/sap_cloud_sdk/destination/client.py | 19 +++++-------- src/sap_cloud_sdk/destination/user-guide.md | 2 +- tests/agentgateway/unit/test_agw_client.py | 30 +++++++------------- tests/agentgateway/unit/test_lob.py | 7 ++--- tests/destination/unit/test_client.py | 17 ++--------- 8 files changed, 35 insertions(+), 65 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 7ba51ef6..96ef79e9 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -122,11 +122,11 @@ def get_ias_client_id_lob() -> str: destination properties are returned — no auth token exchange is performed. Returns: - The IAS client ID string, or ``""`` if the destination is not found - or the property is absent. + The IAS client ID string, or ``""`` if the ``clientId`` property is absent. Raises: EnvironmentError: If ``APPFND_CONHOS_LANDSCAPE`` is not set. + AgentGatewaySDKError: If the IAS destination is not found. Any exception raised by the destination client. """ dest_name = _ias_dest_name() @@ -137,10 +137,7 @@ def get_ias_client_id_lob() -> str: options=ConsumptionOptions(skip_token_retrieval=True), ) if not dest: - logger.warning( - "IAS destination '%s' not found — clientId will be empty", dest_name - ) - return "" + raise AgentGatewaySDKError(f"IAS destination '{dest_name}' not found") return dest.properties.get("clientId", "") diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 7c6a96d0..33a0ccfc 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -309,11 +309,10 @@ def get_ias_client_id(self) -> str: and returns the ``clientId`` destination property. Returns: - The IAS client ID string, or ``""`` if it cannot be resolved. + The IAS client ID string. Raises: - Nothing — failures are caught, logged at WARNING level, - and an empty string is returned instead. + AgentGatewaySDKError: If the IAS client ID cannot be resolved. """ try: credentials_path = detect_customer_agent_credentials() @@ -326,11 +325,10 @@ def get_ias_client_id(self) -> str: # LoB flow — read clientId from the IAS destination properties return get_ias_client_id_lob() - except Exception: - logger.warning( - "Could not resolve IAS client ID from destination — clientId will be empty" - ) - return "" + except AgentGatewaySDKError: + raise + except Exception as e: + raise AgentGatewaySDKError(f"Could not resolve IAS client ID: {e}") from e @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_MCP_TOOLS) async def list_mcp_tools( diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index d65e648b..686d9f37 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -229,7 +229,7 @@ Returns the IAS client ID. Automatically detects agent type: - **Customer agents**: reads `client_id` directly from the mounted credentials file. - **LoB agents**: fetches the IAS destination (`sap-managed-runtime-ias-{landscape}`) at provider subaccount level and returns the `clientId` destination property. -Returns `""` and logs a `WARNING` if the value cannot be resolved. +Raises `AgentGatewaySDKError` if the value cannot be resolved. ```python agw_client = create_client(tenant_subdomain="my-tenant") diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index ec653a58..d4ffd3e0 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, Callable, TypeVar from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var from sap_cloud_sdk.destination._http import DestinationHttp, API_V1, API_V2 from sap_cloud_sdk.destination._models import ( AccessStrategy, @@ -684,17 +685,12 @@ def get_service_instance_id(self) -> str: module ``destination``, instance ``default``). Returns: - The instance ID string, or ``""`` if the secret cannot be resolved. + The instance ID string. Raises: - Nothing — resolution failures are caught, logged at WARNING level, - and an empty string is returned instead. + DestinationOperationError: If the instance ID cannot be resolved from secrets. """ try: - from sap_cloud_sdk.core.secret_resolver import ( - read_from_mount_and_fallback_to_env_var, - ) - config = _DestinationInstanceConfig() read_from_mount_and_fallback_to_env_var( base_volume_mount="/etc/secrets/appfnd", @@ -704,11 +700,10 @@ def get_service_instance_id(self) -> str: target=config, ) return config.instanceid - except Exception: - logger.warning( - "Could not resolve destination instance ID from secrets — instanceId will be empty" - ) - return "" + except Exception as e: + raise DestinationOperationError( + "Could not resolve destination instance ID from secrets" + ) from e # ---------- Internal helpers ---------- diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index 41e22555..f5ec6381 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -755,4 +755,4 @@ The function reads the `instanceid` field from the secret resolved via the stand - **Mount path**: `/etc/secrets/appfnd/destination/default/instanceid` - **Env var fallback**: `CLOUD_SDK_CFG_DESTINATION_DEFAULT_INSTANCEID` -Returns `""` and logs a `WARNING` if the secret cannot be resolved (e.g., running locally without a binding). +Raises `DestinationOperationError` if the secret cannot be resolved (e.g., running locally without a binding). diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 3095c706..8df9e96a 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -1012,14 +1012,13 @@ def test_customer_returns_client_id_from_credentials(self): assert result == "customer-client-id" - def test_customer_returns_empty_string_on_load_failure(self): + def test_customer_raises_on_load_failure(self): with ( patch(_DETECT_CREDS_PATCH, return_value="/etc/ums/credentials/credentials"), patch(_LOAD_CREDS_PATCH, side_effect=Exception("parse error")), ): - result = create_client().get_ias_client_id() - - assert result == "" + with pytest.raises(AgentGatewaySDKError, match="Could not resolve IAS client ID"): + create_client().get_ias_client_id() # --- LoB flow --- @@ -1031,11 +1030,10 @@ def test_lob_returns_client_id_from_destination_properties(self, _mock_detect): assert result == "lob-client-id" @_NO_CUSTOMER_CREDS - def test_lob_returns_empty_string_when_destination_not_found(self, _mock_detect): - with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, return_value=""): - result = create_client(tenant_subdomain="my-tenant").get_ias_client_id() - - assert result == "" + def test_lob_raises_when_destination_not_found(self, _mock_detect): + with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, side_effect=AgentGatewaySDKError("IAS destination 'sap-managed-runtime-ias-eu10' not found")): + with pytest.raises(AgentGatewaySDKError, match="IAS destination"): + create_client(tenant_subdomain="my-tenant").get_ias_client_id() @_NO_CUSTOMER_CREDS def test_lob_returns_empty_string_when_property_absent(self, _mock_detect): @@ -1045,13 +1043,7 @@ def test_lob_returns_empty_string_when_property_absent(self, _mock_detect): assert result == "" @_NO_CUSTOMER_CREDS - def test_lob_returns_empty_string_and_logs_warning_on_exception(self, _mock_detect): - with ( - patch(_GET_IAS_CLIENT_ID_LOB_PATCH, side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")), - patch("sap_cloud_sdk.agentgateway.agw_client.logger") as mock_logger, - ): - result = create_client(tenant_subdomain="my-tenant").get_ias_client_id() - - assert result == "" - mock_logger.warning.assert_called_once() - assert "clientId" in mock_logger.warning.call_args[0][0] + def test_lob_raises_on_exception(self, _mock_detect): + with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")): + with pytest.raises(AgentGatewaySDKError, match="Could not resolve IAS client ID"): + create_client(tenant_subdomain="my-tenant").get_ias_client_id() diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 1e847414..53b1c34f 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -1037,7 +1037,7 @@ def test_returns_client_id_from_destination_properties(self): options=ConsumptionOptions(skip_token_retrieval=True), ) - def test_returns_empty_string_when_destination_not_found(self): + def test_raises_when_destination_not_found(self): mock_dest_client = MagicMock() mock_dest_client.get_destination.return_value = None @@ -1045,9 +1045,8 @@ def test_returns_empty_string_when_destination_not_found(self): patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), ): - result = get_ias_client_id_lob() - - assert result == "" + with pytest.raises(AgentGatewaySDKError, match="sap-managed-runtime-ias-eu10"): + get_ias_client_id_lob() def test_returns_empty_string_when_property_absent(self): mock_dest = MagicMock() diff --git a/tests/destination/unit/test_client.py b/tests/destination/unit/test_client.py index d15fd0ab..05acb4f2 100644 --- a/tests/destination/unit/test_client.py +++ b/tests/destination/unit/test_client.py @@ -2222,7 +2222,7 @@ def test_patch_destination_labels_without_tenant_uses_provider_context(self): assert kwargs["tenant_subdomain"] is None -_RESOLVER_PATCH = "sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var" +_RESOLVER_PATCH = "sap_cloud_sdk.destination.client.read_from_mount_and_fallback_to_env_var" class TestGetServiceInstanceId: @@ -2248,19 +2248,8 @@ def fill_instanceid(*args, **kwargs): ) @patch(_RESOLVER_PATCH, side_effect=RuntimeError("mount failed")) - def test_returns_empty_string_on_exception(self, _mock_read): + def test_raises_on_exception(self, _mock_read): client = DestinationClient(MagicMock()) - result = client.get_service_instance_id() - - assert result == "" - - @patch(_RESOLVER_PATCH, side_effect=RuntimeError("mount failed")) - def test_logs_warning_on_exception(self, _mock_read): - client = DestinationClient(MagicMock()) - - with patch("sap_cloud_sdk.destination.client.logger") as mock_logger: + with pytest.raises(DestinationOperationError, match="Could not resolve destination instance ID from secrets"): client.get_service_instance_id() - - mock_logger.warning.assert_called_once() - assert "instanceId" in mock_logger.warning.call_args[0][0]