"""Internal action client for cross-app write operations. Each coop app exposes JSON action endpoints at ``/internal/actions/{name}``. This module provides helpers to call those endpoints so that callers don't need direct access to another app's DB session or service layer. Failures raise ``ActionError`` so callers can handle or propagate them. """ from __future__ import annotations import logging import os import httpx log = logging.getLogger(__name__) # Re-usable async client (created lazily, one per process) _client: httpx.AsyncClient | None = None # Default request timeout (seconds) — longer than fragments since these are writes _DEFAULT_TIMEOUT = 5.0 # Header sent on every action request so providers can gate access. ACTION_HEADER = "X-Internal-Action" class ActionError(Exception): """Raised when an internal action call fails.""" def __init__(self, message: str, status_code: int = 500, detail: dict | None = None): super().__init__(message) self.status_code = status_code self.detail = detail def _get_client() -> httpx.AsyncClient: global _client if _client is None or _client.is_closed: _client = httpx.AsyncClient( timeout=httpx.Timeout(_DEFAULT_TIMEOUT), follow_redirects=False, ) return _client def _internal_url(app_name: str) -> str: """Resolve the Docker-internal base URL for *app_name*.""" env_key = f"INTERNAL_URL_{app_name.upper()}" return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/") async def call_action( app_name: str, action_name: str, *, payload: dict | None = None, timeout: float = _DEFAULT_TIMEOUT, ) -> dict: """POST JSON to ``{INTERNAL_URL_APP}/internal/actions/{action_name}``. Returns the parsed JSON response on 2xx. Raises ``ActionError`` on network errors or non-2xx responses. """ base = _internal_url(app_name) url = f"{base}/internal/actions/{action_name}" try: resp = await _get_client().post( url, json=payload or {}, headers={ACTION_HEADER: "1"}, timeout=timeout, ) if 200 <= resp.status_code < 300: return resp.json() msg = f"Action {app_name}/{action_name} returned {resp.status_code}" detail = None try: detail = resp.json() except Exception: pass log.error(msg) raise ActionError(msg, status_code=resp.status_code, detail=detail) except ActionError: raise except Exception as exc: msg = f"Action {app_name}/{action_name} failed: {exc}" log.error(msg) raise ActionError(msg) from exc