Make fragment failures raise by default instead of silent degradation
FragmentError raised on network errors or non-200 responses when required=True (default). Logs at ERROR level. Pass required=False for optional fragments that should degrade gracefully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
This module provides helpers to fetch and cache those fragments so that
|
||||||
consuming apps can compose cross-app UI without shared templates.
|
consuming apps can compose cross-app UI without shared templates.
|
||||||
|
|
||||||
All functions return ``""`` on error (graceful degradation — a missing
|
Failures raise ``FragmentError`` by default so broken fragments are
|
||||||
fragment simply means a section is absent from the page).
|
immediately visible rather than silently missing from the page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -31,6 +31,10 @@ _DEFAULT_TIMEOUT = 2.0
|
|||||||
FRAGMENT_HEADER = "X-Fragment-Request"
|
FRAGMENT_HEADER = "X-Fragment-Request"
|
||||||
|
|
||||||
|
|
||||||
|
class FragmentError(Exception):
|
||||||
|
"""Raised when a fragment fetch fails."""
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> httpx.AsyncClient:
|
def _get_client() -> httpx.AsyncClient:
|
||||||
global _client
|
global _client
|
||||||
if _client is None or _client.is_closed:
|
if _client is None or _client.is_closed:
|
||||||
@@ -61,10 +65,13 @@ async def fetch_fragment(
|
|||||||
*,
|
*,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fetch an HTML fragment from another app.
|
"""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)
|
base = _internal_url(app_name)
|
||||||
url = f"{base}/internal/fragments/{fragment_type}"
|
url = f"{base}/internal/fragments/{fragment_type}"
|
||||||
@@ -77,10 +84,20 @@ async def fetch_fragment(
|
|||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.text
|
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 ""
|
return ""
|
||||||
except Exception:
|
except FragmentError:
|
||||||
log.debug("Fragment %s/%s failed", app_name, fragment_type, exc_info=True)
|
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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -88,15 +105,16 @@ async def fetch_fragments(
|
|||||||
requests: Sequence[tuple[str, str, dict | None]],
|
requests: Sequence[tuple[str, str, dict | None]],
|
||||||
*,
|
*,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Fetch multiple fragments concurrently.
|
"""Fetch multiple fragments concurrently.
|
||||||
|
|
||||||
*requests* is a sequence of ``(app_name, fragment_type, params)`` tuples.
|
*requests* is a sequence of ``(app_name, fragment_type, params)`` tuples.
|
||||||
Returns a list of HTML strings in the same order. Failed fetches
|
Returns a list of HTML strings in the same order. When *required*
|
||||||
produce ``""``.
|
is True, any single failure raises ``FragmentError``.
|
||||||
"""
|
"""
|
||||||
return list(await asyncio.gather(*(
|
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
|
for app, ftype, params in requests
|
||||||
)))
|
)))
|
||||||
|
|
||||||
@@ -108,11 +126,11 @@ async def fetch_fragment_cached(
|
|||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
ttl: int = 30,
|
ttl: int = 30,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fetch a fragment with a Redis cache layer.
|
"""Fetch a fragment with a Redis cache layer.
|
||||||
|
|
||||||
Cache key: ``frag:{app}:{type}:{sorted_params}``.
|
Cache key: ``frag:{app}:{type}:{sorted_params}``.
|
||||||
Returns ``""`` on error (cache miss + fetch failure).
|
|
||||||
"""
|
"""
|
||||||
# Build a stable cache key
|
# Build a stable cache key
|
||||||
suffix = ""
|
suffix = ""
|
||||||
@@ -133,7 +151,7 @@ async def fetch_fragment_cached(
|
|||||||
|
|
||||||
# Cache miss — fetch from provider
|
# Cache miss — fetch from provider
|
||||||
html = await fetch_fragment(
|
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)
|
# Store in cache (even empty string — avoids hammering a down service)
|
||||||
|
|||||||
Reference in New Issue
Block a user