All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Replace all 676 inline sexp() string calls across 7 services with render(component_name, **kwargs) calls backed by 46 external .sexpr component definition files (587 defcomps total). - Add render() function to shared/sexp/jinja_bridge.py - Add load_service_components() helper and update load_sexp_dir() for *.sexpr - Update parser keyword regex to support HTMX hx-on::event syntax - Convert remaining inline HTML in route files to render() calls - Add shared/sexp/templates/misc.sexp for cross-service utility components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
192 lines
6.8 KiB
Python
192 lines
6.8 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 _sexp_error_page(errnum: str, message: str, image: str | None = None) -> str:
|
|
"""Render an error page via s-expressions. Bypasses Jinja entirely."""
|
|
from shared.sexp.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,
|
|
)
|
|
|
|
|
|
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:
|
|
# Render via s-expressions (Phase 5 proof-of-concept)
|
|
try:
|
|
html = _sexp_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:
|
|
try:
|
|
html = _sexp_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("HX-Request") == "true":
|
|
from shared.sexp.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("HX-Request") == "true":
|
|
from shared.sexp.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("HX-Request") == "true":
|
|
return await make_response(
|
|
"Something went wrong. Please try again.",
|
|
status,
|
|
)
|
|
|
|
# Raw HTML — avoids context processor / fragment loop on errors.
|
|
return await make_response(_error_page(
|
|
"WELL THIS IS EMBARRASSING…"
|
|
), status)
|