nui-python-shared-utils 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ """
2
+ Error handling utilities for Lambda services with retry logic and pattern matching.
3
+ Provides decorators for automatic retries and error categorization.
4
+ """
5
+
6
+ import time
7
+ import logging
8
+ import functools
9
+ import random
10
+ from typing import Callable, Dict, List, Optional, Any, Union
11
+ from datetime import datetime
12
+ import re
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ # Known error patterns from production
18
+ ERROR_PATTERNS = {
19
+ "json_parse": {
20
+ "pattern": r"Unable to parse JSON data.*Syntax error",
21
+ "category": "data_format",
22
+ "severity": "critical",
23
+ "description": "JSON parsing error - malformed input data",
24
+ },
25
+ "auth_failure": {
26
+ "pattern": r"Client authentication failed",
27
+ "category": "authentication",
28
+ "severity": "warning",
29
+ "description": "OAuth client authentication failure",
30
+ },
31
+ "db_timeout": {
32
+ "pattern": r"Maximum execution time.*exceeded|Query execution was interrupted",
33
+ "category": "database",
34
+ "severity": "critical",
35
+ "description": "Database query timeout",
36
+ },
37
+ "connection_refused": {
38
+ "pattern": r"Connection refused|ECONNREFUSED",
39
+ "category": "network",
40
+ "severity": "critical",
41
+ "description": "Service connection refused",
42
+ },
43
+ "rate_limit": {
44
+ "pattern": r"Rate limit exceeded|Too many requests|429",
45
+ "category": "rate_limit",
46
+ "severity": "warning",
47
+ "description": "API rate limit exceeded",
48
+ },
49
+ "not_found": {
50
+ "pattern": r"404 Not Found|Record not found|Entity not found",
51
+ "category": "not_found",
52
+ "severity": "info",
53
+ "description": "Requested resource not found",
54
+ },
55
+ "permission_denied": {
56
+ "pattern": r"Permission denied|Access denied|403 Forbidden",
57
+ "category": "authorization",
58
+ "severity": "critical",
59
+ "description": "Permission denied for operation",
60
+ },
61
+ "es_timeout": {
62
+ "pattern": r"ConnectionTimeout|search_phase_execution_exception.*timeout",
63
+ "category": "elasticsearch",
64
+ "severity": "critical",
65
+ "description": "Elasticsearch query timeout",
66
+ },
67
+ "memory_error": {
68
+ "pattern": r"MemoryError|Cannot allocate memory|Out of memory",
69
+ "category": "resource",
70
+ "severity": "critical",
71
+ "description": "Memory allocation error",
72
+ },
73
+ "ssl_error": {
74
+ "pattern": r"SSL.*error|certificate verify failed",
75
+ "category": "security",
76
+ "severity": "critical",
77
+ "description": "SSL/TLS certificate error",
78
+ },
79
+ }
80
+
81
+
82
+ class RetryableError(Exception):
83
+ """Exception that should trigger a retry."""
84
+
85
+ pass
86
+
87
+
88
+ class NonRetryableError(Exception):
89
+ """Exception that should not trigger a retry."""
90
+
91
+ pass
92
+
93
+
94
+ class ErrorPatternMatcher:
95
+ """Match errors against known patterns for categorization."""
96
+
97
+ def __init__(self, patterns: Optional[Dict] = None):
98
+ self.patterns = patterns or ERROR_PATTERNS
99
+ self.compiled_patterns: Dict[str, Any] = {}
100
+ self._compile_patterns()
101
+
102
+ def _compile_patterns(self):
103
+ """Pre-compile regex patterns for efficiency."""
104
+ for key, config in self.patterns.items():
105
+ self.compiled_patterns[key] = re.compile(config["pattern"], re.IGNORECASE)
106
+
107
+ def match_error(self, error: Union[str, Exception]) -> Optional[Dict]:
108
+ """
109
+ Match an error against known patterns.
110
+
111
+ Returns:
112
+ Dict with pattern info if matched, None otherwise
113
+ """
114
+ error_str = str(error)
115
+
116
+ for key, pattern in self.compiled_patterns.items():
117
+ if pattern.search(error_str):
118
+ return {"pattern_key": key, **self.patterns[key]}
119
+
120
+ return None
121
+
122
+ def categorize_error(self, error: Union[str, Exception]) -> Dict:
123
+ """
124
+ Categorize an error and return enriched information.
125
+ """
126
+ match = self.match_error(error)
127
+
128
+ if match:
129
+ return {
130
+ "error": str(error),
131
+ "category": match["category"],
132
+ "severity": match["severity"],
133
+ "description": match["description"],
134
+ "pattern_matched": match["pattern_key"],
135
+ "is_retryable": match["category"] in ["network", "database", "elasticsearch", "rate_limit"],
136
+ }
137
+
138
+ # Default categorization for unmatched errors
139
+ return {
140
+ "error": str(error),
141
+ "category": "unknown",
142
+ "severity": "warning",
143
+ "description": "Unrecognized error pattern",
144
+ "pattern_matched": None,
145
+ "is_retryable": False,
146
+ }
147
+
148
+
149
+ def with_retry(
150
+ max_attempts: int = 3,
151
+ initial_delay: float = 1.0,
152
+ max_delay: float = 60.0,
153
+ exponential_base: float = 2.0,
154
+ jitter: bool = True,
155
+ retryable_exceptions: tuple = (Exception,),
156
+ non_retryable_exceptions: tuple = (NonRetryableError,),
157
+ on_retry: Optional[Callable] = None,
158
+ ) -> Callable:
159
+ """
160
+ Decorator for automatic retry with exponential backoff.
161
+
162
+ Args:
163
+ max_attempts: Maximum number of attempts
164
+ initial_delay: Initial delay between retries in seconds
165
+ max_delay: Maximum delay between retries
166
+ exponential_base: Base for exponential backoff
167
+ jitter: Add random jitter to prevent thundering herd
168
+ retryable_exceptions: Tuple of exceptions that trigger retry
169
+ non_retryable_exceptions: Tuple of exceptions that should not retry
170
+ on_retry: Optional callback function called on each retry
171
+
172
+ Example:
173
+ @with_retry(max_attempts=3, initial_delay=2.0)
174
+ def fetch_data():
175
+ return requests.get('https://api.example.com/data')
176
+ """
177
+
178
+ def decorator(func: Callable) -> Callable:
179
+ @functools.wraps(func)
180
+ def wrapper(*args, **kwargs):
181
+ attempt = 0
182
+ delay = initial_delay
183
+ last_exception = None
184
+
185
+ while attempt < max_attempts:
186
+ try:
187
+ return func(*args, **kwargs)
188
+
189
+ except non_retryable_exceptions as e:
190
+ log.error(f"{func.__name__} failed with non-retryable error: {e}")
191
+ raise
192
+
193
+ except retryable_exceptions as e:
194
+ attempt += 1
195
+ last_exception = e
196
+
197
+ if attempt >= max_attempts:
198
+ log.error(f"{func.__name__} failed after {max_attempts} attempts: {e}")
199
+ raise
200
+
201
+ # Calculate delay with exponential backoff
202
+ if jitter:
203
+ # Add random jitter (0.5 to 1.5 times the delay)
204
+ actual_delay = delay * (0.5 + random.random())
205
+ else:
206
+ actual_delay = delay
207
+
208
+ # Cap at max_delay
209
+ actual_delay = min(actual_delay, max_delay)
210
+
211
+ log.warning(
212
+ f"{func.__name__} failed (attempt {attempt}/{max_attempts}), "
213
+ f"retrying in {actual_delay:.1f}s: {e}"
214
+ )
215
+
216
+ # Call retry callback if provided
217
+ if on_retry:
218
+ on_retry(func.__name__, attempt, e, actual_delay)
219
+
220
+ time.sleep(actual_delay)
221
+
222
+ # Exponential backoff for next attempt
223
+ delay *= exponential_base
224
+
225
+ # Should not reach here, but just in case
226
+ if last_exception:
227
+ raise last_exception
228
+
229
+ return wrapper
230
+
231
+ return decorator
232
+
233
+
234
+ def categorize_retryable_error(error: Exception) -> bool:
235
+ """
236
+ Determine if an error should be retried based on its pattern.
237
+
238
+ Args:
239
+ error: The exception to categorize
240
+
241
+ Returns:
242
+ bool: True if error should be retried
243
+ """
244
+ matcher = ErrorPatternMatcher()
245
+ result = matcher.categorize_error(error)
246
+ return result["is_retryable"]
247
+
248
+
249
+ class ErrorAggregator:
250
+ """Aggregate errors for batch reporting."""
251
+
252
+ def __init__(self, max_errors: int = 100):
253
+ self.errors: List[Dict] = []
254
+ self.max_errors = max_errors
255
+ self.matcher = ErrorPatternMatcher()
256
+
257
+ def add_error(self, error: Union[str, Exception], context: Optional[Dict] = None):
258
+ """Add an error to the aggregator."""
259
+ categorized = self.matcher.categorize_error(error)
260
+
261
+ error_entry = {
262
+ "timestamp": datetime.utcnow().isoformat(),
263
+ "error": str(error),
264
+ "type": type(error).__name__ if isinstance(error, Exception) else "str",
265
+ **categorized,
266
+ }
267
+
268
+ if context:
269
+ error_entry["context"] = context
270
+
271
+ self.errors.append(error_entry)
272
+
273
+ # Keep only the most recent errors
274
+ if len(self.errors) > self.max_errors:
275
+ self.errors = self.errors[-self.max_errors :]
276
+
277
+ def get_summary(self) -> Dict:
278
+ """Get error summary statistics."""
279
+ if not self.errors:
280
+ return {"total_errors": 0, "by_category": {}, "by_severity": {}, "recent_errors": []}
281
+
282
+ by_category: Dict[str, int] = {}
283
+ by_severity: Dict[str, int] = {}
284
+
285
+ for error in self.errors:
286
+ # Count by category
287
+ category = error["category"]
288
+ by_category[category] = by_category.get(category, 0) + 1
289
+
290
+ # Count by severity
291
+ severity = error["severity"]
292
+ by_severity[severity] = by_severity.get(severity, 0) + 1
293
+
294
+ return {
295
+ "total_errors": len(self.errors),
296
+ "by_category": by_category,
297
+ "by_severity": by_severity,
298
+ "recent_errors": self.errors[-5:], # Last 5 errors
299
+ }
300
+
301
+ def clear(self):
302
+ """Clear all aggregated errors."""
303
+ self.errors = []
304
+
305
+
306
+ def handle_lambda_error(error: Exception, context: Dict) -> Dict:
307
+ """
308
+ Standard error handler for Lambda functions.
309
+
310
+ Args:
311
+ error: The exception that occurred
312
+ context: Lambda context or custom context dict
313
+
314
+ Returns:
315
+ Dict: Standardized error response
316
+ """
317
+ matcher = ErrorPatternMatcher()
318
+ categorized = matcher.categorize_error(error)
319
+
320
+ log.error(
321
+ f"Lambda error: {error}",
322
+ exc_info=True,
323
+ extra={
324
+ "error_category": categorized["category"],
325
+ "error_severity": categorized["severity"],
326
+ "function_name": context.get("function_name", "unknown"),
327
+ "request_id": context.get("aws_request_id", "unknown"),
328
+ },
329
+ )
330
+
331
+ return {
332
+ "statusCode": 500,
333
+ "body": {
334
+ "error": categorized["description"],
335
+ "category": categorized["category"],
336
+ "request_id": context.get("aws_request_id", "unknown"),
337
+ "timestamp": datetime.utcnow().isoformat(),
338
+ },
339
+ }
340
+
341
+
342
+ # Convenience decorators for common retry scenarios
343
+ def retry_on_network_error(func: Callable) -> Callable:
344
+ """Retry decorator specifically for network-related errors."""
345
+ return with_retry(
346
+ max_attempts=3,
347
+ initial_delay=2.0,
348
+ retryable_exceptions=(ConnectionError, TimeoutError, OSError),
349
+ on_retry=lambda name, attempt, error, delay: log.info(f"Network retry for {name}: attempt {attempt}"),
350
+ )(func)
351
+
352
+
353
+ def retry_on_db_error(func: Callable) -> Callable:
354
+ """Retry decorator specifically for database errors."""
355
+ return with_retry(
356
+ max_attempts=3,
357
+ initial_delay=1.0,
358
+ max_delay=10.0,
359
+ retryable_exceptions=(Exception,), # You might want to specify pymysql exceptions
360
+ on_retry=lambda name, attempt, error, delay: log.info(f"Database retry for {name}: attempt {attempt}"),
361
+ )(func)
362
+
363
+
364
+ def retry_on_es_error(func: Callable) -> Callable:
365
+ """Retry decorator specifically for Elasticsearch errors."""
366
+ return with_retry(
367
+ max_attempts=3,
368
+ initial_delay=2.0,
369
+ max_delay=30.0,
370
+ retryable_exceptions=(Exception,), # You might want to specify ES exceptions
371
+ on_retry=lambda name, attempt, error, delay: log.info(f"Elasticsearch retry for {name}: attempt {attempt}"),
372
+ )(func)