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 (
"
"
""
"Error"
""
""
)
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 {escape(service)} 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)