All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m11s
- Error handlers for FragmentError and generic Exception now return self-contained HTML (no render_template) to avoid the infinite loop where context processor → fetch_fragments → error → render_template → context processor → fetch_fragments → error ... - Account Ghost membership sync moved to background task so it doesn't block Hypercorn's startup timeout (was causing crash-loop). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
170 lines
5.9 KiB
Python
170 lines
5.9 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 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:
|
|
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 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":
|
|
# Build a little styled <ul><li>...</li></ul> snippet
|
|
lis = "".join(
|
|
f"<li>{escape(m)}</li>"
|
|
for m in messages if m
|
|
)
|
|
html = (
|
|
"<ul class='list-disc pl-5 space-y-1 text-sm text-red-600'>"
|
|
f"{lis}"
|
|
"</ul>"
|
|
)
|
|
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":
|
|
return await make_response(
|
|
f"<p class='text-sm text-red-600'>Service <b>{escape(service)}</b> is unavailable.</p>",
|
|
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)
|