Compare commits

..

3 Commits

Author SHA1 Message Date
giles
9ab4b7b3fe Prevent circular fragment fetching between apps
fetch_fragment() auto-returns "" when called inside a fragment request
(detected via X-Fragment-Request header). This prevents deadlocks when
e.g. blog fetches cart-mini from cart, and cart's context processor
fetches nav-tree from blog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:17:47 +00:00
giles
20d3ff8425 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>
2026-02-24 18:03:04 +00:00
giles
cf2e2ba1db Remove cross-domain template dependencies from shared infrastructure
- macros/search.html: shared search input macros (mobile + desktop)
- macros/cart_icon.html: shared cart icon/badge macro (count param, no DB)
- macros/layout.html: inline hamburger icon, use shared search macro
- _oob.html: use cart_mini_html fragment slot instead of cart template import
- db/session.py: guard teardown rollback against committed/dead sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:28:09 +00:00
6 changed files with 184 additions and 20 deletions

View File

@@ -23,9 +23,10 @@
{% endif %}
<div class="flex flex-col items-center flex-1">
<div class="flex w-full justify-center md:justify-start">
{# Cart mini #}
{% from '_types/cart/_mini.html' import mini with context %}
{{mini()}}
{# Cart mini — rendered via fragment #}
{% if cart_mini_html %}
{{ cart_mini_html | safe }}
{% endif %}
{# Site title #}
<div class="font-bold text-5xl flex-1">

View File

@@ -0,0 +1,31 @@
{# Cart icon/badge — shows logo when empty, cart icon with count when items present #}
{% macro cart_icon(count=0, oob=False) %}
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %}>
{% if count == 0 %}
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
<a
href="{{ blog_url('/') }}"
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
>
<img
src="{{ site().logo }}"
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
>
</a>
</div>
{% else %}
<a
href="{{ cart_url('/') }}"
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
>
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
<span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
>
{{ count }}
</span>
</a>
{% endif %}
</div>
{% endmacro %}

View File

@@ -26,7 +26,17 @@
<summary class="bg-white/90">
<div class="flex flex-row items-start">
<div>
{% include '_types/blog/mobile/_filter/_hamburger.html' %}
<div class="md:hidden mx-2 bg-stone-200 rounded">
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
<i class="fa-solid fa-filter"></i>
</span>
<span>
<svg aria-hidden="true" viewBox="0 0 24 24"
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
<path d="M6 9l6 6 6-6" fill="currentColor"/>
</svg>
</span>
</div>
</div>
<div
id="{{id}}"
@@ -37,8 +47,8 @@
</div>
</div>
{% import '_types/browse/mobile/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% from 'macros/search.html' import search_mobile %}
{{ search_mobile(current_local_href, search, search_count, hx_select) }}
</div>
</summary>
{%- endmacro %}

View File

@@ -0,0 +1,83 @@
{# Shared search input macros for filter UIs #}
{% macro search_mobile(current_local_href, search, search_count, hx_select) -%}
<div
id="search-mobile-wrapper"
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
>
<input
id="search-mobile"
type="text"
name="search"
aria-label="search"
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-mobile"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
</div>
</div>
{%- endmacro %}
{% macro search_desktop(current_local_href, search, search_count, hx_select) -%}
<div
id="search-desktop-wrapper"
class="flex flex-row gap-2 items-center"
>
<input
id="search-desktop"
type="text"
name="search"
aria-label="search"
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-desktop"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
{{zap_filter}}
</div>
</div>
{%- endmacro %}

View File

@@ -64,11 +64,17 @@ def register_db(app: Quart):
# If an exception occurred OR we didn't commit (still in txn), roll back.
if hasattr(g, "s"):
if exc is not None or g.s.in_transaction():
if hasattr(g, "tx"):
await g.tx.rollback()
if hasattr(g, "tx") and g.tx.is_active:
try:
await g.tx.rollback()
except Exception:
pass
finally:
if hasattr(g, "s"):
await g.s.close()
try:
await g.s.close()
except Exception:
pass
@app.errorhandler(Exception)
async def mark_error(e):

View File

@@ -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:
@@ -55,17 +59,35 @@ def _internal_url(app_name: str) -> str:
# Public API
# ------------------------------------------------------------------
def _is_fragment_request() -> bool:
"""True when the current request is itself a fragment fetch."""
try:
from quart import request as _req
return bool(_req.headers.get(FRAGMENT_HEADER))
except Exception:
return False
async def fetch_fragment(
app_name: str,
fragment_type: str,
*,
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.
Automatically returns ``""`` when called inside a fragment request
to prevent circular dependencies between apps.
"""
if _is_fragment_request():
return ""
base = _internal_url(app_name)
url = f"{base}/internal/fragments/{fragment_type}"
try:
@@ -77,10 +99,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 +120,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 +141,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 +166,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)