diff --git a/infrastructure/fragments.py b/infrastructure/fragments.py index 90754a0..2c287c2 100644 --- a/infrastructure/fragments.py +++ b/infrastructure/fragments.py @@ -5,8 +5,8 @@ Each coop app exposes HTML fragments at ``/internal/fragments/{type}``. This module provides helpers to fetch and cache those fragments so that consuming apps can compose cross-app UI without shared templates. -All functions return ``""`` on error (graceful degradation — a missing -fragment simply means a section is absent from the page). +Failures raise ``FragmentError`` by default so broken fragments are +immediately visible rather than silently missing from the page. """ from __future__ import annotations @@ -31,6 +31,10 @@ _DEFAULT_TIMEOUT = 2.0 FRAGMENT_HEADER = "X-Fragment-Request" +class FragmentError(Exception): + """Raised when a fragment fetch fails.""" + + def _get_client() -> httpx.AsyncClient: global _client if _client is None or _client.is_closed: @@ -61,10 +65,13 @@ async def fetch_fragment( *, params: dict | None = None, timeout: float = _DEFAULT_TIMEOUT, + required: bool = True, ) -> str: """Fetch an HTML fragment from another app. - Returns the raw HTML string, or ``""`` on any error. + Returns the raw HTML string. When *required* is True (default), + raises ``FragmentError`` on network errors or non-200 responses. + When *required* is False, returns ``""`` on failure. """ base = _internal_url(app_name) url = f"{base}/internal/fragments/{fragment_type}" @@ -77,10 +84,20 @@ async def fetch_fragment( ) if resp.status_code == 200: return resp.text - log.debug("Fragment %s/%s returned %s", app_name, fragment_type, resp.status_code) + msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}" + if required: + log.error(msg) + raise FragmentError(msg) + log.warning(msg) return "" - except Exception: - log.debug("Fragment %s/%s failed", app_name, fragment_type, exc_info=True) + except FragmentError: + raise + except Exception as exc: + msg = f"Fragment {app_name}/{fragment_type} failed: {exc}" + if required: + log.error(msg) + raise FragmentError(msg) from exc + log.warning(msg) return "" @@ -88,15 +105,16 @@ async def fetch_fragments( requests: Sequence[tuple[str, str, dict | None]], *, timeout: float = _DEFAULT_TIMEOUT, + required: bool = True, ) -> list[str]: """Fetch multiple fragments concurrently. *requests* is a sequence of ``(app_name, fragment_type, params)`` tuples. - Returns a list of HTML strings in the same order. Failed fetches - produce ``""``. + Returns a list of HTML strings in the same order. When *required* + is True, any single failure raises ``FragmentError``. """ return list(await asyncio.gather(*( - fetch_fragment(app, ftype, params=params, timeout=timeout) + fetch_fragment(app, ftype, params=params, timeout=timeout, required=required) for app, ftype, params in requests ))) @@ -108,11 +126,11 @@ async def fetch_fragment_cached( params: dict | None = None, ttl: int = 30, timeout: float = _DEFAULT_TIMEOUT, + required: bool = True, ) -> str: """Fetch a fragment with a Redis cache layer. Cache key: ``frag:{app}:{type}:{sorted_params}``. - Returns ``""`` on error (cache miss + fetch failure). """ # Build a stable cache key suffix = "" @@ -133,7 +151,7 @@ async def fetch_fragment_cached( # Cache miss — fetch from provider html = await fetch_fragment( - app_name, fragment_type, params=params, timeout=timeout, + app_name, fragment_type, params=params, timeout=timeout, required=required, ) # Store in cache (even empty string — avoids hammering a down service)