Coverage for fastblocks/exceptions.py: 97%
125 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1import typing as t
2from abc import ABC, abstractmethod
3from contextlib import suppress
4from dataclasses import dataclass
5from enum import Enum
6from operator import itemgetter
8from acb.depends import depends
9from starlette.exceptions import HTTPException
10from starlette.requests import Request
11from starlette.responses import PlainTextResponse, Response
12from fastblocks.htmx import HtmxRequest
14_templates_cache = None
17class ErrorSeverity(Enum):
18 CRITICAL = "critical"
19 ERROR = "error"
20 WARNING = "warning"
21 INFO = "info"
24class ErrorCategory(Enum):
25 CONFIGURATION = "configuration"
26 DEPENDENCY = "dependency"
27 AUTHENTICATION = "authentication"
28 AUTHORIZATION = "authorization"
29 VALIDATION = "validation"
30 CACHING = "caching"
31 TEMPLATE = "template"
32 MIDDLEWARE = "middleware"
33 APPLICATION = "application"
36@dataclass
37class ErrorContext:
38 error_id: str
39 category: ErrorCategory
40 severity: ErrorSeverity
41 message: str
42 details: dict[str, t.Any] | None = None
43 request_id: str | None = None
44 user_id: str | None = None
47class ErrorHandler(ABC):
48 @abstractmethod
49 async def can_handle(self, exception: Exception, context: ErrorContext) -> bool:
50 pass
52 @abstractmethod
53 async def handle(
54 self,
55 exception: Exception,
56 context: ErrorContext,
57 request: Request,
58 ) -> Response:
59 pass
62class ErrorHandlerRegistry:
63 def __init__(self) -> None:
64 self._handlers: list[tuple[int, ErrorHandler]] = []
65 self._fallback_handler: ErrorHandler | None = None
67 def register(self, handler: ErrorHandler, priority: int = 0) -> None:
68 self._handlers.append((priority, handler))
69 self._handlers.sort(key=itemgetter(0), reverse=True)
71 def set_fallback(self, handler: ErrorHandler) -> None:
72 self._fallback_handler = handler
74 async def handle_error(
75 self,
76 exception: Exception,
77 context: ErrorContext,
78 request: Request,
79 ) -> Response:
80 for _, handler in self._handlers:
81 if await handler.can_handle(exception, context):
82 return await handler.handle(exception, context, request)
84 if self._fallback_handler:
85 return await self._fallback_handler.handle(exception, context, request)
87 return PlainTextResponse("Internal Server Error", status_code=500)
90class DefaultErrorHandler(ErrorHandler):
91 async def can_handle(self, exception: Exception, context: ErrorContext) -> bool:
92 return True
94 async def handle(
95 self,
96 exception: Exception,
97 context: ErrorContext,
98 request: Request,
99 ) -> Response:
100 status_code = getattr(exception, "status_code", 500)
101 message = {404: "Content not found", 500: "Server error"}.get(
102 status_code,
103 "An error occurred",
104 )
106 if hasattr(request, "scope") and request.scope.get("htmx"):
107 return PlainTextResponse(content=message, status_code=status_code)
109 templates = safe_depends_get("templates", _exception_cache)
110 if templates:
111 with suppress(Exception):
112 result = await templates.app.render_template(
113 request,
114 "index.html",
115 status_code=status_code,
116 context={"page": str(status_code)},
117 )
118 return result # type: ignore[no-any-return]
120 return PlainTextResponse(content=message, status_code=status_code)
123_error_registry = ErrorHandlerRegistry()
124_error_registry.set_fallback(DefaultErrorHandler())
127def register_error_handler(handler: ErrorHandler, priority: int = 0) -> None:
128 _error_registry.register(handler, priority)
131def safe_depends_get(
132 key: str,
133 cache_dict: dict[str, t.Any],
134 default: t.Any = None,
135) -> t.Any:
136 if key not in cache_dict:
137 try:
138 cache_dict[key] = depends.get(key)
139 except Exception:
140 cache_dict[key] = default
141 return cache_dict[key]
144_exception_cache: dict[t.Any, t.Any] = {}
147async def handle_exception(request: HtmxRequest, exc: HTTPException) -> Response:
148 status_code = getattr(exc, "status_code", 500)
149 error_context = ErrorContext(
150 error_id=f"http_{status_code}",
151 category=ErrorCategory.APPLICATION,
152 severity=ErrorSeverity.ERROR if status_code >= 500 else ErrorSeverity.WARNING,
153 message=exc.detail if hasattr(exc, "detail") else f"HTTP {status_code}",
154 details={
155 "status_code": status_code,
156 "request_path": getattr(getattr(request, "url", None), "path", "/")
157 if getattr(request, "url", None)
158 else "/",
159 },
160 )
161 if hasattr(request, "scope") and hasattr(request, "receive"):
162 from starlette.requests import Request
164 scope = getattr(request, "scope", {})
165 receive = getattr(request, "receive", None)
166 if scope and receive:
167 starlette_request = Request(scope, receive)
168 return await _error_registry.handle_error(
169 exc, error_context, starlette_request
170 )
171 return await _error_registry.handle_error(
172 exc, error_context, t.cast(t.Any, request)
173 )
176class FastBlocksException(Exception):
177 def __init__(
178 self,
179 message: str,
180 category: ErrorCategory = ErrorCategory.APPLICATION,
181 severity: ErrorSeverity = ErrorSeverity.ERROR,
182 details: dict[str, t.Any] | None = None,
183 status_code: int | None = None,
184 ) -> None:
185 super().__init__(message)
186 self.message = message
187 self.category = category
188 self.severity = severity
189 self.details = details if details is not None else {}
190 self.status_code = status_code
192 def to_error_context(self, error_id: str | None = None) -> ErrorContext:
193 return ErrorContext(
194 error_id=error_id or str(self.__class__.__name__.lower()),
195 category=self.category,
196 severity=self.severity,
197 message=self.message,
198 details=self.details,
199 )
202class ConfigurationError(FastBlocksException):
203 def __init__(self, message: str, config_key: str | None = None) -> None:
204 details = {"config_key": config_key} if config_key else {}
205 super().__init__(
206 message,
207 category=ErrorCategory.CONFIGURATION,
208 severity=ErrorSeverity.CRITICAL,
209 details=details,
210 )
213class DependencyError(FastBlocksException):
214 def __init__(self, message: str, dependency_key: str | None = None) -> None:
215 details = {"dependency_key": dependency_key} if dependency_key else {}
216 super().__init__(
217 message,
218 category=ErrorCategory.DEPENDENCY,
219 severity=ErrorSeverity.ERROR,
220 details=details,
221 )
224class StarletteCachesException(FastBlocksException):
225 def __init__(self, message: str = "Cache operation failed") -> None:
226 super().__init__(message, category=ErrorCategory.CACHING)
229class DuplicateCaching(StarletteCachesException):
230 def __init__(self, message: str = "Duplicate cache middleware detected") -> None:
231 super().__init__(message)
234class MissingCaching(StarletteCachesException):
235 def __init__(self, message: str = "Cache middleware not found") -> None:
236 super().__init__(message)
239class RequestNotCachable(StarletteCachesException):
240 def __init__(self, request: Request) -> None:
241 self.request = request
242 super().__init__(
243 f"Request {request.method} {request.url.path} is not cacheable",
244 )
247class ResponseNotCachable(StarletteCachesException):
248 def __init__(self, response: Response) -> None:
249 self.response = response
250 super().__init__(
251 f"Response with status {response.status_code} is not cacheable",
252 )