Coverage for fastblocks/exceptions.py: 98%

123 statements  

« 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 

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 return await templates.app.render_template( 

113 request, 

114 "index.html", 

115 status_code=status_code, 

116 context={"page": str(status_code)}, 

117 ) 

118 

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

120 

121 

122_error_registry = ErrorHandlerRegistry() 

123_error_registry.set_fallback(DefaultErrorHandler()) 

124 

125 

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

127 _error_registry.register(handler, priority) 

128 

129 

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] 

141 

142 

143_exception_cache = {} 

144 

145 

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 

162 

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 ) 

173 

174 

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 {} 

188 

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 ) 

197 

198 

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 ) 

208 

209 

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 ) 

219 

220 

221class StarletteCachesException(FastBlocksException): 

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

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

224 

225 

226class DuplicateCaching(StarletteCachesException): 

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

228 super().__init__(message) 

229 

230 

231class MissingCaching(StarletteCachesException): 

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

233 super().__init__(message) 

234 

235 

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 ) 

242 

243 

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 )