Coverage for fastblocks/exceptions.py: 97%

125 statements  

« 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 

7 

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 

13 

14_templates_cache = None 

15 

16 

17class ErrorSeverity(Enum): 

18 CRITICAL = "critical" 

19 ERROR = "error" 

20 WARNING = "warning" 

21 INFO = "info" 

22 

23 

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" 

34 

35 

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 

45 

46 

47class ErrorHandler(ABC): 

48 @abstractmethod 

49 async def can_handle(self, exception: Exception, context: ErrorContext) -> bool: 

50 pass 

51 

52 @abstractmethod 

53 async def handle( 

54 self, 

55 exception: Exception, 

56 context: ErrorContext, 

57 request: Request, 

58 ) -> Response: 

59 pass 

60 

61 

62class ErrorHandlerRegistry: 

63 def __init__(self) -> None: 

64 self._handlers: list[tuple[int, ErrorHandler]] = [] 

65 self._fallback_handler: ErrorHandler | None = None 

66 

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) 

70 

71 def set_fallback(self, handler: ErrorHandler) -> None: 

72 self._fallback_handler = handler 

73 

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) 

83 

84 if self._fallback_handler: 

85 return await self._fallback_handler.handle(exception, context, request) 

86 

87 return PlainTextResponse("Internal Server Error", status_code=500) 

88 

89 

90class DefaultErrorHandler(ErrorHandler): 

91 async def can_handle(self, exception: Exception, context: ErrorContext) -> bool: 

92 return True 

93 

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 ) 

105 

106 if hasattr(request, "scope") and request.scope.get("htmx"): 

107 return PlainTextResponse(content=message, status_code=status_code) 

108 

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] 

119 

120 return PlainTextResponse(content=message, status_code=status_code) 

121 

122 

123_error_registry = ErrorHandlerRegistry() 

124_error_registry.set_fallback(DefaultErrorHandler()) 

125 

126 

127def register_error_handler(handler: ErrorHandler, priority: int = 0) -> None: 

128 _error_registry.register(handler, priority) 

129 

130 

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] 

142 

143 

144_exception_cache: dict[t.Any, t.Any] = {} 

145 

146 

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 

163 

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 ) 

174 

175 

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 

191 

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 ) 

200 

201 

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 ) 

211 

212 

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 ) 

222 

223 

224class StarletteCachesException(FastBlocksException): 

225 def __init__(self, message: str = "Cache operation failed") -> None: 

226 super().__init__(message, category=ErrorCategory.CACHING) 

227 

228 

229class DuplicateCaching(StarletteCachesException): 

230 def __init__(self, message: str = "Duplicate cache middleware detected") -> None: 

231 super().__init__(message) 

232 

233 

234class MissingCaching(StarletteCachesException): 

235 def __init__(self, message: str = "Cache middleware not found") -> None: 

236 super().__init__(message) 

237 

238 

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 ) 

245 

246 

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 )