Coverage for fastblocks/exceptions.py: 98%
123 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -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 return await templates.app.render_template(
113 request,
114 "index.html",
115 status_code=status_code,
116 context={"page": str(status_code)},
117 )
119 return PlainTextResponse(content=message, status_code=status_code)
122_error_registry = ErrorHandlerRegistry()
123_error_registry.set_fallback(DefaultErrorHandler())
126def register_error_handler(handler: ErrorHandler, priority: int = 0) -> None:
127 _error_registry.register(handler, priority)
130def safe_depends_get(
131 key: str,
132 cache_dict: dict[str, t.Any],
133 default: t.Any = None,
134) -> t.Any:
135 if key not in cache_dict:
136 try:
137 cache_dict[key] = depends.get(key)
138 except Exception:
139 cache_dict[key] = default
140 return cache_dict[key]
143_exception_cache = {}
146async def handle_exception(request: HtmxRequest, exc: HTTPException) -> Response:
147 status_code = getattr(exc, "status_code", 500)
148 error_context = ErrorContext(
149 error_id=f"http_{status_code}",
150 category=ErrorCategory.APPLICATION,
151 severity=ErrorSeverity.ERROR if status_code >= 500 else ErrorSeverity.WARNING,
152 message=str(exc.detail) if hasattr(exc, "detail") else f"HTTP {status_code}",
153 details={
154 "status_code": status_code,
155 "request_path": getattr(getattr(request, "url", None), "path", "/")
156 if getattr(request, "url", None)
157 else "/",
158 },
159 )
160 if hasattr(request, "scope") and hasattr(request, "receive"):
161 from starlette.requests import Request
163 scope = getattr(request, "scope", {})
164 receive = getattr(request, "receive", None)
165 if scope and receive:
166 starlette_request = Request(scope, receive)
167 return await _error_registry.handle_error(
168 exc, error_context, starlette_request
169 )
170 return await _error_registry.handle_error(
171 exc, error_context, t.cast(t.Any, request)
172 )
175class FastBlocksException(Exception):
176 def __init__(
177 self,
178 message: str,
179 category: ErrorCategory = ErrorCategory.APPLICATION,
180 severity: ErrorSeverity = ErrorSeverity.ERROR,
181 details: dict[str, t.Any] | None = None,
182 ) -> None:
183 super().__init__(message)
184 self.message = message
185 self.category = category
186 self.severity = severity
187 self.details = details if details is not None else {}
189 def to_error_context(self, error_id: str | None = None) -> ErrorContext:
190 return ErrorContext(
191 error_id=error_id or str(self.__class__.__name__.lower()),
192 category=self.category,
193 severity=self.severity,
194 message=self.message,
195 details=self.details,
196 )
199class ConfigurationError(FastBlocksException):
200 def __init__(self, message: str, config_key: str | None = None) -> None:
201 details = {"config_key": config_key} if config_key else {}
202 super().__init__(
203 message,
204 category=ErrorCategory.CONFIGURATION,
205 severity=ErrorSeverity.CRITICAL,
206 details=details,
207 )
210class DependencyError(FastBlocksException):
211 def __init__(self, message: str, dependency_key: str | None = None) -> None:
212 details = {"dependency_key": dependency_key} if dependency_key else {}
213 super().__init__(
214 message,
215 category=ErrorCategory.DEPENDENCY,
216 severity=ErrorSeverity.ERROR,
217 details=details,
218 )
221class StarletteCachesException(FastBlocksException):
222 def __init__(self, message: str = "Cache operation failed") -> None:
223 super().__init__(message, category=ErrorCategory.CACHING)
226class DuplicateCaching(StarletteCachesException):
227 def __init__(self, message: str = "Duplicate cache middleware detected") -> None:
228 super().__init__(message)
231class MissingCaching(StarletteCachesException):
232 def __init__(self, message: str = "Cache middleware not found") -> None:
233 super().__init__(message)
236class RequestNotCachable(StarletteCachesException):
237 def __init__(self, request: Request) -> None:
238 self.request = request
239 super().__init__(
240 f"Request {request.method} {request.url.path} is not cacheable",
241 )
244class ResponseNotCachable(StarletteCachesException):
245 def __init__(self, response: Response) -> None:
246 self.response = response
247 super().__init__(
248 f"Response with status {response.status_code} is not cacheable",
249 )