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

1"""Error handling middleware with detailed error pages.""" 

2 

3from __future__ import annotations 

4 

5import traceback 

6from typing import Any 

7from urllib.parse import quote 

8 

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 

14 

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 

19 

20logger = get_logger(__name__) 

21 

22 

23class AdminErrorMiddleware(BaseHTTPMiddleware): 

24 """HTTP middleware that catches exceptions and displays detailed error pages or JSON responses. 

25 

26 Uses BaseHTTPMiddleware for proper exception handling in Starlette applications. 

27 """ 

28 

29 def __init__( 

30 self, app, debug: bool = True, login_url: str = "/admin/login" 

31 ) -> None: 

32 """Initialize error middleware. 

33 

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 

42 

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) 

52 

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 

62 

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 

70 

71 def _is_htmx(self, request: Request) -> bool: 

72 """Check if request expects a fragment swap.""" 

73 return wants_fragment(request) 

74 

75 async def handle(self, request: Request, exc: Exception) -> Response: 

76 """Handle exceptions and return a Response object. 

77 

78 This method is compatible with Starlette's exception_handler signature. 

79 """ 

80 status_code = 500 

81 message = "Internal Server Error" 

82 

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 

89 

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) 

96 

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 ) 

103 

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 

131 

132 # 2. Map Status to Metadata 

133 title = "Error" 

134 icon = "⚠️" 

135 

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 = "💥" 

145 

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 """ 

163 

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 """ 

185 

186 response = HTMLResponse(html, status_code=200) # 200 so HTMX swaps by default 

187 

188 # 4. Trigger Toast Notifications 

189 from lexigram.serialization import dumps_str 

190 

191 response.headers["HX-Trigger"] = dumps_str( 

192 {"showMessage": {"message": f"{title}: {message}", "type": "error"}}, 

193 ) 

194 

195 return response 

196 

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.""" 

205 

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) 

210 

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) 

215 

216 # 3. Handle Other Errors (403, 404, 500 production) - Show Styled Page 

217 from lexigram.admin.lib.template import render_error_page 

218 

219 title = "Error" 

220 icon = "⚠️" 

221 

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 = "💥" 

234 

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 ) 

243 

244 return HTMLResponse(html, status_code=status_code) 

245 

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 

249 

250 exc_type = type(exc).__name__ 

251 exc_message = str(exc) 

252 

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 

259 

260 # Get local variables from the last frame 

261 tb = exc.__traceback__ 

262 while tb and tb.tb_next: 

263 tb = tb.tb_next 

264 

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 

277 

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 

286 

287 from lexigram.admin.lib.security import mask_sensitive_data 

288 

289 masked_query = mask_sensitive_data(dict(request.query_params)) 

290 masked_locals = mask_sensitive_data(local_vars) 

291 

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 )