Coverage for src/lexigram/admin/middleware/error.py: 58%
136 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
1"""Error handling middleware with detailed error pages."""
3from __future__ import annotations
5import traceback
6from typing import Any
7from urllib.parse import quote
9from starlette.datastructures import URL
10from starlette.exceptions import HTTPException as HTTPError
11from starlette.middleware.base import BaseHTTPMiddleware
12from starlette.requests import Request
13from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
15from lexigram.admin.exceptions import NotFoundError
16from lexigram.admin.state.context import wants_fragment
17from lexigram.logging import get_logger
18from lexigram.ui import el, render_to_string
20logger = get_logger(__name__)
23class AdminErrorMiddleware(BaseHTTPMiddleware):
24 """HTTP middleware that catches exceptions and displays detailed error pages or JSON responses.
26 Uses BaseHTTPMiddleware for proper exception handling in Starlette applications.
27 """
29 def __init__(
30 self, app, debug: bool = True, login_url: str = "/admin/login"
31 ) -> None:
32 """Initialize error middleware.
34 Args:
35 app: ASGI application
36 debug: Whether to show detailed error pages
37 login_url: URL to redirect to for 401 Unauthorized
38 """
39 super().__init__(app)
40 self.debug = debug
41 self.login_url = login_url
43 async def dispatch(self, request: Request, call_next) -> Any:
44 """Dispatch method that wraps request processing and catches exceptions."""
45 try:
46 return await call_next(request)
47 except Exception as exc: # noqa: BLE001 — last-resort error middleware must catch all
48 logger.exception(
49 "AdminErrorMiddleware caught exception: %s", type(exc).__name__
50 )
51 return await self.handle(request, exc)
53 def _should_return_json(self, request: Request) -> bool:
54 """Determine if JSON response is preferred."""
55 accept = request.headers.get("accept", "")
56 # Check explicit Accept header
57 if "application/json" in accept:
58 return True
59 # Check path heuristic for API requests
60 try:
61 from lexigram.admin.settings import get_admin_settings
63 api_prefix = get_admin_settings().ADMIN_API_PREFIX.rstrip("/") # type: ignore[attr-defined]
64 if request.url.path.startswith(api_prefix):
65 # But if it's HTMX, we might prefer HTML fragments
66 return not self._is_htmx(request)
67 except (ImportError, AttributeError):
68 pass
69 return False
71 def _is_htmx(self, request: Request) -> bool:
72 """Check if request expects a fragment swap."""
73 return wants_fragment(request)
75 async def handle(self, request: Request, exc: Exception) -> Response:
76 """Handle exceptions and return a Response object.
78 This method is compatible with Starlette's exception_handler signature.
79 """
80 status_code = 500
81 message = "Internal Server Error"
83 if isinstance(exc, NotFoundError):
84 status_code = 404
85 message = str(exc)
86 elif isinstance(exc, HTTPError):
87 status_code = exc.status_code
88 message = exc.detail
90 # Decide content type
91 if self._should_return_json(request):
92 return self._make_json_response(status_code, message)
93 if self._is_htmx(request):
94 return self._make_htmx_response(request, status_code, message, exc)
95 return self._make_html_response(request, status_code, message, exc)
97 def _make_json_response(self, status_code: int, message: str) -> Response:
98 """Create JSON error response."""
99 return JSONResponse(
100 status_code=status_code,
101 content={"error": {"code": status_code, "message": message}},
102 )
104 def _make_htmx_response(
105 self,
106 request: Request,
107 status_code: int,
108 message: str,
109 exc: Exception | None = None,
110 ) -> Response:
111 """Create HTMX error response with professional styling."""
112 # 1. Handle 401 - Full-page redirect to login via HX-Redirect so the
113 # login page is not swapped into the current component. Loop-guarded
114 # when the request already targets the login page.
115 if status_code == 401:
116 if str(request.url.path).rstrip("/") == str(self.login_url).rstrip("/"):
117 return JSONResponse(
118 status_code=401,
119 content={"error": "session_expired", "login_url": self.login_url},
120 )
121 full = (
122 request.url.path
123 if not request.url.query
124 else f"{request.url.path}?{request.url.query}"
125 )
126 next_url = quote(full, safe="/?=&")
127 login_url = f"{self.login_url}?next={next_url}"
128 response = Response(status_code=200)
129 response.headers["HX-Redirect"] = login_url
130 return response
132 # 2. Map Status to Metadata
133 title = "Error"
134 icon = "⚠️"
136 if status_code == 403:
137 title = "Access Denied"
138 icon = "🔒"
139 elif status_code == 404:
140 title = "Not Found"
141 icon = "🔍"
142 elif status_code == 500:
143 title = "Server Error"
144 icon = "💥"
146 # 3. Handle Debug Details
147 debug_html = ""
148 debug_button = ""
149 if self.debug and exc:
150 tb_text = "".join(
151 traceback.format_exception(type(exc), exc, exc.__traceback__),
152 )
153 debug_html = f"""
154 <div id="error-details-{id(exc)}" class="hidden mt-4 p-4 bg-background text-foreground rounded-lg text-xs overflow-auto max-h-64 font-mono">
155 {tb_text}
156 </div>
157 """
158 debug_button = f"""
159 <button onclick="document.getElementById('error-details-{id(exc)}').classList.toggle('hidden')" class="mr-2 text-sm font-medium text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300">
160 Show Details
161 </button>
162 """
164 # 4. Create Styled Fragment
165 html = f"""
166 <div class="admin-error-fragment p-6 my-4 bg-card rounded-xl shadow-sm border border-destructive/30">
167 <div class="flex items-center gap-4">
168 <div class="w-12 h-12 bg-destructive/10 rounded-full flex items-center justify-center text-2xl">
169 {icon}
170 </div>
171 <div>
172 <h3 class="text-lg font-bold text-foreground">{title} ({status_code})</h3>
173 <p class="text-muted-foreground">{message}</p>
174 </div>
175 </div>
176 {debug_html}
177 <div class="mt-4 flex justify-end">
178 {debug_button}
179 <button onclick="this.closest('.admin-error-fragment').remove()" class="text-sm font-medium text-muted-foreground hover:text-foreground">
180 Dismiss
181 </button>
182 </div>
183 </div>
184 """
186 response = HTMLResponse(html, status_code=200) # 200 so HTMX swaps by default
188 # 4. Trigger Toast Notifications
189 from lexigram.serialization import dumps_str
191 response.headers["HX-Trigger"] = dumps_str(
192 {"showMessage": {"message": f"{title}: {message}", "type": "error"}},
193 )
195 return response
197 def _make_html_response(
198 self,
199 request: Request,
200 status_code: int,
201 message: str,
202 exc: Exception,
203 ) -> Response:
204 """Create HTML error response."""
206 # 1. Handle 401 - Redirect to Login
207 if status_code == 401:
208 login_url = URL(self.login_url).include_query_params(next=str(request.url))
209 return RedirectResponse(url=str(login_url), status_code=302)
211 # 2. Handle 500 in Debug Mode - Show Traceback
212 if status_code == 500 and self.debug and not isinstance(exc, HTTPError):
213 html = self._render_debug_error_html(request, exc)
214 return HTMLResponse(html, status_code=500)
216 # 3. Handle Other Errors (403, 404, 500 production) - Show Styled Page
217 from lexigram.admin.lib.template import render_error_page
219 title = "Error"
220 icon = "⚠️"
222 if status_code == 403:
223 title = "Access Denied"
224 message = "You don't have permission to access this resource."
225 icon = "🔒"
226 elif status_code == 404:
227 title = "Page Not Found"
228 message = "The page you're looking for could not be found."
229 icon = "🔍"
230 elif status_code == 500:
231 title = "Internal Server Error"
232 message = "Something went wrong on our end."
233 icon = "💥"
235 html = render_error_page(
236 status_code=status_code,
237 title=title,
238 message=message,
239 icon=icon,
240 action_text="Go to Dashboard",
241 action_url="/admin/",
242 )
244 return HTMLResponse(html, status_code=status_code)
246 def _render_debug_error_html(self, request: Request, exc: Exception) -> str:
247 """Render detailed error page HTML."""
248 from lexigram.admin.lib.template import render_template
250 exc_type = type(exc).__name__
251 exc_message = str(exc)
253 # Get traceback
254 tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
255 tb_html = render_to_string(
256 el("div", *[el("div", line, class_="tb-line") for line in tb_lines])
257 )
258 tb_plain = "".join(tb_lines) # Plain text for copying
260 # Get local variables from the last frame
261 tb = exc.__traceback__
262 while tb and tb.tb_next:
263 tb = tb.tb_next
265 local_vars = {}
266 if tb:
267 for key, value in tb.tb_frame.f_locals.items():
268 if not key.startswith("__"):
269 try:
270 val_str = repr(value)[:200]
271 except (
272 RuntimeError,
273 ValueError,
274 ) as e: # Best-effort error handling in debug page
275 # Best-effort: avoid raising during error page rendering; fall back to placeholder
276 from lexigram.logging import get_logger
278 get_logger(__name__).debug(
279 "Failed to repr local var %s: %s",
280 key,
281 e,
282 exc_info=True,
283 )
284 val_str = "<unable to repr>"
285 local_vars[key] = val_str
287 from lexigram.admin.lib.security import mask_sensitive_data
289 masked_query = mask_sensitive_data(dict(request.query_params))
290 masked_locals = mask_sensitive_data(local_vars)
292 # Render using template
293 return render_template(
294 "debug_error.html",
295 exc_type=exc_type,
296 exc_message=exc_message,
297 traceback=tb_html,
298 traceback_plain=tb_plain,
299 request_method=request.method,
300 request_url=str(request.url),
301 request_path=request.url.path,
302 request_query=masked_query,
303 local_vars=masked_locals,
304 )