Files
mono/shared/browser/app/errors.py
giles c0d369eb8e Refactor SX templates: shared components, Python migration, cleanup
- Extract shared components (empty-state, delete-btn, sentinel, crud-*,
  view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth
  forms, order tables/detail/checkout)
- Migrate all Python sx_call() callers to use shared components directly
- Remove 55+ thin wrapper defcomps from domain .sx files
- Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc)
- Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx
- Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx
- Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0)
- Add SX response validation and debug headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:34:34 +00:00

307 lines
11 KiB
Python

from werkzeug.exceptions import HTTPException
from shared.utils import hx_fragment_request
from quart import (
request,
render_template,
make_response,
current_app
)
from markupsafe import escape
from shared.infrastructure.fragments import FragmentError as _FragmentError
class AppError(ValueError):
"""
Base class for app-level, client-safe errors.
Behaves like ValueError so existing except ValueError: still works.
"""
status_code: int = 400
def __init__(self, message, *, status_code: int | None = None):
# Support a single message or a list/tuple of messages
if isinstance(message, (list, tuple, set)):
self.messages = [str(m) for m in message]
msg = self.messages[0] if self.messages else ""
else:
self.messages = [str(message)]
msg = str(message)
super().__init__(msg)
if status_code is not None:
self.status_code = status_code
def _error_page(message: str) -> str:
"""Self-contained error page HTML. Bypasses Jinja/context processors."""
return (
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Error</title>"
"<style>"
"body{margin:0;min-height:100vh;display:flex;align-items:center;"
"justify-content:center;font-family:system-ui,sans-serif;"
"background:#fafafa;color:#333}"
".box{text-align:center;padding:2rem;max-width:480px}"
".box h1{font-size:1.5rem;color:#ef4444;margin:0 0 1rem}"
".box p{margin:0 0 1.5rem;line-height:1.6}"
".box a{color:#3b82f6;text-decoration:none}"
".box img{max-width:300px;margin:1rem auto}"
"</style></head><body>"
"<div class='box'>"
f"<h1>{message}</h1>"
"<img src='/static/errors/error.gif' width='300' height='300'>"
"<p><a href='javascript:location.reload()'>Reload</a></p>"
"</div></body></html>"
)
def _sx_error_page(errnum: str, message: str, image: str | None = None) -> str:
"""Render an error page via s-expressions. Bypasses Jinja entirely."""
from shared.sx.page import render_page
return render_page(
'(~error-page :title title :message message :image image :asset-url "/static")',
title=f"{errnum} Error",
message=message,
image=image,
)
async def _rich_error_page(errnum: str, message: str, image: str | None = None) -> str | None:
"""Try to render an error page with site headers and post breadcrumb.
Returns HTML string on success, None if we should fall back to the
minimal error page. All failures are swallowed — this must never
make a bad situation worse.
"""
from quart import g
# Skip for internal/static requests
if request.path.startswith(("/internal/", "/static/", "/auth/")):
return None
try:
# Build a minimal context — avoid get_template_context() which
# calls cross-service fragment fetches that may fail.
from shared.infrastructure.context import base_context
try:
ctx = await base_context()
except Exception:
ctx = {"base_title": "Rose Ash", "asset_url": "/static"}
# Inject Jinja globals (blog_url, cart_url, etc.) — these are
# needed by call_url() for cross-subdomain links.
for key, val in current_app.jinja_env.globals.items():
if key not in ctx:
ctx[key] = val
# Try to fetch fragments, but don't fail if they're unreachable
try:
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.cart_identity import current_cart_identity
user = getattr(g, "user", None)
ident = current_cart_identity()
cart_params = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini, auth_menu, nav_tree = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": current_app.name, "path": request.path}),
])
ctx["cart_mini"] = cart_mini
ctx["auth_menu"] = auth_menu
ctx["nav_tree"] = nav_tree
except Exception:
ctx.setdefault("cart_mini", "")
ctx.setdefault("auth_menu", "")
ctx.setdefault("nav_tree", "")
# Try to hydrate post data from slug if not already available
segments = [s for s in request.path.strip("/").split("/") if s]
slug = segments[0] if segments else None
post_data = getattr(g, "post_data", None)
if slug and not post_data:
try:
from shared.infrastructure.data_client import fetch_data
raw = await fetch_data(
"blog", "post-by-slug",
params={"slug": slug},
required=False,
)
if raw:
post_data = {
"post": {
"id": raw["id"],
"title": raw["title"],
"slug": raw["slug"],
"feature_image": raw.get("feature_image"),
},
}
except Exception:
pass
# Root header (site nav bar)
from shared.sx.helpers import (
root_header_sx, post_header_sx,
header_child_sx, full_page_sx, sx_call,
)
hdr = root_header_sx(ctx)
# Post breadcrumb if we resolved a post
post = (post_data or {}).get("post") or ctx.get("post") or {}
if post.get("slug"):
ctx["post"] = post
post_row = post_header_sx(ctx)
if post_row:
hdr = "(<> " + hdr + " " + header_child_sx(post_row) + ")"
# Error content
error_content = sx_call("error-content", errnum=errnum, message=message, image=image)
return full_page_sx(ctx, header_rows=hdr, content=error_content)
except Exception:
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
return None
def errors(app):
def _info(e):
return {
"exception": e,
"method": request.method,
"url": str(request.url),
"base_url": str(request.base_url),
"root_path": request.root_path,
"path": request.path,
"full_path": request.full_path,
"endpoint": request.endpoint,
"url_rule": str(request.url_rule) if request.url_rule else None,
"headers": {k: v for k, v in request.headers.items()
if k.lower().startswith("x-forwarded") or k in ("Host",)},
}
@app.errorhandler(404)
async def not_found(e):
current_app.logger.warning("404 %s", _info(e))
if hx_fragment_request():
html = await render_template(
"_types/root/exceptions/hx/_.html",
errnum='404'
)
else:
# Try a rich error page with site headers + post breadcrumb
html = await _rich_error_page(
"404", "NOT FOUND",
image="/static/errors/404.gif",
)
if html is None:
try:
html = _sx_error_page(
"404", "NOT FOUND",
image="/static/errors/404.gif",
)
except Exception:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='404',
)
return await make_response(html, 404)
@app.errorhandler(403)
async def not_allowed(e):
current_app.logger.warning("403 %s", _info(e))
if hx_fragment_request():
html = await render_template(
"_types/root/exceptions/hx/_.html",
errnum='403'
)
else:
html = await _rich_error_page(
"403", "FORBIDDEN",
image="/static/errors/403.gif",
)
if html is None:
try:
html = _sx_error_page(
"403", "FORBIDDEN",
image="/static/errors/403.gif",
)
except Exception:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='403',
)
return await make_response(html, 403)
@app.errorhandler(AppError)
async def app_error(e: AppError):
# App-level, client-safe errors
current_app.logger.info("AppError %s", _info(e))
status = getattr(e, "status_code", 400)
messages = getattr(e, "messages", [str(e)])
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
from shared.sx.jinja_bridge import render as render_comp
items = "".join(
render_comp("error-list-item", message=str(escape(m)))
for m in messages if m
)
html = render_comp("error-list", items_html=items)
return await make_response(html, status)
# Non-HTMX: show a nicer page with error messages
html = await render_template(
"_types/root/exceptions/app_error.html",
messages=messages,
)
return await make_response(html, status)
@app.errorhandler(_FragmentError)
async def fragment_error(e):
current_app.logger.error("FragmentError %s", _info(e))
msg = str(e)
# Extract service name from "Fragment account/auth-menu failed: ..."
service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown"
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
from shared.sx.jinja_bridge import render as render_comp
return await make_response(
render_comp("fragment-error", service=str(escape(service))),
503,
)
# Raw HTML — cannot use render_template here because the context
# processor would try to fetch fragments again → infinite loop.
return await make_response(_error_page(
f"The <b>{escape(service)}</b> service is currently unavailable. It may be restarting."
), 503)
@app.errorhandler(Exception)
async def error(e):
current_app.logger.exception("Exception %s", _info(e))
status = 500
if isinstance(e, HTTPException):
status = e.code or 500
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
return await make_response(
"Something went wrong. Please try again.",
status,
)
errnum = str(status)
html = await _rich_error_page(
errnum, "WELL THIS IS EMBARRASSING\u2026",
image="/static/errors/error.gif",
)
if html is None:
html = _error_page("WELL THIS IS EMBARRASSING&hellip;")
return await make_response(html, status)