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
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +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: 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)
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 default_message = "LLM provider rate limit exceeded"
358 default_error_code = "external.llm.rate_limit"
359 default_status_code = 429
362class LLMTimeoutError(LLMProviderError):
363 """LLM request timed out"""
365 default_message = "LLM request timed out"
366 default_error_code = "external.llm.timeout"
367 default_status_code = 504
370class LLMModelNotFoundError(LLMProviderError):
371 """LLM model not found"""
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
379class LLMOverloadError(LLMProviderError):
380 """LLM provider is overloaded (529 status).
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 """
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
392 def __init__(
393 self,
394 message: str | None = None,
395 retry_after: float | None = None,
396 **kwargs: Any,
397 ):
398 """Initialize LLMOverloadError.
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)
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."
416class OpenFGAError(ExternalServiceError):
417 """OpenFGA service error"""
419 default_message = "OpenFGA service error"
420 default_error_code = "external.openfga.error"
423class OpenFGATimeoutError(OpenFGAError):
424 """OpenFGA request timed out"""
426 default_message = "OpenFGA request timed out"
427 default_error_code = "external.openfga.timeout"
428 default_status_code = 504
431class OpenFGAUnavailableError(OpenFGAError):
432 """OpenFGA service unavailable"""
434 default_message = "OpenFGA service unavailable"
435 default_error_code = "external.openfga.unavailable"
438class RedisError(ExternalServiceError):
439 """Redis service error"""
441 default_message = "Redis service error"
442 default_error_code = "external.redis.error"
445class RedisConnectionError(RedisError):
446 """Redis connection error"""
448 default_message = "Redis connection error"
449 default_error_code = "external.redis.connection"
452class RedisTimeoutError(RedisError):
453 """Redis request timed out"""
455 default_message = "Redis request timed out"
456 default_error_code = "external.redis.timeout"
457 default_status_code = 504
460class KeycloakError(ExternalServiceError):
461 """Keycloak service error"""
463 default_message = "Keycloak service error"
464 default_error_code = "external.keycloak.error"
467class KeycloakAuthError(KeycloakError):
468 """Keycloak authentication error"""
470 default_message = "Keycloak authentication error"
471 default_error_code = "external.keycloak.auth"
472 default_status_code = 401
475class KeycloakUnavailableError(KeycloakError):
476 """Keycloak service unavailable"""
478 default_message = "Keycloak service unavailable"
479 default_error_code = "external.keycloak.unavailable"
482# ==============================================================================
483# Resilience Errors (503/504 - Service Unavailable/Timeout)
484# ==============================================================================
487class ResilienceError(MCPServerException):
488 """Base class for resilience pattern errors"""
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
497class CircuitBreakerOpenError(ResilienceError):
498 """Circuit breaker is open, failing fast"""
500 default_message = "Circuit breaker is open"
501 default_error_code = "resilience.circuit_breaker_open"
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."
508class RetryExhaustedError(ResilienceError):
509 """Retry attempts exhausted"""
511 default_message = "Retry attempts exhausted"
512 default_error_code = "resilience.retry_exhausted"
515class TimeoutError(ResilienceError):
516 """Operation timed out"""
518 default_message = "Operation timed out"
519 default_error_code = "resilience.timeout"
520 default_status_code = 504
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."
527class BulkheadRejectedError(ResilienceError):
528 """Bulkhead rejected request (concurrency limit)"""
530 default_message = "Bulkhead rejected request"
531 default_error_code = "resilience.bulkhead_rejected"
532 default_status_code = 503
534 def _generate_user_message(self) -> str:
535 return "Service is under heavy load. Please try again in a moment."
538# ==============================================================================
539# Storage Errors (500 - Server Error)
540# ==============================================================================
543class StorageError(MCPServerException):
544 """Base class for storage errors"""
546 default_message = "Storage error"
547 default_error_code = "storage.error"
548 default_status_code = 500
549 default_category = ErrorCategory.SERVER_ERROR
552class StorageBackendError(StorageError):
553 """Storage backend error"""
555 default_message = "Storage backend error"
556 default_error_code = "storage.backend"
559class DataNotFoundError(StorageError):
560 """Data not found in storage"""
562 default_message = "Data not found"
563 default_error_code = "storage.not_found"
564 default_status_code = 404
567class DataIntegrityError(StorageError):
568 """Data integrity check failed"""
570 default_message = "Data integrity error"
571 default_error_code = "storage.integrity"
574# ==============================================================================
575# Compliance Errors (403 - Forbidden)
576# ==============================================================================
579class ComplianceError(MCPServerException):
580 """Base class for compliance errors"""
582 default_message = "Compliance violation"
583 default_error_code = "compliance.violation"
584 default_status_code = 403
585 default_category = ErrorCategory.SERVER_ERROR
588class GDPRViolationError(ComplianceError):
589 """GDPR compliance violation"""
591 default_message = "GDPR compliance violation"
592 default_error_code = "compliance.gdpr"
595class HIPAAViolationError(ComplianceError):
596 """HIPAA compliance violation"""
598 default_message = "HIPAA compliance violation"
599 default_error_code = "compliance.hipaa"
602class SOC2ViolationError(ComplianceError):
603 """SOC 2 compliance violation"""
605 default_message = "SOC 2 compliance violation"
606 default_error_code = "compliance.soc2"
609# ==============================================================================
610# Internal Server Errors (500)
611# ==============================================================================
614class InternalServerError(MCPServerException):
615 """Unexpected internal server error"""
617 default_message = "Internal server error"
618 default_error_code = "server.internal"
619 default_status_code = 500
622class UnexpectedError(InternalServerError):
623 """Unexpected error occurred"""
625 default_message = "Unexpected error occurred"
626 default_error_code = "server.unexpected"
629class NotImplementedError(InternalServerError):
630 """Feature not implemented"""
632 default_message = "Feature not implemented"
633 default_error_code = "server.not_implemented"
634 default_status_code = 501