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.
- nui_lambda_shared_utils/__init__.py +252 -0
- nui_lambda_shared_utils/base_client.py +323 -0
- nui_lambda_shared_utils/cli.py +225 -0
- nui_lambda_shared_utils/cloudwatch_metrics.py +367 -0
- nui_lambda_shared_utils/config.py +136 -0
- nui_lambda_shared_utils/db_client.py +623 -0
- nui_lambda_shared_utils/error_handler.py +372 -0
- nui_lambda_shared_utils/es_client.py +460 -0
- nui_lambda_shared_utils/es_query_builder.py +315 -0
- nui_lambda_shared_utils/jwt_auth.py +277 -0
- nui_lambda_shared_utils/lambda_helpers.py +84 -0
- nui_lambda_shared_utils/log_processors.py +172 -0
- nui_lambda_shared_utils/powertools_helpers.py +263 -0
- nui_lambda_shared_utils/secrets_helper.py +187 -0
- nui_lambda_shared_utils/slack_client.py +675 -0
- nui_lambda_shared_utils/slack_formatter.py +307 -0
- nui_lambda_shared_utils/slack_setup/__init__.py +14 -0
- nui_lambda_shared_utils/slack_setup/channel_creator.py +295 -0
- nui_lambda_shared_utils/slack_setup/channel_definitions.py +187 -0
- nui_lambda_shared_utils/slack_setup/setup_helpers.py +211 -0
- nui_lambda_shared_utils/timezone.py +117 -0
- nui_lambda_shared_utils/utils.py +291 -0
- nui_python_shared_utils-1.3.0.dist-info/METADATA +470 -0
- nui_python_shared_utils-1.3.0.dist-info/RECORD +28 -0
- nui_python_shared_utils-1.3.0.dist-info/WHEEL +5 -0
- nui_python_shared_utils-1.3.0.dist-info/entry_points.txt +2 -0
- nui_python_shared_utils-1.3.0.dist-info/licenses/LICENSE +21 -0
- nui_python_shared_utils-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|