Coverage for src / mcp_server_langgraph / core / exceptions.py: 98%

280 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-03 00:43 +0000

1""" 

2Custom exception hierarchy for MCP server. 

3 

4Provides rich error context, automatic HTTP status code mapping, 

5and integration with observability stack. 

6 

7See ADR-0029 for design rationale. 

8""" 

9 

10from enum import Enum 

11from typing import Any 

12 

13from opentelemetry import trace 

14 

15 

16class ErrorCategory(str, Enum): 

17 """High-level error categories for metrics""" 

18 

19 CLIENT_ERROR = "client_error" # 4xx errors (user's fault) 

20 SERVER_ERROR = "server_error" # 5xx errors (our fault) 

21 EXTERNAL_ERROR = "external_error" # 5xx errors (external service's fault) 

22 RATE_LIMIT = "rate_limit" # 429 errors 

23 AUTH_ERROR = "auth_error" # 401/403 errors 

24 

25 

26class RetryPolicy(str, Enum): 

27 """Whether exception is retry-able""" 

28 

29 NEVER = "never" # Never retry (client errors, permanent failures) 

30 ALWAYS = "always" # Always retry (transient failures) 

31 CONDITIONAL = "conditional" # Retry with conditions (idempotent operations only) 

32 

33 

34class MCPServerException(Exception): 

35 """ 

36 Base exception for all MCP server errors. 

37 

38 Attributes: 

39 message: Human-readable error message 

40 error_code: Machine-readable error code (e.g., "auth.token_expired") 

41 status_code: HTTP status code (e.g., 401, 500) 

42 category: Error category for metrics 

43 retry_policy: Whether this error is retry-able 

44 metadata: Additional context (user_id, resource_id, etc.) 

45 trace_id: OpenTelemetry trace ID for correlation 

46 user_message: User-friendly message (safe to display) 

47 cause: Original exception that caused this error 

48 """ 

49 

50 # Default values (overridden by subclasses) 

51 default_message = "An error occurred" 

52 default_error_code = "server.error" 

53 default_status_code = 500 

54 default_category = ErrorCategory.SERVER_ERROR 

55 default_retry_policy = RetryPolicy.NEVER 

56 

57 def __init__( 

58 self, 

59 message: str | None = None, 

60 error_code: str | None = None, 

61 status_code: int | None = None, 

62 category: ErrorCategory | None = None, 

63 retry_policy: RetryPolicy | None = None, 

64 metadata: dict[str, Any] | None = None, 

65 trace_id: str | None = None, 

66 user_message: str | None = None, 

67 cause: Exception | None = None, 

68 ): 

69 self.message = message or self.default_message 

70 self.error_code = error_code or self.default_error_code 

71 self.status_code = status_code or self.default_status_code 

72 self.category = category or self.default_category 

73 self.retry_policy = retry_policy or self.default_retry_policy 

74 self.metadata = metadata or {} 

75 self.trace_id = trace_id or self._get_current_trace_id() 

76 self.user_message = user_message or self._generate_user_message() 

77 self.cause = cause 

78 

79 # Call parent constructor 

80 super().__init__(self.message) 

81 

82 def _get_current_trace_id(self) -> str | None: 

83 """Get current OpenTelemetry trace ID""" 

84 try: 

85 span = trace.get_current_span() 

86 if span and span.get_span_context().is_valid: 

87 return format(span.get_span_context().trace_id, "032x") 

88 except Exception: 

89 pass 

90 return None 

91 

92 def _generate_user_message(self) -> str: 

93 """Generate safe user-facing message""" 

94 if self.category == ErrorCategory.CLIENT_ERROR: 

95 return "There was a problem with your request. Please check and try again." 

96 elif self.category == ErrorCategory.AUTH_ERROR: 

97 return "Authentication failed. Please sign in and try again." 

98 elif self.category == ErrorCategory.RATE_LIMIT: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 return "You've made too many requests. Please wait and try again." 

100 else: 

101 return "An unexpected error occurred. Please try again later." 

102 

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

104 """Convert to dictionary for JSON response""" 

105 return { 

106 "error": { 

107 "code": self.error_code, 

108 "message": self.user_message, 

109 "details": self.message, 

110 "trace_id": self.trace_id, 

111 "metadata": self.metadata, 

112 } 

113 } 

114 

115 def __str__(self) -> str: 

116 """String representation with full context""" 

117 parts = [f"{self.error_code}: {self.message}"] 

118 if self.metadata: 118 ↛ 120line 118 didn't jump to line 120 because the condition on line 118 was always true

119 parts.append(f"metadata={self.metadata}") 

120 if self.trace_id: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 parts.append(f"trace_id={self.trace_id}") 

122 return " | ".join(parts) 

123 

124 

125# ============================================================================== 

126# Configuration Errors (500 - Server Error) 

127# ============================================================================== 

128 

129 

130class ConfigurationError(MCPServerException): 

131 """Base class for configuration errors""" 

132 

133 default_message = "Configuration error" 

134 default_error_code = "config.error" 

135 default_status_code = 500 

136 default_category = ErrorCategory.SERVER_ERROR 

137 

138 

139class MissingConfigError(ConfigurationError): 

140 """Required configuration is missing""" 

141 

142 default_message = "Required configuration is missing" 

143 default_error_code = "config.missing" 

144 

145 

146class InvalidConfigError(ConfigurationError): 

147 """Configuration value is invalid""" 

148 

149 default_message = "Configuration value is invalid" 

150 default_error_code = "config.invalid" 

151 

152 

153class SecretNotFoundError(ConfigurationError): 

154 """Secret not found in secrets manager""" 

155 

156 default_message = "Secret not found" 

157 default_error_code = "config.secret_not_found" 

158 

159 

160# ============================================================================== 

161# Authentication Errors (401 - Unauthorized) 

162# ============================================================================== 

163 

164 

165class AuthenticationError(MCPServerException): 

166 """Base class for authentication errors""" 

167 

168 default_message = "Authentication failed" 

169 default_error_code = "auth.failed" 

170 default_status_code = 401 

171 default_category = ErrorCategory.AUTH_ERROR 

172 default_retry_policy = RetryPolicy.NEVER 

173 

174 

175class InvalidCredentialsError(AuthenticationError): 

176 """Username or password is incorrect""" 

177 

178 default_message = "Invalid credentials" 

179 default_error_code = "auth.invalid_credentials" 

180 

181 def _generate_user_message(self) -> str: 

182 return "Invalid username or password. Please try again." 

183 

184 

185class TokenExpiredError(AuthenticationError): 

186 """JWT token has expired""" 

187 

188 default_message = "Token has expired" 

189 default_error_code = "auth.token_expired" 

190 default_retry_policy = RetryPolicy.CONDITIONAL # Retry with refresh token 

191 

192 def _generate_user_message(self) -> str: 

193 return "Your session has expired. Please sign in again." 

194 

195 

196class TokenInvalidError(AuthenticationError): 

197 """JWT token is invalid""" 

198 

199 default_message = "Token is invalid" 

200 default_error_code = "auth.token_invalid" 

201 

202 def _generate_user_message(self) -> str: 

203 return "Authentication failed. Please sign in again." 

204 

205 

206class MFARequiredError(AuthenticationError): 

207 """Multi-factor authentication is required""" 

208 

209 default_message = "MFA required" 

210 default_error_code = "auth.mfa_required" 

211 default_status_code = 403 

212 

213 def _generate_user_message(self) -> str: 

214 return "Multi-factor authentication is required. Please complete MFA." 

215 

216 

217# ============================================================================== 

218# Authorization Errors (403 - Forbidden) 

219# ============================================================================== 

220 

221 

222class AuthorizationError(MCPServerException): 

223 """Base class for authorization errors""" 

224 

225 default_message = "Authorization failed" 

226 default_error_code = "authz.failed" 

227 default_status_code = 403 

228 default_category = ErrorCategory.AUTH_ERROR 

229 default_retry_policy = RetryPolicy.NEVER 

230 

231 

232class PermissionDeniedError(AuthorizationError): 

233 """User does not have required permission""" 

234 

235 default_message = "Permission denied" 

236 default_error_code = "authz.permission_denied" 

237 

238 def _generate_user_message(self) -> str: 

239 return "You don't have permission to perform this action." 

240 

241 

242class ResourceNotFoundError(AuthorizationError): 

243 """Resource not found (or user doesn't have access)""" 

244 

245 default_message = "Resource not found" 

246 default_error_code = "authz.resource_not_found" 

247 default_status_code = 404 

248 

249 def _generate_user_message(self) -> str: 

250 return "The requested resource was not found." 

251 

252 

253class InsufficientPermissionsError(AuthorizationError): 

254 """User's tier/plan doesn't allow this action""" 

255 

256 default_message = "Insufficient permissions" 

257 default_error_code = "authz.insufficient_permissions" 

258 

259 def _generate_user_message(self) -> str: 

260 return "Your current plan doesn't include this feature. Please upgrade." 

261 

262 

263# ============================================================================== 

264# Rate Limiting Errors (429 - Too Many Requests) 

265# ============================================================================== 

266 

267 

268class RateLimitError(MCPServerException): 

269 """Base class for rate limiting errors""" 

270 

271 default_message = "Rate limit exceeded" 

272 default_error_code = "rate_limit.exceeded" 

273 default_status_code = 429 

274 default_category = ErrorCategory.RATE_LIMIT 

275 default_retry_policy = RetryPolicy.CONDITIONAL 

276 

277 def _generate_user_message(self) -> str: 

278 retry_after = self.metadata.get("retry_after", 60) 

279 return f"Rate limit exceeded. Please wait {retry_after} seconds and try again." 

280 

281 

282class RateLimitExceededError(RateLimitError): 

283 """Request rate limit exceeded""" 

284 

285 

286class QuotaExceededError(RateLimitError): 

287 """Usage quota exceeded""" 

288 

289 default_error_code = "quota.exceeded" 

290 

291 def _generate_user_message(self) -> str: 

292 return "You've exceeded your usage quota. Please upgrade your plan." 

293 

294 

295# ============================================================================== 

296# Validation Errors (400 - Bad Request) 

297# ============================================================================== 

298 

299 

300class ValidationError(MCPServerException): 

301 """Base class for validation errors""" 

302 

303 default_message = "Validation failed" 

304 default_error_code = "validation.failed" 

305 default_status_code = 400 

306 default_category = ErrorCategory.CLIENT_ERROR 

307 default_retry_policy = RetryPolicy.NEVER 

308 

309 

310class InputValidationError(ValidationError): 

311 """User input failed validation""" 

312 

313 default_error_code = "validation.input" 

314 

315 def _generate_user_message(self) -> str: 

316 field = self.metadata.get("field", "input") 

317 return f"Invalid {field}. Please check and try again." 

318 

319 

320class SchemaValidationError(ValidationError): 

321 """Data doesn't match expected schema""" 

322 

323 default_error_code = "validation.schema" 

324 

325 

326class ConstraintViolationError(ValidationError): 

327 """Business rule constraint violated""" 

328 

329 default_error_code = "validation.constraint" 

330 

331 

332# ============================================================================== 

333# External Service Errors (502/503/504) 

334# ============================================================================== 

335 

336 

337class ExternalServiceError(MCPServerException): 

338 """Base class for external service errors""" 

339 

340 default_message = "External service error" 

341 default_error_code = "external.error" 

342 default_status_code = 503 

343 default_category = ErrorCategory.EXTERNAL_ERROR 

344 default_retry_policy = RetryPolicy.ALWAYS 

345 

346 

347class LLMProviderError(ExternalServiceError): 

348 """LLM API error""" 

349 

350 default_message = "LLM provider error" 

351 default_error_code = "external.llm.error" 

352 

353 

354class LLMRateLimitError(LLMProviderError): 

355 """LLM provider rate limit exceeded""" 

356 

357 default_message = "LLM provider rate limit exceeded" 

358 default_error_code = "external.llm.rate_limit" 

359 default_status_code = 429 

360 

361 

362class LLMTimeoutError(LLMProviderError): 

363 """LLM request timed out""" 

364 

365 default_message = "LLM request timed out" 

366 default_error_code = "external.llm.timeout" 

367 default_status_code = 504 

368 

369 

370class LLMModelNotFoundError(LLMProviderError): 

371 """LLM model not found""" 

372 

373 default_message = "LLM model not found" 

374 default_error_code = "external.llm.model_not_found" 

375 default_status_code = 400 

376 default_retry_policy = RetryPolicy.NEVER 

377 

378 

379class LLMOverloadError(LLMProviderError): 

380 """LLM provider is overloaded (529 status). 

381 

382 This error indicates the LLM provider is experiencing high load and 

383 temporarily cannot process requests. The retry_after attribute may 

384 contain the server's suggested wait time before retrying. 

385 """ 

386 

387 default_message = "LLM provider is overloaded" 

388 default_error_code = "external.llm.overloaded" 

389 default_status_code = 529 # Non-standard but commonly used for overload 

390 default_retry_policy = RetryPolicy.ALWAYS 

391 

392 def __init__( 

393 self, 

394 message: str | None = None, 

395 retry_after: float | None = None, 

396 **kwargs: Any, 

397 ): 

398 """Initialize LLMOverloadError. 

399 

400 Args: 

401 message: Human-readable error message 

402 retry_after: Seconds to wait before retrying (from Retry-After header) 

403 **kwargs: Additional arguments passed to parent 

404 """ 

405 # Set retry_after BEFORE calling super().__init__ because 

406 # _generate_user_message() is called during parent initialization 

407 self.retry_after = retry_after 

408 super().__init__(message=message, **kwargs) 

409 

410 def _generate_user_message(self) -> str: 

411 if self.retry_after: 

412 return f"The AI service is temporarily overloaded. Please wait {int(self.retry_after)} seconds and try again." 

413 return "The AI service is temporarily overloaded. Please try again later." 

414 

415 

416class OpenFGAError(ExternalServiceError): 

417 """OpenFGA service error""" 

418 

419 default_message = "OpenFGA service error" 

420 default_error_code = "external.openfga.error" 

421 

422 

423class OpenFGATimeoutError(OpenFGAError): 

424 """OpenFGA request timed out""" 

425 

426 default_message = "OpenFGA request timed out" 

427 default_error_code = "external.openfga.timeout" 

428 default_status_code = 504 

429 

430 

431class OpenFGAUnavailableError(OpenFGAError): 

432 """OpenFGA service unavailable""" 

433 

434 default_message = "OpenFGA service unavailable" 

435 default_error_code = "external.openfga.unavailable" 

436 

437 

438class RedisError(ExternalServiceError): 

439 """Redis service error""" 

440 

441 default_message = "Redis service error" 

442 default_error_code = "external.redis.error" 

443 

444 

445class RedisConnectionError(RedisError): 

446 """Redis connection error""" 

447 

448 default_message = "Redis connection error" 

449 default_error_code = "external.redis.connection" 

450 

451 

452class RedisTimeoutError(RedisError): 

453 """Redis request timed out""" 

454 

455 default_message = "Redis request timed out" 

456 default_error_code = "external.redis.timeout" 

457 default_status_code = 504 

458 

459 

460class KeycloakError(ExternalServiceError): 

461 """Keycloak service error""" 

462 

463 default_message = "Keycloak service error" 

464 default_error_code = "external.keycloak.error" 

465 

466 

467class KeycloakAuthError(KeycloakError): 

468 """Keycloak authentication error""" 

469 

470 default_message = "Keycloak authentication error" 

471 default_error_code = "external.keycloak.auth" 

472 default_status_code = 401 

473 

474 

475class KeycloakUnavailableError(KeycloakError): 

476 """Keycloak service unavailable""" 

477 

478 default_message = "Keycloak service unavailable" 

479 default_error_code = "external.keycloak.unavailable" 

480 

481 

482# ============================================================================== 

483# Resilience Errors (503/504 - Service Unavailable/Timeout) 

484# ============================================================================== 

485 

486 

487class ResilienceError(MCPServerException): 

488 """Base class for resilience pattern errors""" 

489 

490 default_message = "Resilience error" 

491 default_error_code = "resilience.error" 

492 default_status_code = 503 

493 default_category = ErrorCategory.SERVER_ERROR 

494 default_retry_policy = RetryPolicy.CONDITIONAL 

495 

496 

497class CircuitBreakerOpenError(ResilienceError): 

498 """Circuit breaker is open, failing fast""" 

499 

500 default_message = "Circuit breaker is open" 

501 default_error_code = "resilience.circuit_breaker_open" 

502 

503 def _generate_user_message(self) -> str: 

504 service = self.metadata.get("service", "the service") 

505 return f"{service} is temporarily unavailable. Please try again later." 

506 

507 

508class RetryExhaustedError(ResilienceError): 

509 """Retry attempts exhausted""" 

510 

511 default_message = "Retry attempts exhausted" 

512 default_error_code = "resilience.retry_exhausted" 

513 

514 

515class TimeoutError(ResilienceError): 

516 """Operation timed out""" 

517 

518 default_message = "Operation timed out" 

519 default_error_code = "resilience.timeout" 

520 default_status_code = 504 

521 

522 def _generate_user_message(self) -> str: 

523 timeout = self.metadata.get("timeout_seconds", "unknown") 

524 return f"Operation timed out after {timeout} seconds. Please try again." 

525 

526 

527class BulkheadRejectedError(ResilienceError): 

528 """Bulkhead rejected request (concurrency limit)""" 

529 

530 default_message = "Bulkhead rejected request" 

531 default_error_code = "resilience.bulkhead_rejected" 

532 default_status_code = 503 

533 

534 def _generate_user_message(self) -> str: 

535 return "Service is under heavy load. Please try again in a moment." 

536 

537 

538# ============================================================================== 

539# Storage Errors (500 - Server Error) 

540# ============================================================================== 

541 

542 

543class StorageError(MCPServerException): 

544 """Base class for storage errors""" 

545 

546 default_message = "Storage error" 

547 default_error_code = "storage.error" 

548 default_status_code = 500 

549 default_category = ErrorCategory.SERVER_ERROR 

550 

551 

552class StorageBackendError(StorageError): 

553 """Storage backend error""" 

554 

555 default_message = "Storage backend error" 

556 default_error_code = "storage.backend" 

557 

558 

559class DataNotFoundError(StorageError): 

560 """Data not found in storage""" 

561 

562 default_message = "Data not found" 

563 default_error_code = "storage.not_found" 

564 default_status_code = 404 

565 

566 

567class DataIntegrityError(StorageError): 

568 """Data integrity check failed""" 

569 

570 default_message = "Data integrity error" 

571 default_error_code = "storage.integrity" 

572 

573 

574# ============================================================================== 

575# Compliance Errors (403 - Forbidden) 

576# ============================================================================== 

577 

578 

579class ComplianceError(MCPServerException): 

580 """Base class for compliance errors""" 

581 

582 default_message = "Compliance violation" 

583 default_error_code = "compliance.violation" 

584 default_status_code = 403 

585 default_category = ErrorCategory.SERVER_ERROR 

586 

587 

588class GDPRViolationError(ComplianceError): 

589 """GDPR compliance violation""" 

590 

591 default_message = "GDPR compliance violation" 

592 default_error_code = "compliance.gdpr" 

593 

594 

595class HIPAAViolationError(ComplianceError): 

596 """HIPAA compliance violation""" 

597 

598 default_message = "HIPAA compliance violation" 

599 default_error_code = "compliance.hipaa" 

600 

601 

602class SOC2ViolationError(ComplianceError): 

603 """SOC 2 compliance violation""" 

604 

605 default_message = "SOC 2 compliance violation" 

606 default_error_code = "compliance.soc2" 

607 

608 

609# ============================================================================== 

610# Internal Server Errors (500) 

611# ============================================================================== 

612 

613 

614class InternalServerError(MCPServerException): 

615 """Unexpected internal server error""" 

616 

617 default_message = "Internal server error" 

618 default_error_code = "server.internal" 

619 default_status_code = 500 

620 

621 

622class UnexpectedError(InternalServerError): 

623 """Unexpected error occurred""" 

624 

625 default_message = "Unexpected error occurred" 

626 default_error_code = "server.unexpected" 

627 

628 

629class NotImplementedError(InternalServerError): 

630 """Feature not implemented""" 

631 

632 default_message = "Feature not implemented" 

633 default_error_code = "server.not_implemented" 

634 default_status_code = 501