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
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 06:31 +0000
1"""
2Custom exception hierarchy for MCP server.
4Provides rich error context, automatic HTTP status code mapping,
5and integration with observability stack.
7See ADR-0029 for design rationale.
8"""
10from enum import Enum
11from typing import Any
13from opentelemetry import trace
16class ErrorCategory(str, Enum):
17 """High-level error categories for metrics"""
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
26class RetryPolicy(str, Enum):
27 """Whether exception is retry-able"""
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)
34class MCPServerException(Exception):
35 """
36 Base exception for all MCP server errors.
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 """
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
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
79 # Call parent constructor
80 super().__init__(self.message)
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
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."
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 }
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)
125# ==============================================================================
126# Configuration Errors (500 - Server Error)
127# ==============================================================================
130class ConfigurationError(MCPServerException):
131 """Base class for configuration errors"""
133 default_message = "Configuration error"
134 default_error_code = "config.error"
135 default_status_code = 500
136 default_category = ErrorCategory.SERVER_ERROR
139class MissingConfigError(ConfigurationError):
140 """Required configuration is missing"""
142 default_message = "Required configuration is missing"
143 default_error_code = "config.missing"
146class InvalidConfigError(ConfigurationError):
147 """Configuration value is invalid"""
149 default_message = "Configuration value is invalid"
150 default_error_code = "config.invalid"
153class SecretNotFoundError(ConfigurationError):
154 """Secret not found in secrets manager"""
156 default_message = "Secret not found"
157 default_error_code = "config.secret_not_found"
160# ==============================================================================
161# Authentication Errors (401 - Unauthorized)
162# ==============================================================================
165class AuthenticationError(MCPServerException):
166 """Base class for authentication errors"""
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
175class InvalidCredentialsError(AuthenticationError):
176 """Username or password is incorrect"""
178 default_message = "Invalid credentials"
179 default_error_code = "auth.invalid_credentials"
181 def _generate_user_message(self) -> str:
182 return "Invalid username or password. Please try again."
185class TokenExpiredError(AuthenticationError):
186 """JWT token has expired"""
188 default_message = "Token has expired"
189 default_error_code = "auth.token_expired"
190 default_retry_policy = RetryPolicy.CONDITIONAL # Retry with refresh token
192 def _generate_user_message(self) -> str:
193 return "Your session has expired. Please sign in again."
196class TokenInvalidError(AuthenticationError):
197 """JWT token is invalid"""
199 default_message = "Token is invalid"
200 default_error_code = "auth.token_invalid"
202 def _generate_user_message(self) -> str:
203 return "Authentication failed. Please sign in again."
206class MFARequiredError(AuthenticationError):
207 """Multi-factor authentication is required"""
209 default_message = "MFA required"
210 default_error_code = "auth.mfa_required"
211 default_status_code = 403
213 def _generate_user_message(self) -> str:
214 return "Multi-factor authentication is required. Please complete MFA."
217# ==============================================================================
218# Authorization Errors (403 - Forbidden)
219# ==============================================================================
222class AuthorizationError(MCPServerException):
223 """Base class for authorization errors"""
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
232class PermissionDeniedError(AuthorizationError):
233 """User does not have required permission"""
235 default_message = "Permission denied"
236 default_error_code = "authz.permission_denied"
238 def _generate_user_message(self) -> str:
239 return "You don't have permission to perform this action."
242class ResourceNotFoundError(AuthorizationError):
243 """Resource not found (or user doesn't have access)"""
245 default_message = "Resource not found"
246 default_error_code = "authz.resource_not_found"
247 default_status_code = 404
249 def _generate_user_message(self) -> str:
250 return "The requested resource was not found."
253class InsufficientPermissionsError(AuthorizationError):
254 """User's tier/plan doesn't allow this action"""
256 default_message = "Insufficient permissions"
257 default_error_code = "authz.insufficient_permissions"
259 def _generate_user_message(self) -> str:
260 return "Your current plan doesn't include this feature. Please upgrade."
263# ==============================================================================
264# Rate Limiting Errors (429 - Too Many Requests)
265# ==============================================================================
268class RateLimitError(MCPServerException):
269 """Base class for rate limiting errors"""
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
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."
282class RateLimitExceededError(RateLimitError):
283 """Request rate limit exceeded"""
286class QuotaExceededError(RateLimitError):
287 """Usage quota exceeded"""
289 default_error_code = "quota.exceeded"
291 def _generate_user_message(self) -> str:
292 return "You've exceeded your usage quota. Please upgrade your plan."
295# ==============================================================================
296# Validation Errors (400 - Bad Request)
297# ==============================================================================
300class ValidationError(MCPServerException):
301 """Base class for validation errors"""
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
310class InputValidationError(ValidationError):
311 """User input failed validation"""
313 default_error_code = "validation.input"
315 def _generate_user_message(self) -> str:
316 field = self.metadata.get("field", "input")
317 return f"Invalid {field}. Please check and try again."
320class SchemaValidationError(ValidationError):
321 """Data doesn't match expected schema"""
323 default_error_code = "validation.schema"
326class ConstraintViolationError(ValidationError):
327 """Business rule constraint violated"""
329 default_error_code = "validation.constraint"
332# ==============================================================================
333# External Service Errors (502/503/504)
334# ==============================================================================
337class ExternalServiceError(MCPServerException):
338 """Base class for external service errors"""
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
347class LLMProviderError(ExternalServiceError):
348 """LLM API error"""
350 default_message = "LLM provider error"
351 default_error_code = "external.llm.error"
354class LLMRateLimitError(LLMProviderError):
355 """LLM provider rate limit exceeded.
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 """
362 default_message = "LLM provider rate limit exceeded"
363 default_error_code = "external.llm.rate_limit"
364 default_status_code = 429
366 def __init__(
367 self,
368 message: str | None = None,
369 retry_after: float | None = None,
370 **kwargs: Any,
371 ):
372 """Initialize LLMRateLimitError.
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)
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."
390class LLMTimeoutError(LLMProviderError):
391 """LLM request timed out"""
393 default_message = "LLM request timed out"
394 default_error_code = "external.llm.timeout"
395 default_status_code = 504
398class LLMModelNotFoundError(LLMProviderError):
399 """LLM model not found"""
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
407class LLMOverloadError(LLMProviderError):
408 """LLM provider is overloaded (529 status).
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 """
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
420 def __init__(
421 self,
422 message: str | None = None,
423 retry_after: float | None = None,
424 **kwargs: Any,
425 ):
426 """Initialize LLMOverloadError.
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)
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."
444class OpenFGAError(ExternalServiceError):
445 """OpenFGA service error"""
447 default_message = "OpenFGA service error"
448 default_error_code = "external.openfga.error"
451class OpenFGATimeoutError(OpenFGAError):
452 """OpenFGA request timed out"""
454 default_message = "OpenFGA request timed out"
455 default_error_code = "external.openfga.timeout"
456 default_status_code = 504
459class OpenFGAUnavailableError(OpenFGAError):
460 """OpenFGA service unavailable"""
462 default_message = "OpenFGA service unavailable"
463 default_error_code = "external.openfga.unavailable"
466class RedisError(ExternalServiceError):
467 """Redis service error"""
469 default_message = "Redis service error"
470 default_error_code = "external.redis.error"
473class RedisConnectionError(RedisError):
474 """Redis connection error"""
476 default_message = "Redis connection error"
477 default_error_code = "external.redis.connection"
480class RedisTimeoutError(RedisError):
481 """Redis request timed out"""
483 default_message = "Redis request timed out"
484 default_error_code = "external.redis.timeout"
485 default_status_code = 504
488class KeycloakError(ExternalServiceError):
489 """Keycloak service error"""
491 default_message = "Keycloak service error"
492 default_error_code = "external.keycloak.error"
495class KeycloakAuthError(KeycloakError):
496 """Keycloak authentication error"""
498 default_message = "Keycloak authentication error"
499 default_error_code = "external.keycloak.auth"
500 default_status_code = 401
503class KeycloakUnavailableError(KeycloakError):
504 """Keycloak service unavailable"""
506 default_message = "Keycloak service unavailable"
507 default_error_code = "external.keycloak.unavailable"
510# ==============================================================================
511# Resilience Errors (503/504 - Service Unavailable/Timeout)
512# ==============================================================================
515class ResilienceError(MCPServerException):
516 """Base class for resilience pattern errors"""
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
525class CircuitBreakerOpenError(ResilienceError):
526 """Circuit breaker is open, failing fast"""
528 default_message = "Circuit breaker is open"
529 default_error_code = "resilience.circuit_breaker_open"
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."
536class RetryExhaustedError(ResilienceError):
537 """Retry attempts exhausted"""
539 default_message = "Retry attempts exhausted"
540 default_error_code = "resilience.retry_exhausted"
543class TimeoutError(ResilienceError):
544 """Operation timed out"""
546 default_message = "Operation timed out"
547 default_error_code = "resilience.timeout"
548 default_status_code = 504
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."
555class BulkheadRejectedError(ResilienceError):
556 """Bulkhead rejected request (concurrency limit)"""
558 default_message = "Bulkhead rejected request"
559 default_error_code = "resilience.bulkhead_rejected"
560 default_status_code = 503
562 def _generate_user_message(self) -> str:
563 return "Service is under heavy load. Please try again in a moment."
566# ==============================================================================
567# Storage Errors (500 - Server Error)
568# ==============================================================================
571class StorageError(MCPServerException):
572 """Base class for storage errors"""
574 default_message = "Storage error"
575 default_error_code = "storage.error"
576 default_status_code = 500
577 default_category = ErrorCategory.SERVER_ERROR
580class StorageBackendError(StorageError):
581 """Storage backend error"""
583 default_message = "Storage backend error"
584 default_error_code = "storage.backend"
587class DataNotFoundError(StorageError):
588 """Data not found in storage"""
590 default_message = "Data not found"
591 default_error_code = "storage.not_found"
592 default_status_code = 404
595class DataIntegrityError(StorageError):
596 """Data integrity check failed"""
598 default_message = "Data integrity error"
599 default_error_code = "storage.integrity"
602# ==============================================================================
603# Compliance Errors (403 - Forbidden)
604# ==============================================================================
607class ComplianceError(MCPServerException):
608 """Base class for compliance errors"""
610 default_message = "Compliance violation"
611 default_error_code = "compliance.violation"
612 default_status_code = 403
613 default_category = ErrorCategory.SERVER_ERROR
616class GDPRViolationError(ComplianceError):
617 """GDPR compliance violation"""
619 default_message = "GDPR compliance violation"
620 default_error_code = "compliance.gdpr"
623class HIPAAViolationError(ComplianceError):
624 """HIPAA compliance violation"""
626 default_message = "HIPAA compliance violation"
627 default_error_code = "compliance.hipaa"
630class SOC2ViolationError(ComplianceError):
631 """SOC 2 compliance violation"""
633 default_message = "SOC 2 compliance violation"
634 default_error_code = "compliance.soc2"
637# ==============================================================================
638# Internal Server Errors (500)
639# ==============================================================================
642class InternalServerError(MCPServerException):
643 """Unexpected internal server error"""
645 default_message = "Internal server error"
646 default_error_code = "server.internal"
647 default_status_code = 500
650class UnexpectedError(InternalServerError):
651 """Unexpected error occurred"""
653 default_message = "Unexpected error occurred"
654 default_error_code = "server.unexpected"
657class NotImplementedError(InternalServerError):
658 """Feature not implemented"""
660 default_message = "Feature not implemented"
661 default_error_code = "server.not_implemented"
662 default_status_code = 501