Coverage for src / augint_library / exceptions.py: 92%
147 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 20:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 20:22 +0000
1"""Custom exception hierarchy for augint-library.
3This module demonstrates how to create a production-ready exception system with:
4- Structured error data with error codes
5- JSON serialization support
6- Fluent interface for adding context
7- Decorators for exception handling
8- Testing utilities
10Common Use Cases:
11 1. Creating domain-specific exceptions
12 2. Building APIs with consistent error responses
13 3. Adding context to errors as they bubble up
14 4. Testing error conditions
15 5. Logging structured error data
17Examples:
18 Basic exception with error code:
19 >>> from augint_library.exceptions import ValidationError, NetworkError
20 >>>
21 >>> # Validation error with field details
22 >>> error = ValidationError(
23 ... "Invalid email format",
24 ... field="email",
25 ... value="not-an-email"
26 ... )
27 >>> print(f"Code: {error.code.value}")
28 Code: VALIDATION_ERROR
29 >>> print(f"Field: {error.details['field']}")
30 Field: email
32 Network error with retry information:
33 >>> error = NetworkError(
34 ... "Connection timeout",
35 ... service="payment-api",
36 ... status_code=504,
37 ... retry_after=30
38 ... )
39 >>> print(error.to_json())
40 {"message": "Connection timeout", "code": "NETWORK_ERROR", ...}
42 Exception chaining for context:
43 >>> try:
44 ... # Low-level error
45 ... raise ConnectionError("Socket closed")
46 ... except ConnectionError as e:
47 ... # Wrap with business context
48 ... raise NetworkError(
49 ... "Failed to fetch user data",
50 ... service="user-api",
51 ... status_code=500
52 ... ) from e
54 Using error codes for handling:
55 >>> from augint_library.exceptions import ErrorCode
56 >>>
57 >>> def handle_error(error: AugintError):
58 ... if error.code == ErrorCode.VALIDATION_ERROR:
59 ... return {"status": 400, "error": "Bad Request"}
60 ... elif error.code == ErrorCode.NETWORK_ERROR:
61 ... return {"status": 503, "error": "Service Unavailable"}
62 ... else:
63 ... return {"status": 500, "error": "Internal Error"}
65 Using decorators for error handling:
66 >>> from augint_library.exceptions import handle_exceptions
67 >>>
68 >>> @handle_exceptions(NetworkError, default="offline")
69 ... def fetch_user(user_id):
70 ... # This might raise NetworkError
71 ... return fetch_data(f"/users/{user_id}")
72 >>>
73 >>> # Returns "offline" instead of raising on NetworkError
74 >>> user = fetch_user(123)
76Note:
77 Well-designed exceptions are crucial for maintainable code. They should:
78 - Carry enough context to debug issues
79 - Be specific enough to handle different cases
80 - Support serialization for APIs and logging
81 - Follow a consistent hierarchy
82"""
84import json
85from datetime import datetime, timezone
86from enum import Enum
87from functools import wraps
88from typing import Any, Callable, Optional
91class ErrorCode(Enum):
92 """Enumeration of error codes for consistent error handling."""
94 GENERIC_ERROR = "GENERIC_ERROR"
95 CONFIGURATION_ERROR = "CONFIGURATION_ERROR"
96 VALIDATION_ERROR = "VALIDATION_ERROR"
97 RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
98 RESOURCE_ALREADY_EXISTS = "RESOURCE_ALREADY_EXISTS"
99 NETWORK_ERROR = "NETWORK_ERROR"
100 NETWORK_TIMEOUT = "NETWORK_TIMEOUT"
101 RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
102 AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
103 AUTHORIZATION_ERROR = "AUTHORIZATION_ERROR"
104 MULTIPLE_ERRORS = "MULTIPLE_ERRORS"
107class AugintError(Exception):
108 """Base exception class for all augint-library errors.
110 Features:
111 - Error codes for categorization
112 - Structured error details
113 - JSON serialization
114 - Fluent interface for context
115 """
117 def __init__(
118 self,
119 message: str,
120 code: ErrorCode = ErrorCode.GENERIC_ERROR,
121 details: Optional[dict[str, Any]] = None,
122 ):
123 """Initialize AugintError.
125 Args:
126 message: Human-readable error message
127 code: Error code for categorization
128 details: Additional error context
129 """
130 super().__init__(message)
131 self.message = message
132 self.code = code
133 self.details = details or {}
134 self.timestamp = datetime.now(timezone.utc)
136 def with_code(self, code: ErrorCode) -> "AugintError":
137 """Set error code (fluent interface)."""
138 self.code = code
139 return self
141 def with_detail(self, key: str, value: Any) -> "AugintError":
142 """Add a detail (fluent interface)."""
143 self.details[key] = value
144 return self
146 def with_context(self, context: dict[str, Any]) -> "AugintError":
147 """Add multiple details (fluent interface)."""
148 self.details.update(context)
149 return self
151 def to_dict(self) -> dict[str, Any]:
152 """Convert exception to dictionary."""
153 return {
154 "code": self.code.value,
155 "message": self.message,
156 "details": self.details,
157 "timestamp": self.timestamp.isoformat(),
158 }
160 def to_json(self) -> str:
161 """Convert exception to JSON string."""
162 return json.dumps({"error": self.to_dict()}, default=str)
165class ConfigurationError(AugintError):
166 """Raised when configuration is invalid or missing."""
168 def __init__(self, message: str, key: Optional[str] = None, **kwargs: Any) -> None:
169 """Initialize ConfigurationError.
171 Args:
172 message: Error message
173 key: Configuration key that caused the error
174 **kwargs: Additional details
175 """
176 details = {"key": key} if key else {}
177 details.update(kwargs)
178 super().__init__(message, ErrorCode.CONFIGURATION_ERROR, details)
181class ValidationError(AugintError):
182 """Raised when validation fails."""
184 def __init__(
185 self,
186 message: str,
187 field: Optional[str] = None,
188 value: Any = None,
189 constraint: Optional[str] = None,
190 **kwargs: Any,
191 ) -> None:
192 """Initialize ValidationError.
194 Args:
195 message: Error message
196 field: Field that failed validation
197 value: Invalid value
198 constraint: Constraint that was violated
199 **kwargs: Additional details
200 """
201 details = {}
202 if field is not None:
203 details["field"] = field
204 if value is not None:
205 details["value"] = value
206 if constraint is not None:
207 details["constraint"] = constraint
208 details.update(kwargs)
209 super().__init__(message, ErrorCode.VALIDATION_ERROR, details)
211 @classmethod
212 def create_mock(cls, **kwargs: Any) -> "ValidationError":
213 """Create a mock ValidationError for testing."""
214 message = kwargs.pop("message", "Mock validation error")
215 return cls(message, **kwargs)
218class ResourceError(AugintError):
219 """Base class for resource-related errors."""
221 def __init__(
222 self, message: str, code: ErrorCode, resource_type: str, resource_id: Any, **kwargs: Any
223 ) -> None:
224 """Initialize ResourceError."""
225 details = {"resource_type": resource_type, "resource_id": resource_id}
226 details.update(kwargs)
227 super().__init__(message, code, details)
230class ResourceNotFoundError(ResourceError):
231 """Raised when a resource is not found."""
233 def __init__(self, resource_type: str, resource_id: Any, **kwargs: Any) -> None:
234 """Initialize ResourceNotFoundError."""
235 message = f"{resource_type} with id {resource_id} not found"
236 super().__init__(
237 message, ErrorCode.RESOURCE_NOT_FOUND, resource_type, resource_id, **kwargs
238 )
241class ResourceAlreadyExistsError(ResourceError):
242 """Raised when trying to create a resource that already exists."""
244 def __init__(self, resource_type: str, resource_id: Any, **kwargs: Any) -> None:
245 """Initialize ResourceAlreadyExistsError."""
246 message = f"{resource_type} with id {resource_id} already exists"
247 super().__init__(
248 message, ErrorCode.RESOURCE_ALREADY_EXISTS, resource_type, resource_id, **kwargs
249 )
252class NetworkError(AugintError):
253 """Base class for network-related errors."""
255 def __init__(
256 self,
257 message: str,
258 code: ErrorCode = ErrorCode.NETWORK_ERROR,
259 service: Optional[str] = None,
260 **kwargs: Any,
261 ) -> None:
262 """Initialize NetworkError."""
263 details = {"service": service} if service else {}
264 details.update(kwargs)
265 super().__init__(message, code, details)
268class NetworkTimeoutError(NetworkError):
269 """Raised when a network operation times out."""
271 def __init__(self, service: str, timeout: float, **kwargs: Any) -> None:
272 """Initialize NetworkTimeoutError."""
273 message = f"Timeout connecting to {service} after {timeout}s"
274 super().__init__(
275 message, ErrorCode.NETWORK_TIMEOUT, service=service, timeout=timeout, **kwargs
276 )
279class RateLimitError(NetworkError):
280 """Raised when rate limit is exceeded."""
282 def __init__(
283 self,
284 service: str,
285 retry_after: Optional[int] = None,
286 limit: Optional[int] = None,
287 remaining: Optional[int] = None,
288 **kwargs: Any,
289 ) -> None:
290 """Initialize RateLimitError."""
291 message = f"Rate limit exceeded for {service}"
292 if retry_after: 292 ↛ 296line 292 didn't jump to line 296 because the condition on line 292 was always true
293 message += f", retry after {retry_after}s"
295 # Add rate limit details to kwargs
296 if retry_after is not None: 296 ↛ 298line 296 didn't jump to line 298 because the condition on line 296 was always true
297 kwargs["retry_after"] = retry_after
298 if limit is not None: 298 ↛ 300line 298 didn't jump to line 300 because the condition on line 298 was always true
299 kwargs["limit"] = limit
300 if remaining is not None: 300 ↛ 303line 300 didn't jump to line 303 because the condition on line 300 was always true
301 kwargs["remaining"] = remaining
303 super().__init__(message, ErrorCode.RATE_LIMIT_EXCEEDED, service=service, **kwargs)
306class MultiError(AugintError):
307 """Container for multiple errors in batch operations."""
309 def __init__(self, errors: list[AugintError]):
310 """Initialize MultiError.
312 Args:
313 errors: List of errors that occurred
314 """
315 self.errors = errors
316 message = f"{len(errors)} errors occurred"
317 super().__init__(message, ErrorCode.MULTIPLE_ERRORS)
319 def to_dict(self) -> dict[str, Any]:
320 """Convert to dictionary including all errors."""
321 base_dict = super().to_dict()
322 base_dict["errors"] = [error.to_dict() for error in self.errors]
323 return base_dict
326def handle_exceptions(exception_map: dict[type[Exception], ErrorCode]) -> Callable[..., Any]:
327 """Decorator to transform standard exceptions to AugintError.
329 Args:
330 exception_map: Mapping of exception types to error codes
332 Returns:
333 Decorated function
334 """
336 def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
337 @wraps(func)
338 def wrapper(*args: Any, **kwargs: Any) -> Any:
339 try:
340 return func(*args, **kwargs)
341 except AugintError:
342 # Re-raise our own exceptions
343 raise
344 except Exception as e:
345 # Transform mapped exceptions
346 for exc_type, error_code in exception_map.items(): 346 ↛ 350line 346 didn't jump to line 350 because the loop on line 346 didn't complete
347 if isinstance(e, exc_type):
348 raise AugintError(str(e), code=error_code) from e
349 # Re-raise unmapped exceptions
350 raise
352 return wrapper
354 return decorator
357def collect_errors(func: Callable[..., Any]) -> Callable[..., tuple[Any, list[AugintError]]]:
358 """Decorator to collect errors in batch operations.
360 Returns tuple of (results, errors) instead of raising.
362 Args:
363 func: Function to decorate
365 Returns:
366 Decorated function returning (results, errors)
367 """
369 @wraps(func)
370 def wrapper(*args: Any, **kwargs: Any) -> tuple[Any, list[AugintError]]:
371 errors = []
372 results = []
374 # Get the first argument (assumed to be iterable)
375 if not args: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true
376 return func(*args, **kwargs), []
378 items = args[0]
379 remaining_args = args[1:]
381 for item in items:
382 try:
383 # Call function with individual item
384 result = func([item], *remaining_args, **kwargs)
385 results.extend(result if isinstance(result, list) else [result])
386 except AugintError as e:
387 errors.append(e)
388 except Exception as e:
389 # Wrap non-AugintError exceptions
390 errors.append(AugintError(str(e)))
392 return results, errors
394 return wrapper
397def error_context(**context: Any) -> Callable[..., Any]:
398 """Decorator to add context to any exceptions raised.
400 Args:
401 **context: Context to add to exceptions
403 Returns:
404 Decorated function
405 """
407 def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
408 @wraps(func)
409 def wrapper(*args: Any, **kwargs: Any) -> Any:
410 try:
411 return func(*args, **kwargs)
412 except AugintError as e:
413 # Add context to our exceptions
414 e.with_context(context)
415 raise
416 except Exception:
417 # Don't modify other exceptions
418 raise
420 return wrapper
422 return decorator