Fix error page loop + account startup timeout

- 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>
This commit is contained in:
giles
2026-02-25 14:45:29 +00:00
parent 1ea9ae4050
commit 3797a0c7c9
2 changed files with 48 additions and 19 deletions

View File

@@ -82,18 +82,22 @@ def create_app() -> "Quart":
from bp.data.routes import register as register_data
app.register_blueprint(register_data())
# --- Ghost membership sync at startup ---
# --- Ghost membership sync at startup (background) ---
# Runs as a background task to avoid blocking Hypercorn's startup timeout.
@app.before_serving
async def _sync_ghost_membership():
from services.ghost_membership import sync_all_membership_from_ghost
from shared.db.session import get_session
try:
async with get_session() as s:
await sync_all_membership_from_ghost(s)
await s.commit()
print("[account] Ghost membership sync complete")
except Exception as e:
print(f"[account] Ghost membership sync failed (non-fatal): {e}")
async def _schedule_ghost_membership_sync():
import asyncio
async def _sync():
from services.ghost_membership import sync_all_membership_from_ghost
from shared.db.session import get_session
try:
async with get_session() as s:
await sync_all_membership_from_ghost(s)
await s.commit()
print("[account] Ghost membership sync complete")
except Exception as e:
print(f"[account] Ghost membership sync failed (non-fatal): {e}")
asyncio.get_event_loop().create_task(_sync())
return app

View File

@@ -33,6 +33,30 @@ class AppError(ValueError):
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 {
@@ -119,11 +143,11 @@ def errors(app):
f"<p class='text-sm text-red-600'>Service <b>{escape(service)}</b> is unavailable.</p>",
503,
)
html = await render_template(
"_types/root/exceptions/error.html",
service_name=service,
)
return await make_response(html, 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):
@@ -134,11 +158,12 @@ def errors(app):
status = e.code or 500
if request.headers.get("HX-Request") == "true":
# Generic message for unexpected/untrusted errors
return await make_response(
"Something went wrong. Please try again.",
status,
)
html = await render_template("_types/root/exceptions/error.html")
return await make_response(html, status)
# Raw HTML — avoids context processor / fragment loop on errors.
return await make_response(_error_page(
"WELL THIS IS EMBARRASSING&hellip;"
), status)