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

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

2

3 This module provides a consistent exception hierarchy and handlers

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

5 for consistency across the codebase.

6

7 Example:

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

27 from __future__ import annotations

28

29 from datetime import datetime, timezone

30 from typing import TYPE_CHECKING, Any

31

32 from dataknobs_common.exceptions import (

33 ConfigurationError as CommonConfigurationError,

34)

35 from dataknobs_common.exceptions import (

36 DataknobsError,

37)

38 from dataknobs_common.exceptions import (

39 NotFoundError as CommonNotFoundError,

40)

41 from dataknobs_common.exceptions import (

42 ValidationError as CommonValidationError,

43)

44

45 if TYPE_CHECKING:

46 from fastapi import FastAPI, HTTPException, Request

47 from fastapi.responses import JSONResponse

48

49

50 class 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

102 class 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

114 class 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

125 class 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

137 class 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

149 class 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

164 class 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

186 async 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

207 async 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

233 async 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

268 def 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)