Coverage for src / dataknobs_bots / api / exceptions.py: 0%

58 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-16 10:13 -0700

1"""Custom exceptions and exception handlers for FastAPI applications. 

2 

3This module provides a consistent exception hierarchy and handlers 

4for bot-related API errors. The exceptions extend from dataknobs_common 

5for consistency across the codebase. 

6 

7Example: 

8 ```python 

9 from fastapi import FastAPI 

10 from dataknobs_bots.api.exceptions import ( 

11 register_exception_handlers, 

12 BotNotFoundError, 

13 ) 

14 

15 app = FastAPI() 

16 register_exception_handlers(app) 

17 

18 @app.get("/bots/{bot_id}") 

19 async def get_bot(bot_id: str): 

20 bot = await manager.get(bot_id) 

21 if not bot: 

22 raise BotNotFoundError(bot_id) 

23 return {"bot_id": bot_id} 

24 ``` 

25""" 

26 

27from __future__ import annotations 

28 

29from datetime import datetime, timezone 

30from typing import TYPE_CHECKING, Any 

31 

32from dataknobs_common.exceptions import ( 

33 ConfigurationError as CommonConfigurationError, 

34) 

35from dataknobs_common.exceptions import ( 

36 DataknobsError, 

37) 

38from dataknobs_common.exceptions import ( 

39 NotFoundError as CommonNotFoundError, 

40) 

41from dataknobs_common.exceptions import ( 

42 ValidationError as CommonValidationError, 

43) 

44 

45if TYPE_CHECKING: 

46 from fastapi import FastAPI, HTTPException, Request 

47 from fastapi.responses import JSONResponse 

48 

49 

50class APIError(DataknobsError): 

51 """Base exception for API errors. 

52 

53 Extends DataknobsError to provide HTTP-specific error handling 

54 with status codes and structured error responses. 

55 

56 Attributes: 

57 message: Error message 

58 status_code: HTTP status code 

59 detail: Error details (maps to DataknobsError.context) 

60 error_code: Machine-readable error code 

61 """ 

62 

63 def __init__( 

64 self, 

65 message: str, 

66 status_code: int = 500, 

67 detail: dict[str, Any] | None = None, 

68 error_code: str | None = None, 

69 ): 

70 """Initialize API error. 

71 

72 Args: 

73 message: Human-readable error message 

74 status_code: HTTP status code (default: 500) 

75 detail: Optional dictionary with error details 

76 error_code: Optional machine-readable error code 

77 """ 

78 # Pass detail as context to DataknobsError 

79 super().__init__(message, context=detail) 

80 self.status_code = status_code 

81 self.error_code = error_code or self.__class__.__name__ 

82 

83 @property 

84 def detail(self) -> dict[str, Any]: 

85 """Alias for context to maintain API compatibility.""" 

86 return self.context 

87 

88 def to_dict(self) -> dict[str, Any]: 

89 """Convert error to dictionary for JSON response. 

90 

91 Returns: 

92 Dictionary representation of the error 

93 """ 

94 return { 

95 "error": self.error_code, 

96 "message": str(self), 

97 "detail": self.context, 

98 "timestamp": datetime.now(timezone.utc).isoformat(), 

99 } 

100 

101 

102class BotNotFoundError(APIError, CommonNotFoundError): 

103 """Exception raised when bot instance is not found.""" 

104 

105 def __init__(self, bot_id: str): 

106 APIError.__init__( 

107 self, 

108 message=f"Bot with ID '{bot_id}' not found", 

109 status_code=404, 

110 detail={"bot_id": bot_id}, 

111 ) 

112 

113 

114class BotCreationError(APIError): 

115 """Exception raised when bot creation fails.""" 

116 

117 def __init__(self, bot_id: str, reason: str): 

118 super().__init__( 

119 message=f"Failed to create bot '{bot_id}': {reason}", 

120 status_code=500, 

121 detail={"bot_id": bot_id, "reason": reason}, 

122 ) 

123 

124 

125class ConversationNotFoundError(APIError, CommonNotFoundError): 

126 """Exception raised when conversation is not found.""" 

127 

128 def __init__(self, conversation_id: str): 

129 APIError.__init__( 

130 self, 

131 message=f"Conversation with ID '{conversation_id}' not found", 

132 status_code=404, 

133 detail={"conversation_id": conversation_id}, 

134 ) 

135 

136 

137class ValidationError(APIError, CommonValidationError): 

138 """Exception raised when input validation fails.""" 

139 

140 def __init__(self, message: str, detail: dict[str, Any] | None = None): 

141 APIError.__init__( 

142 self, 

143 message=message, 

144 status_code=422, 

145 detail=detail, 

146 ) 

147 

148 

149class ConfigurationError(APIError, CommonConfigurationError): 

150 """Exception raised when configuration is invalid.""" 

151 

152 def __init__(self, message: str, config_key: str | None = None): 

153 detail = {} 

154 if config_key: 

155 detail["config_key"] = config_key 

156 APIError.__init__( 

157 self, 

158 message=message, 

159 status_code=500, 

160 detail=detail, 

161 ) 

162 

163 

164class RateLimitError(APIError): 

165 """Exception raised when rate limit is exceeded.""" 

166 

167 def __init__( 

168 self, 

169 message: str = "Rate limit exceeded", 

170 retry_after: int | None = None, 

171 ): 

172 detail = {} 

173 if retry_after: 

174 detail["retry_after"] = retry_after 

175 super().__init__( 

176 message=message, 

177 status_code=429, 

178 detail=detail, 

179 ) 

180 

181 

182# Exception Handlers 

183# Note: These use TYPE_CHECKING imports to avoid requiring FastAPI at import time 

184 

185 

186async def api_error_handler( 

187 request: Request, # type: ignore[name-defined] 

188 exc: APIError, 

189) -> JSONResponse: # type: ignore[name-defined] 

190 """Handle API errors with standardized response format. 

191 

192 Args: 

193 request: FastAPI request object 

194 exc: API error exception 

195 

196 Returns: 

197 JSON response with error details 

198 """ 

199 from fastapi.responses import JSONResponse 

200 

201 return JSONResponse( 

202 status_code=exc.status_code, 

203 content=exc.to_dict(), 

204 ) 

205 

206 

207async def http_exception_handler( 

208 request: Request, # type: ignore[name-defined] 

209 exc: HTTPException, # type: ignore[name-defined] 

210) -> JSONResponse: # type: ignore[name-defined] 

211 """Handle FastAPI HTTP exceptions. 

212 

213 Args: 

214 request: FastAPI request object 

215 exc: HTTP exception 

216 

217 Returns: 

218 JSON response with error details 

219 """ 

220 from fastapi.responses import JSONResponse 

221 

222 return JSONResponse( 

223 status_code=exc.status_code, 

224 content={ 

225 "error": "HTTPException", 

226 "message": str(exc.detail), 

227 "detail": {}, 

228 "timestamp": datetime.now(timezone.utc).isoformat(), 

229 }, 

230 ) 

231 

232 

233async def general_exception_handler( 

234 request: Request, # type: ignore[name-defined] 

235 exc: Exception, 

236) -> JSONResponse: # type: ignore[name-defined] 

237 """Handle unexpected exceptions. 

238 

239 Args: 

240 request: FastAPI request object 

241 exc: Generic exception 

242 

243 Returns: 

244 JSON response with error details 

245 

246 Note: 

247 This handler logs the full exception but returns a generic 

248 message to avoid leaking internal details. 

249 """ 

250 import logging 

251 

252 from fastapi.responses import JSONResponse 

253 

254 logger = logging.getLogger(__name__) 

255 logger.exception(f"Unhandled exception: {exc}") 

256 

257 return JSONResponse( 

258 status_code=500, 

259 content={ 

260 "error": "InternalServerError", 

261 "message": "An unexpected error occurred", 

262 "detail": {"exception_type": type(exc).__name__}, 

263 "timestamp": datetime.now(timezone.utc).isoformat(), 

264 }, 

265 ) 

266 

267 

268def register_exception_handlers( 

269 app: FastAPI, # type: ignore[name-defined] 

270) -> None: 

271 """Register all exception handlers with a FastAPI app. 

272 

273 Args: 

274 app: FastAPI application instance 

275 

276 Example: 

277 ```python 

278 from fastapi import FastAPI 

279 from dataknobs_bots.api.exceptions import register_exception_handlers 

280 

281 app = FastAPI() 

282 register_exception_handlers(app) 

283 ``` 

284 """ 

285 from fastapi import HTTPException 

286 

287 app.add_exception_handler(APIError, api_error_handler) # type: ignore 

288 app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore 

289 app.add_exception_handler(Exception, general_exception_handler)