Skip to content

feat(cbc): add CBC (Central Business Configuration) client module - #333

Open
soumyadey wants to merge 14 commits into
SAP:mainfrom
soumyadey:feat/cbc-client
Open

soumyadey wants to merge 14 commits into
SAP:mainfrom
soumyadey:feat/cbc-client

Conversation

@soumyadey

@soumyadey soumyadey commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Description

Adds sap_cloud_sdk.cbc — a typed Python client for reading tenant-specific business configuration from SAP Central Business Configuration (CBC). The client is built as two layers: a generic core (client.py) where base_url and app_tenant_id are per-request callables and mTLS is supplied as a Callable[[], ssl.SSLContext] factory, and a platform adapter (client_adapter.py) that ships the SAP application-platform provisioning defaults on top of the core. The mTLS certificate is reloaded automatically when a request fails the TLS handshake (certificate rotation/expiry), so a long-lived client recovers without being recreated. Includes a full exception hierarchy, Pydantic-backed API models, and a CBCClient Protocol for test doubles.

Related Issue

Closes #280

Type of Change

  • New feature (non-breaking change that adds functionality)

How to Test

Unit tests (no external service required):

pytest tests/cbc/unit/

Integration tests (requires a CBC server or mock):

Against a plain-HTTP mock (CLOUD_SDK_CBC_URL must have the CBC tenant id baked in — it is used verbatim):

CLOUD_SDK_CBC_URL=http://localhost:8001 \
CLOUD_SDK_CBC_APP_TENANT_ID=<app-tenant-id> \
pytest -v tests/cbc/integration/

Against a real mTLS server, add the client cert and key paths:

CLOUD_SDK_CBC_URL=https://<cbc-tenant-id>.<rest-of-host> \
CLOUD_SDK_CBC_APP_TENANT_ID=<app-tenant-id> \
CLOUD_SDK_CBC_CERT_PATH=<path-to-cert.pem> \
CLOUD_SDK_CBC_KEY_PATH=<path-to-key.pem> \
pytest -v tests/cbc/integration/

CLOUD_SDK_CBC_URL, CLOUD_SDK_CBC_CERT_PATH, and CLOUD_SDK_CBC_KEY_PATH are integration-harness env vars (read only by the test conftest), not part of the SDK's public API. The cert/key are optional — supply both for mTLS, omit for a plain-HTTP mock.

Expected result: 62 unit tests pass; integration tests skip automatically when env vars are absent (CI-safe).

Checklist

  • I have read the Contributing Guidelines
  • I have verified that my changes solve the issue
  • I have added/updated automated tests to cover my changes
  • All tests pass locally
  • I have verified that my code follows the Code Guidelines
  • I have updated documentation (if applicable)
  • I have added type hints for all public APIs
  • My code does not contain sensitive information (credentials, tokens, etc.)
  • I have followed Conventional Commits for commit messages

Breaking Changes

None. This is a new module with no existing public API.

Additional Notes

Module structure follows the repo convention (client.py, client_adapter.py, exceptions.py, _models.py, py.typed, user-guide.md).

Key design decisions:

  • Two-layer contract: the core create_client is generic and knows nothing about the platform — base_url and app_tenant_id are Callable[[], str] invoked per request (both vary per tenant in a multi-tenant agent), and the credential input is a Callable[[], ssl.SSLContext] factory. The platform adapter (create_agent_client) layers strictly on top and supplies the SAP application-platform defaults.
  • The adapter owns two ContextVars (app_tenant_id_var, tenant_subdomain_var) that the app populates per request; it resolves base_url from the tenant-mapping Destination Fragment (listing the subaccount and matching on appTenantId) and loads the provider-level mTLS certificate from the Destination Service. Every default is overridable via the CBCDestinationConfig object passed to create_agent_client, or CLOUD_SDK_CBC_* env vars.
  • create_agent_client takes a single CBCDestinationConfig settings object (which Destination Service instance, certificate name, and keystore password to read) rather than loose keyword args — the config object lives in cbc/config.py, matching the dedicated-config.py convention of the agw, adms, print, and destination modules. A value set on the config wins over its CLOUD_SDK_CBC_* env var, which in turn falls back to the platform default.
  • mTLS certificate rotation is handled reactively: the ssl_context factory is resolved once at construction and re-invoked only when a request fails the TLS handshake (an expired/rotated client cert surfaces as ReadError wrapping ssl.SSLError, detected by walking the exception cause chain). On such a failure the client rebuilds its httpx.Client from a fresh context under a lock and retries the request once; a second failure, a non-TLS transport error, or a failing rebuild propagates cleanly, leaving the previous working client in place. A long-lived create_agent_client() singleton therefore recovers from rotation on its own. Mirrors the objectstore _execute_with_retry rotation pattern.
  • The three platform resolvers (resolve_base_url, resolve_app_tenant_id, load_ssl_context) are public, exported at the top level. create_agent_client stays a fixed preset wiring all three; a caller who wants to keep most of the preset but override a single axis composes create_client with the public resolvers plus their own callable for that axis, instead of reimplementing the resolvers.
  • get_configuration reads the config objects from the CBC configurationObjects API — which already returns each config object with its child entities — so consumers work with the authored config-object vocabulary without any client-side grouping. The per-request base_url / app_tenant_id callables are each resolved once per public call and threaded into the internal requests, so a single get_configuration triggers one base_url resolution (one tenant-mapping fragment lookup in the adapter), not one per entity.
  • EntityData, ConfigObject, ConfigData are plain @dataclass (not Pydantic) — they are constructed in client code, never parsed from JSON.
  • Only the two public API methods carry @record_metrics; internal helpers do not, to avoid double-counting a single user operation.

Test evidence:

62 passed, 1 warning
tests/cbc/unit/test_client.py::TestAppTenantIdCallable::test_callable_is_invoked_on_each_call PASSED
tests/cbc/unit/test_client.py::TestConfigurationsUrl::test_joins_base_url_and_path_verbatim PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_returns_parsed_versions PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_raises_client_error_on_404 PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_raises_server_error_on_500 PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_raises_network_error_on_connection_failure PASSED
tests/cbc/unit/test_client.py::TestGetConfigurationObjects::test_returns_grouped_config_objects PASSED
tests/cbc/unit/test_client.py::TestFetchEntityData::test_reads_array_content_items PASSED
tests/cbc/unit/test_client.py::TestFetchEntityData::test_reads_object_content_item PASSED
tests/cbc/unit/test_client.py::TestFetchEntityData::test_defaults_to_empty_list_when_content_absent PASSED
tests/cbc/unit/test_client.py::TestFetchEntityData::test_falls_back_to_path_entity_id_without_metadata PASSED
tests/cbc/unit/test_client.py::TestGetConfiguration::test_resolves_latest_version_when_none_given PASSED
tests/cbc/unit/test_client.py::TestGetConfiguration::test_raises_runtime_error_when_no_versions_exist PASSED
tests/cbc/unit/test_client.py::TestGetConfiguration::test_uses_explicit_consumption_version PASSED
tests/cbc/unit/test_client.py::TestGetConfiguration::test_resolves_base_url_once_per_call PASSED
tests/cbc/unit/test_client.py::TestDefaultClientContextManager::test_close_called_on_exit PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_returns_default_client PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_passes_ssl_context_through PASSED
tests/cbc/unit/test_client.py::TestIsTlsFailure::test_true_for_read_error_wrapping_ssl_error PASSED
tests/cbc/unit/test_client.py::TestIsTlsFailure::test_false_for_plain_connect_error PASSED
tests/cbc/unit/test_client.py::TestIsTlsFailure::test_false_for_non_ssl_os_error PASSED
tests/cbc/unit/test_client.py::TestCertRotation::test_reactive_success_rebuilds_and_retries PASSED
tests/cbc/unit/test_client.py::TestCertRotation::test_second_failure_propagates_after_one_rebuild PASSED
tests/cbc/unit/test_client.py::TestCertRotation::test_non_tls_transport_error_does_not_rebuild PASSED
tests/cbc/unit/test_client.py::TestCertRotation::test_no_factory_does_not_rebuild PASSED
tests/cbc/unit/test_client.py::TestCertRotation::test_rebuild_failure_propagates_and_keeps_client PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveAppTenantId::test_returns_contextvar_value PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveAppTenantId::test_raises_when_empty PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveBaseUrl::test_reads_cbc_url_from_matching_fragment PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveBaseUrl::test_raises_when_subdomain_empty PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveBaseUrl::test_raises_when_app_tenant_id_empty PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveBaseUrl::test_raises_when_no_matching_fragment PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveBaseUrl::test_raises_when_cbc_url_missing_from_fragment PASSED
tests/cbc/unit/test_client_adapter.py::TestResolveBaseUrl::test_wraps_destination_error_as_config_error PASSED
tests/cbc/unit/test_client_adapter.py::TestLoadSslContext::test_loads_cert_into_ssl_context PASSED
tests/cbc/unit/test_client_adapter.py::TestLoadSslContext::test_raises_when_cert_not_found PASSED
tests/cbc/unit/test_client_adapter.py::TestLoadSslContext::test_wraps_destination_error_as_config_error PASSED
tests/cbc/unit/test_client_adapter.py::TestCreateAgentClient::test_builds_ssl_context_from_cert_default PASSED
tests/cbc/unit/test_client_adapter.py::TestCreateAgentClient::test_env_overrides_apply PASSED
tests/cbc/unit/test_client_adapter.py::TestCreateAgentClient::test_config_values_apply PASSED
tests/cbc/unit/test_client_adapter.py::TestCreateAgentClient::test_config_value_wins_over_env PASSED
tests/cbc/unit/test_client_adapter.py::TestCreateAgentClient::test_raises_when_landscape_unset_and_no_cert_name PASSED
tests/cbc/unit/test_client_adapter.py::TestCreateAgentClient::test_wires_resolvers_into_client PASSED
tests/cbc/unit/test_client_adapter.py::TestComposeWithPublicResolvers::test_compose_core_with_public_resolvers PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_none_for_empty_list PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_latest_by_modified_date PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_latest_by_created_date_when_no_modified PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_last_item_when_no_dates PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_list_returns_list PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_list_raises_when_dict PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_object_returns_dict PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_object_raises_when_list PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_is_list_and_is_object_for_list_content PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_is_list_and_is_object_for_dict_content PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_value_returns_raw_without_asserting_shape PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_config_object_returns_matching PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_config_object_returns_none_when_missing PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_entity_data_returns_match PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_entity_data_returns_none_when_missing PASSED
tests/cbc/unit/test_models.py::TestApiError::test_parses_cbc_error_envelope PASSED
tests/cbc/unit/test_models.py::TestApiError::test_fallback_on_empty_body PASSED
tests/cbc/unit/test_models.py::TestApiError::test_fallback_on_unparseable_body PASSED
Integration: 5 passed in 19.10s (real CBC server)
platform darwin -- Python 3.12.12, pytest-9.1.0
plugins: asyncio-1.4.0, bdd-8.1.0, respx-0.23.1

tests/cbc/integration/test_e2e_bdd.py::test_consumption_versions_non_empty PASSED
tests/cbc/integration/test_e2e_bdd.py::test_latest_version_non_empty PASSED
tests/cbc/integration/test_e2e_bdd.py::test_get_configuration_returns_config_data PASSED
tests/cbc/integration/test_e2e_bdd.py::test_configuration_has_config_objects PASSED
tests/cbc/integration/test_e2e_bdd.py::test_every_entity_has_id_and_data PASSED

5 passed, 1 warning in 19.10s
Sample ConfigData response (real CBC server)
{
  "consumption_version": "a0392d4f-...",
  "app_tenant_id": "<app-tenant-id>",
  "config_objects": [
    {
      "config_object_id": "payment-config",
      "entities": [
        {
          "entity_id": "payment-mode",
          "data": [
            { "paymentModeCode": "CASH",          "name": "Cash",                "isOnline": false },
            { "paymentModeCode": "CARD",          "name": "Credit / Debit Card", "isOnline": false },
            { "paymentModeCode": "DIGITAL_WALLET","name": "Digital Wallet",      "isOnline": true  }
          ]
        }
      ]
    },
    {
      "config_object_id": "tax-config",
      "entities": [
        {
          "entity_id": "tax-category",
          "data": [
            { "code": "STD",     "ratePercent": 8.5, "isDefault": true  },
            { "code": "REDUCED", "ratePercent": 5,   "isDefault": false },
            { "code": "ZERO",    "ratePercent": 0,   "isDefault": false }
          ]
        }
      ]
    }
  ]
}

@soumyadey
soumyadey requested a review from a team as a code owner September 14, 2026 15:10
Comment thread src/sap_cloud_sdk/cbc/client.py Outdated

def get_configuration(
self,
tenant_context: TenantContext,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if tenant_context is required for all request, can we move it to client_level? Similar to agent gateway.

Also, can't we infer it from what is created during provisioning / spii?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback. You're right that binding tenant context at client level is the better pattern — it's exactly what agentgateway does with tenant_subdomain: str | Callable[[], str]. The callable form is the key: one injected client instance, and the callable reads from whatever auth context the consumer maintains at call time (e.g. a request-scoped context var populated during SPII handling).

The TenantContext | Callable[[], TenantContext] signature handles both agent deployment modes:

  • Single-tenant: tenant IDs are fixed at startup → pass a TenantContext value directly.
  • Multi-tenant: tenant IDs vary per request → pass a callable that reads from the incoming request context.

One nuance worth aligning on: CBC requires two IDs per call — cbcTenantId (for URL subdomain routing) and appTenantId (query param). The app tenant is extractable from the auth context, but cbcTenantId comes from a separate mapping populated during SPII provisioning.

On inferring from SPII: the client can't own or infer that mapping — the SPII callback handler stores it wherever the consumer decides (a cache, a DB, a context var), and the client has no business coupling to that store. The consumer extracts both IDs and supplies them via the callable. The proposal would be:

client = create_client(
    tenant_context=lambda: TenantContext(
        cbc_tenant_id=get_cbc_tid(auth_ctx.tenant_id),
        app_tenant_id=auth_ctx.tenant_id,
    )
)
# then all call sites become:
config = client.get_configuration()

Does that match your expectation? If so I'll update DefaultClient.__init__ to accept tenant_context: TenantContext | Callable[[], TenantContext] and remove it from the public method signatures — same pattern as agw's tenant_subdomain.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect something like create_client(tenant=<tenant>) where tenant is the subscriber agent sub-account id. Would it be possible? And of course you can also have something like:

create_client(config=..., tenant=...) where you can add manually the configuration and override SPII.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_client(tenant=subscriber_subaccount_id) would not work for two reasons:

  1. The CBC API today requires both app_tenant_id (subscriber subaccount ID) and cbc_tenant_id. How the consumer derives cbc_tenant_id from app_tenant_id is outside this SDK's scope — it might be a DB lookup, a cache populated during SPII handshake, a call to UMS, etc. CBC may relax this in future (internally resolve cbc_tenant_id from the subaccount ID), but that's not supported today.
  2. For multi-tenant agents, tenant also needs to be request-scoped — so Callable[[], TenantContext] is necessary alongside the direct value form.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we agreed on moving tenant_context to client level, I've implemented this in 0c61a7f — TenantContext | Callable[[], TenantContext] at construction time, removed from the public method signatures. The reasoning for the callable and the two IDs is covered in the discussion above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: went with your suggestion - the caller just provides the subaccount id, no TenantContext type. There's no second tenant id anymore: the full CBC URL comes straight from the tenant-mapping Fragment created during provisioning / SPII handshake.

Comment thread src/sap_cloud_sdk/cbc/client.py Outdated

Example (local mock)::

client = DefaultClient(base_url="http://localhost:8001")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a final decision about local mode and we would like to keep it consistent across module. Is this really needed on first version?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — on reflection this isn't needed. Quick context on why local mode exists: CBC rewrites the URL subdomain to the cbcTenantId on every request. When an agent tests against a locally running mock server (the CBC CLI's local cell command spins one up based on the agent's config object shapes), that rewrite silently corrupts the URL — http://localhost:8001 becomes http://<tenant-id>.localhost:8001, which won't resolve. Auto-detection was added to spare developers from having to know this.

But CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false alongside CLOUD_SDK_CBC_URL=http://localhost:8001 already handles it — so auto-detection is a convenience, not a necessity. Happy to remove it in v1 and revisit as part of the cross-module local mode decision. Shall I go ahead and remove it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please, remove local mode for now if it not a must have in V1.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c62dfa8 — _is_local_url deleted, user-guide, docstrings, and tests updated accordingly.

Comment thread src/sap_cloud_sdk/cbc/client.py Outdated
Comment thread src/sap_cloud_sdk/cbc/config.py Outdated
replace_subdomain: bool | None = None


def load_from_env() -> CBCConfig:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is only loading from env? This is not being provisioned by managed runtime. My expectation is that it should work similar to agw, where we read fragments and destination created during provisioning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — I see that aicore supports both:

  1. Destination mode: AICORE_DESTINATION_NAME set → fetches URL + credentials from BTP Destination Service at startup.
  2. Direct mode (fallback): reads from mounted K8s secret volume or env vars — used for local development where no Destination Service is available.

CBC credentials are stored in a BTP Destination entry, so we should support the same pattern: CLOUD_SDK_CBC_DESTINATION_NAME → fetch URL + cert from the destination, with env vars as the local fallback. I'll double check how the CBC URL and credentials are stored in the destination and implement this mirroring the aicore approach — destination mode when CLOUD_SDK_CBC_DESTINATION_NAME is set, env/file fallback otherwise. Does that sound right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this sounds good. We just need to ensure that we are relying on what is already created on sap-internal-sdk during SPII.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. We now read provisioning via sap_cloud_sdk.destination the same way agw does - the CBC URL from the tenant-mapping Destination Fragment (create_fragment_client), and the mTLS provider certificate from the Destination Service (create_certificate_client, provider access strategy).

@soumyadey
soumyadey force-pushed the feat/cbc-client branch 2 times, most recently from c3dc18a to c62dfa8 Compare September 18, 2026 17:41
Typed Python client for reading tenant-specific business configuration
from SAP Central Business Configuration. Supports mTLS (production),
local/mock (loopback auto-detection), and HTTPS mock servers via the
CLOUD_SDK_CBC_REPLACE_SUBDOMAIN env var override.

Public API: create_client(), CBCClient protocol, DefaultClient,
CBCConfig, ConfigData / ConfigObject / EntityData / EntityContent,
ConsumptionVersions, and a full CBC exception hierarchy.
…h params

Replace the cert tuple parameter with symmetric cert_path/key_path params.
Add CLOUD_SDK_CBC_CERT / CLOUD_SDK_CBC_KEY env vars so PEM values can be
supplied directly (e.g. from K8s secrets) without writing to disk first —
create_client() handles the temp-file lifecycle automatically.
…nts, version bump

- Bump version to 0.54.0 (required by CI for src/ changes)
- Fix ruff format violations in _models.py and client.py
- Fix ty errors: conftest fixture return type CBCClient, test_models assert-not-None before .version
- Update test_module (15→16) and test_operation (161→163) counts for CBC module/operations
- Soften "do not instantiate" to "prefer create_client"
- Replace contradictory direct-instantiation examples with create_client usage
- Reference BTP Destination Service and env vars as credential sources
- Add tmp/ to .gitignore
`_is_local_url` auto-disabled subdomain replacement for loopback URLs.
Replace with an explicit opt-out: `replace_subdomain` now defaults to
`True`; consumers set `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false` when
pointing at a local mock server.

- Remove `_is_local_url` from `_http.py`
- Default `replace_subdomain` to `True` in `DefaultClient.__init__`
- Update `config.py` and `user-guide.md` to document the env-var escape hatch
- Remove `TestDefaultClientLocalMode` and related tests
Binds `TenantContext | Callable[[], TenantContext]` at construction time
instead of per-call. The callable form supports multi-tenant agents where
the tenant varies per request (e.g. read from a request-scoped context var).

- `DefaultClient.__init__` and `create_client` gain `tenant_context` param
- `get_consumption_versions` and `get_configuration` drop the param
- `CBCClient` Protocol updated to match
- Integration conftest bakes tenant into the client fixture
- Tests cover callable invocation count and missing-tenant error
Redesign the CBC client around a generic core and a platform adapter layered
strictly on top of it.

Core (client.py): base_url and app_tenant_id are per-request callables; the only
credential input is an ssl.SSLContext. create_client is a thin factory. Drops
TenantContext, config.py, _http.py, and all env/cert-file machinery.

Platform adapter (client_adapter.py): ships the SAP application-platform
provisioning defaults. The SDK owns two ContextVars (app_tenant_id_var,
tenant_subdomain_var) that the app populates; create_agent_client resolves
base_url from the tenant-mapping Destination Fragment (listing the subaccount
and matching on appTenantId, pre-PR-SAP#79 shape) and loads the provider mTLS
cert from the Destination Service. Every default is overridable via args or
CLOUD_SDK_CBC_* env vars.

CBC unit coverage 98% (adapter 100%).
get_configuration previously re-invoked the base_url callable on every
HTTP request (consumption versions + config objects + one per entity),
so a single call triggered 2+N tenant-mapping fragment lookups in the
platform adapter. Resolve base_url once at the top of the public call
and thread the resolved string into the internal methods
(_get_configuration_objects, _fetch_entity_data, _configurations_url),
mirroring how app_tenant_id is already threaded. One operation now does
one base_url resolution.

Also fix incoherent unit-test data: agent-config no longer holds
restaurant/contact/hours entities; replaced with tax-config /
tax-category / tax-rate to match the finance-domain examples used
elsewhere.
The adapter's _resolve_base_url and _load_ssl_context call Destination
Service APIs that can raise DestinationError, which leaked past the
documented CBCConfigError contract. Wrap both in try/except, re-raising
as CBCConfigError with a descriptive message and chained cause.
The adapter loaded the mTLS certificate once in create_agent_client and
baked it into the httpx.Client at construction, so a rotated or expired
certificate killed a long-lived client until the process restarted —
violating the "Credential Binding Rotation" guideline that every module
reading credentials must recover from rotation.

Make the core reload reactively. ssl_context becomes a
Callable[[], ssl.SSLContext] | None, resolved once at construction and
re-invoked only when a request fails the TLS handshake. On such a
failure the client rebuilds its httpx.Client from a fresh context under
a lock (guarding against a thundering herd) and retries the one request
once; a second failure, a non-TLS transport error, or a failing rebuild
(the cert loader raises CBCConfigError) propagates cleanly, leaving the
previous working client in place. TLS failures are detected by walking
the exception's __cause__/__context__ chain for ssl.SSLError, since httpx
surfaces an expired client cert as ReadError wrapping ssl.SSLError two
levels down. Mirrors objectstore's _execute_with_retry rotation pattern.

create_agent_client drops its ssl_context parameter and now owns cert
resolution end-to-end, passing a lambda: load_ssl_context(...) factory to
the core.

Expose the three platform resolvers as public composition helpers
(resolve_base_url, resolve_app_tenant_id, load_ssl_context) at the top
level, so a caller can keep most of the platform preset but override a
single axis via create_client instead of reimplementing the resolvers.

Also:
- resolve base_url + app_tenant_id once per get_configuration on the
  auto-version path (extract _get_consumption_versions taking resolved
  values), avoiding a double resolve that could also disagree if the
  request context changed between the two.
- rename _build_client/_rebuild_client/self._client to the _http_client
  forms, so names disambiguate the httpx client from the CBC client.
- build the client with verify=ctx if ctx is not None else True, making
  it explicit that TLS verification is never disabled.
The pre-commit ty hook checks tests/ (unlike an src-only ty run), which
surfaced 10 diagnostics in the CBC test suite.

test_client_adapter.py: create_agent_client() returns the CBCClient
Protocol, which has no private members, so accessing _ssl_factory /
_base_url / _resolve_app_tenant_id failed. Narrow to DefaultClient with
an isinstance assert before touching privates, and guard the optional
_ssl_factory before calling it.

test_client.py: orig_build = client._build_http_client is typed
() -> httpx.Client, so orig_build() was Client, not MagicMock, breaking
the .request wiring and the -> MagicMock return annotations. Align the
build helpers' return types with the method they replace (httpx.Client),
cast() the mock where .request is wired, and replace the ineffective
mypy # type: ignore[method-assign] with the repo's ty idiom
# ty: ignore[invalid-assignment].
Replace the three loose keyword args on create_agent_client
(destination_instance, cbc_cert_name, p12_password) with a single
CBCDestinationConfig settings object, addressing the PR review request
for a config object. The object lives in a new cbc/config.py, matching
the dedicated-config.py convention of the agw, adms, print, and
destination modules.

Per-field env overrides are unchanged: a value set on the config wins
over its CLOUD_SDK_CBC_* env var, which in turn falls back to the
platform default. Scoped to the adapter layer only — the core
create_client and its hardcoded timeout are untouched; a core config
object is deferred.

Also rename the private _ClientConfig (API-path holder) to _ApiPaths so
it no longer reads as a near-homonym of the new public config class.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add CBC (Central Business Configuration) consumption support

2 participants