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

287 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-08 06:31 +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: 

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 This error indicates the LLM provider has rate-limited the request (429 status). 

358 The retry_after attribute may contain the server's suggested wait time before 

359 retrying, extracted from the Retry-After header. 

360 """ 

361 

362 default_message = "LLM provider rate limit exceeded" 

363 default_error_code = "external.llm.rate_limit" 

364 default_status_code = 429 

365 

366 def __init__( 

367 self, 

368 message: str | None = None, 

369 retry_after: float | None = None, 

370 **kwargs: Any, 

371 ): 

372 """Initialize LLMRateLimitError. 

373 

374 Args: 

375 message: Human-readable error message 

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

377 **kwargs: Additional arguments passed to parent 

378 """ 

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

380 # _generate_user_message() is called during parent initialization 

381 self.retry_after = retry_after 

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

383 

384 def _generate_user_message(self) -> str: 

385 if self.retry_after: 

386 return f"Rate limit exceeded. Please wait {int(self.retry_after)} seconds and try again." 

387 return "Rate limit exceeded. Please try again later." 

388 

389 

390class LLMTimeoutError(LLMProviderError): 

391 """LLM request timed out""" 

392 

393 default_message = "LLM request timed out" 

394 default_error_code = "external.llm.timeout" 

395 default_status_code = 504 

396 

397 

398class LLMModelNotFoundError(LLMProviderError): 

399 """LLM model not found""" 

400 

401 default_message = "LLM model not found" 

402 default_error_code = "external.llm.model_not_found" 

403 default_status_code = 400 

404 default_retry_policy = RetryPolicy.NEVER 

405 

406 

407class LLMOverloadError(LLMProviderError): 

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

409 

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

411 temporarily cannot process requests. The retry_after attribute may 

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

413 """ 

414 

415 default_message = "LLM provider is overloaded" 

416 default_error_code = "external.llm.overloaded" 

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

418 default_retry_policy = RetryPolicy.ALWAYS 

419 

420 def __init__( 

421 self, 

422 message: str | None = None, 

423 retry_after: float | None = None, 

424 **kwargs: Any, 

425 ): 

426 """Initialize LLMOverloadError. 

427 

428 Args: 

429 message: Human-readable error message 

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

431 **kwargs: Additional arguments passed to parent 

432 """ 

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

434 # _generate_user_message() is called during parent initialization 

435 self.retry_after = retry_after 

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

437 

438 def _generate_user_message(self) -> str: 

439 if self.retry_after: 

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

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

442 

443 

444class OpenFGAError(ExternalServiceError): 

445 """OpenFGA service error""" 

446 

447 default_message = "OpenFGA service error" 

448 default_error_code = "external.openfga.error" 

449 

450 

451class OpenFGATimeoutError(OpenFGAError): 

452 """OpenFGA request timed out""" 

453 

454 default_message = "OpenFGA request timed out" 

455 default_error_code = "external.openfga.timeout" 

456 default_status_code = 504 

457 

458 

459class OpenFGAUnavailableError(OpenFGAError): 

460 """OpenFGA service unavailable""" 

461 

462 default_message = "OpenFGA service unavailable" 

463 default_error_code = "external.openfga.unavailable" 

464 

465 

466class RedisError(ExternalServiceError): 

467 """Redis service error""" 

468 

469 default_message = "Redis service error" 

470 default_error_code = "external.redis.error" 

471 

472 

473class RedisConnectionError(RedisError): 

474 """Redis connection error""" 

475 

476 default_message = "Redis connection error" 

477 default_error_code = "external.redis.connection" 

478 

479 

480class RedisTimeoutError(RedisError): 

481 """Redis request timed out""" 

482 

483 default_message = "Redis request timed out" 

484 default_error_code = "external.redis.timeout" 

485 default_status_code = 504 

486 

487 

488class KeycloakError(ExternalServiceError): 

489 """Keycloak service error""" 

490 

491 default_message = "Keycloak service error" 

492 default_error_code = "external.keycloak.error" 

493 

494 

495class KeycloakAuthError(KeycloakError): 

496 """Keycloak authentication error""" 

497 

498 default_message = "Keycloak authentication error" 

499 default_error_code = "external.keycloak.auth" 

500 default_status_code = 401 

501 

502 

503class KeycloakUnavailableError(KeycloakError): 

504 """Keycloak service unavailable""" 

505 

506 default_message = "Keycloak service unavailable" 

507 default_error_code = "external.keycloak.unavailable" 

508 

509 

510# ============================================================================== 

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

512# ============================================================================== 

513 

514 

515class ResilienceError(MCPServerException): 

516 """Base class for resilience pattern errors""" 

517 

518 default_message = "Resilience error" 

519 default_error_code = "resilience.error" 

520 default_status_code = 503 

521 default_category = ErrorCategory.SERVER_ERROR 

522 default_retry_policy = RetryPolicy.CONDITIONAL 

523 

524 

525class CircuitBreakerOpenError(ResilienceError): 

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

527 

528 default_message = "Circuit breaker is open" 

529 default_error_code = "resilience.circuit_breaker_open" 

530 

531 def _generate_user_message(self) -> str: 

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

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

534 

535 

536class RetryExhaustedError(ResilienceError): 

537 """Retry attempts exhausted""" 

538 

539 default_message = "Retry attempts exhausted" 

540 default_error_code = "resilience.retry_exhausted" 

541 

542 

543class TimeoutError(ResilienceError): 

544 """Operation timed out""" 

545 

546 default_message = "Operation timed out" 

547 default_error_code = "resilience.timeout" 

548 default_status_code = 504 

549 

550 def _generate_user_message(self) -> str: 

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

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

553 

554 

555class BulkheadRejectedError(ResilienceError): 

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

557 

558 default_message = "Bulkhead rejected request" 

559 default_error_code = "resilience.bulkhead_rejected" 

560 default_status_code = 503 

561 

562 def _generate_user_message(self) -> str: 

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

564 

565 

566# ============================================================================== 

567# Storage Errors (500 - Server Error) 

568# ============================================================================== 

569 

570 

571class StorageError(MCPServerException): 

572 """Base class for storage errors""" 

573 

574 default_message = "Storage error" 

575 default_error_code = "storage.error" 

576 default_status_code = 500 

577 default_category = ErrorCategory.SERVER_ERROR 

578 

579 

580class StorageBackendError(StorageError): 

581 """Storage backend error""" 

582 

583 default_message = "Storage backend error" 

584 default_error_code = "storage.backend" 

585 

586 

587class DataNotFoundError(StorageError): 

588 """Data not found in storage""" 

589 

590 default_message = "Data not found" 

591 default_error_code = "storage.not_found" 

592 default_status_code = 404 

593 

594 

595class DataIntegrityError(StorageError): 

596 """Data integrity check failed""" 

597 

598 default_message = "Data integrity error" 

599 default_error_code = "storage.integrity" 

600 

601 

602# ============================================================================== 

603# Compliance Errors (403 - Forbidden) 

604# ============================================================================== 

605 

606 

607class ComplianceError(MCPServerException): 

608 """Base class for compliance errors""" 

609 

610 default_message = "Compliance violation" 

611 default_error_code = "compliance.violation" 

612 default_status_code = 403 

613 default_category = ErrorCategory.SERVER_ERROR 

614 

615 

616class GDPRViolationError(ComplianceError): 

617 """GDPR compliance violation""" 

618 

619 default_message = "GDPR compliance violation" 

620 default_error_code = "compliance.gdpr" 

621 

622 

623class HIPAAViolationError(ComplianceError): 

624 """HIPAA compliance violation""" 

625 

626 default_message = "HIPAA compliance violation" 

627 default_error_code = "compliance.hipaa" 

628 

629 

630class SOC2ViolationError(ComplianceError): 

631 """SOC 2 compliance violation""" 

632 

633 default_message = "SOC 2 compliance violation" 

634 default_error_code = "compliance.soc2" 

635 

636 

637# ============================================================================== 

638# Internal Server Errors (500) 

639# ============================================================================== 

640 

641 

642class InternalServerError(MCPServerException): 

643 """Unexpected internal server error""" 

644 

645 default_message = "Internal server error" 

646 default_error_code = "server.internal" 

647 default_status_code = 500 

648 

649 

650class UnexpectedError(InternalServerError): 

651 """Unexpected error occurred""" 

652 

653 default_message = "Unexpected error occurred" 

654 default_error_code = "server.unexpected" 

655 

656 

657class NotImplementedError(InternalServerError): 

658 """Feature not implemented""" 

659 

660 default_message = "Feature not implemented" 

661 default_error_code = "server.not_implemented" 

662 default_status_code = 501