"""ows-royalties downstream catalog: builds a GuardRegistry from this service's resources. The per-service "consumer" layer over the generic mechanism in `guards.py`. `build_registry()` is a factory (not an import side effect) so callers/tests can construct their own registry; the module-level `REGISTRY` is the default the app wires up (to be moved onto `app.extensions` by `init_hardening` in a later phase). Another service writes its own version of this file. """ from typing import Any, Callable, Hashable from core.hardening.guards import ( Adapter, Classifier, DownstreamServerError, DownstreamTransportError, GuardRegistry, Policy, httpx_timeout_adapter, is_downstream_failure, no_timeout_adapter, requests_timeout_adapter, ) from core.hardening.resources import DOWNSTREAMS, Resource try: # observability.py lands in a later phase; until then the registry uses its no-op default from core.hardening.observability import on_breaker_state except ImportError: # pragma: no cover on_breaker_state = None def make_snowflake_adapter( executor_factory: Callable[..., Any] | None = None, ) -> Adapter: """Build a Snowflake adapter that opens an executor and runs the call inside its with-block. `executor_factory(statement_timeout_in_seconds=...)` defaults to the repo-local GuardedAdjustmentsExecutor, lazy-imported so the HTTP-only paths and tests don't need the Snowflake driver; inject a fake in tests. """ def adapter(resource: Hashable, policy: Policy, call: Callable[..., Any]) -> Any: factory = executor_factory if factory is None: from royalties.connectors.snowflake_guarded import ( GuardedAdjustmentsExecutor, ) factory = GuardedAdjustmentsExecutor with factory(statement_timeout_in_seconds=int(policy.read_timeout)) as ex: return call(ex) return adapter def _is_snowflake_failure(e: BaseException) -> bool: """Count Snowflake operational/database failures only (bad-SQL ProgrammingError passes through). Scoped to the `snowflake.*` module so an identically-named `OperationalError` from another driver (sqlite3, psycopg2, SQLAlchemy) can never trip this breaker. """ from_snowflake = (type(e).__module__ or '').startswith('snowflake.') return from_snowflake and type(e).__name__ in {'OperationalError', 'DatabaseError'} def _catalog() -> dict[Resource, tuple[Adapter, Classifier]]: # (adapter, classifier) per resource. HTTP resources normalize their own failures in the # adapter, so they share the generic classifier; Snowflake keeps a driver-specific one. return { Resource.OWS_ABACUS_ACCOUNT: (httpx_timeout_adapter, is_downstream_failure), Resource.AIRFLOW_MWAA: (requests_timeout_adapter, is_downstream_failure), Resource.OWS_COLLABORATOR: (no_timeout_adapter, is_downstream_failure), Resource.SNOWFLAKE: (make_snowflake_adapter(), _is_snowflake_failure), } def build_registry( on_state_change: Callable[[str, str, str], None] | None = on_breaker_state, ) -> GuardRegistry: """Build a fresh GuardRegistry holding this service's downstream catalog.""" catalog = _catalog() if set(catalog) != set(DOWNSTREAMS): raise RuntimeError( 'every downstream resource needs a policy + (adapter, classifier)' ) registry = GuardRegistry(on_state_change=on_state_change) for resource, (adapter, classifier) in catalog.items(): registry.register(resource, DOWNSTREAMS[resource], adapter, classifier) return registry REGISTRY: GuardRegistry = build_registry() def call_downstream( resource: Resource, call: Callable[..., Any], *, idempotent: bool = False, registry: GuardRegistry | None = None, ) -> Any: """Run an outbound `call` to `resource` under its breaker + (idempotent-only) bounded retry. Defaults to the module `REGISTRY`; pass `registry` to use a different one (e.g. in tests). """ return (registry or REGISTRY).call(resource, call, idempotent=idempotent) # REGISTRY is intentionally not exported: prefer build_registry() / call_downstream(registry=...). # It stays a module attribute (the app default wiring), to be moved onto app.extensions later. __all__ = [ 'DownstreamServerError', 'DownstreamTransportError', 'build_registry', 'call_downstream', 'make_snowflake_adapter', ]