aws-python-helper 0.23.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.
Files changed (37) hide show
  1. aws_python_helper/__init__.py +45 -0
  2. aws_python_helper/api/__init__.py +11 -0
  3. aws_python_helper/api/auth_middleware.py +108 -0
  4. aws_python_helper/api/auth_validators.py +143 -0
  5. aws_python_helper/api/base.py +272 -0
  6. aws_python_helper/api/dispatcher.py +213 -0
  7. aws_python_helper/api/exceptions.py +43 -0
  8. aws_python_helper/api/fetcher.py +210 -0
  9. aws_python_helper/api/handler.py +106 -0
  10. aws_python_helper/database/__init__.py +11 -0
  11. aws_python_helper/database/database_proxy.py +50 -0
  12. aws_python_helper/database/external_database_proxy.py +66 -0
  13. aws_python_helper/database/external_mongo_manager.py +212 -0
  14. aws_python_helper/database/mongo_manager.py +214 -0
  15. aws_python_helper/fargate/__init__.py +9 -0
  16. aws_python_helper/fargate/executor.py +226 -0
  17. aws_python_helper/fargate/fetcher.py +108 -0
  18. aws_python_helper/fargate/handler.py +101 -0
  19. aws_python_helper/fargate/task_base.py +165 -0
  20. aws_python_helper/lambda_standalone/__init__.py +8 -0
  21. aws_python_helper/lambda_standalone/base.py +171 -0
  22. aws_python_helper/lambda_standalone/fetcher.py +122 -0
  23. aws_python_helper/lambda_standalone/handler.py +117 -0
  24. aws_python_helper/sns/__init__.py +6 -0
  25. aws_python_helper/sns/publisher.py +245 -0
  26. aws_python_helper/sqs/__init__.py +10 -0
  27. aws_python_helper/sqs/consumer_base.py +416 -0
  28. aws_python_helper/sqs/fetcher.py +111 -0
  29. aws_python_helper/sqs/handler.py +138 -0
  30. aws_python_helper/utils/__init__.py +9 -0
  31. aws_python_helper/utils/json_encoder.py +108 -0
  32. aws_python_helper/utils/response.py +145 -0
  33. aws_python_helper/utils/serializer.py +103 -0
  34. aws_python_helper-0.23.0.dist-info/METADATA +712 -0
  35. aws_python_helper-0.23.0.dist-info/RECORD +37 -0
  36. aws_python_helper-0.23.0.dist-info/WHEEL +5 -0
  37. aws_python_helper-0.23.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,416 @@
1
+ """
2
+ SQS Consumer Base - Base class for all SQS consumers
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Dict, Any, List
7
+ import logging
8
+ import json
9
+
10
+ from ..database.mongo_manager import MongoManager
11
+ from ..database.database_proxy import DatabaseProxy
12
+ from ..database.external_mongo_manager import ExternalMongoManager
13
+ from ..database.external_database_proxy import ExternalDatabaseProxy
14
+
15
+
16
+ class SQSConsumer(ABC):
17
+ """
18
+ Base class for all SQS consumers
19
+
20
+ Provides structure to process SQS messages in batch or individually.
21
+
22
+ Processing modes:
23
+ - "single": Process messages one by one using process_record()
24
+ - "batch": Process all messages together using process_batch()
25
+
26
+ Error Handling & Retries:
27
+ Both modes support controlled retry via AWS SQS reportBatchItemFailures:
28
+ - "single" mode: If process_record() raises an exception, that message
29
+ is automatically reported for retry. Other messages continue processing.
30
+ - "batch" mode: Use add_message_failed() to mark failed messages.
31
+ The handler will automatically create reportBatchItemFailures so AWS SQS
32
+ retries only those specific messages. Your _do_process_batch() is automatically
33
+ wrapped with error handling.
34
+
35
+ This ensures that if 1 message fails out of 5, only that 1 message is retried,
36
+ not the entire batch.
37
+
38
+ Usage:
39
+ # Single mode (default):
40
+ class MyConsumer(SQSConsumer):
41
+ @property
42
+ def processing_mode(self):
43
+ return "single" # or omit, "single" is default
44
+
45
+ async def process_record(self, record):
46
+ # Process individual record
47
+ # If you raise an exception here, AWS SQS will retry only this message
48
+ # Other messages in the batch will continue processing normally
49
+ body = self.parse_body(record)
50
+ # Your processing logic here
51
+ # If something fails, just raise an exception:
52
+ # if error_condition:
53
+ # raise ValueError("This message failed, will be retried")
54
+ pass
55
+
56
+ # Batch mode:
57
+ class MyBatchConsumer(SQSConsumer):
58
+ @property
59
+ def processing_mode(self):
60
+ return "batch"
61
+
62
+ async def process_batch(self, records):
63
+ # Process all records together
64
+ # Use add_message_failed() to mark failed messages - no need for try-except!
65
+ # The base class wraps this method with error handling automatically
66
+ for record in records:
67
+ message_id = record.get('messageId')
68
+ try:
69
+ # Your batch processing logic here
70
+ # Process all records together (bulk operations, transactions, etc.)
71
+ await self.process_message(record)
72
+ except Exception as e:
73
+ # Mark as failed - automatically handled by base class
74
+ self.add_message_failed(message_id, str(e))
75
+ # No need to return results - base class handles it automatically
76
+ # But you can return results if you want more control
77
+ """
78
+
79
+ def __init__(self):
80
+ """Initialize the consumer with a logger"""
81
+ self.logger = logging.getLogger(self.__class__.__name__)
82
+ self._db = None
83
+ self._external_db = None
84
+ self._failed_messages = [] # Track failed messages for batch mode
85
+
86
+ @property
87
+ def processing_mode(self) -> str:
88
+ """
89
+ Processing mode: "single" or "batch"
90
+
91
+ - "single": Process messages individually using process_record()
92
+ - "batch": Process all messages together using process_batch()
93
+
94
+ Returns:
95
+ "single" or "batch" (default: "single")
96
+ """
97
+ return "single"
98
+
99
+ @property
100
+ def db(self):
101
+ """
102
+ Access to MongoDB databases (main cluster)
103
+
104
+ Usage:
105
+ result = await self.db.users_db.users.find_one({'_id': user_id})
106
+ """
107
+ if self._db is None:
108
+ self._db = DatabaseProxy(MongoManager)
109
+ return self._db
110
+
111
+ @property
112
+ def external_db(self):
113
+ """
114
+ Access to external MongoDB clusters
115
+
116
+ Returns None if EXTERNAL_MONGODB_CONNECTIONS environment variable is not set.
117
+
118
+ Usage:
119
+ if self.external_db:
120
+ result = await self.external_db.ClusterDockets.smart_data.addresses.find_one({...})
121
+ await self.external_db.ClusterDockets.core.users.insert_one({...})
122
+
123
+ Returns:
124
+ ExternalDatabaseProxy instance for accessing external clusters, or None if not configured
125
+ """
126
+ if self._external_db is None:
127
+ # Initialize external connections if not already done
128
+ if not ExternalMongoManager.is_initialized():
129
+ has_connections = ExternalMongoManager.initialize()
130
+ if not has_connections:
131
+ # No external connections available, return None
132
+ return None
133
+ else:
134
+ # Check if there are any connections available
135
+ if len(ExternalMongoManager.get_available_clusters()) == 0:
136
+ return None
137
+
138
+ self._external_db = ExternalDatabaseProxy()
139
+ return self._external_db
140
+
141
+ async def process_record(self, record: Dict[str, Any]):
142
+ """
143
+ Process an individual SQS record
144
+
145
+ This method is required when processing_mode is "single".
146
+ When processing_mode is "batch", this method is optional.
147
+
148
+ If this method raises an exception, AWS SQS will automatically retry
149
+ only that specific message. Other messages in the batch will continue
150
+ processing normally.
151
+
152
+ Args:
153
+ record: SQS record with 'body', 'messageId', etc.
154
+
155
+ Raises:
156
+ Exception: Any error in the processing. The exception will be caught
157
+ and the message will be reported for retry by AWS SQS.
158
+ """
159
+ # Only raise NotImplementedError if in single mode
160
+ if self.processing_mode == "single":
161
+ raise NotImplementedError(
162
+ f"You must implement process_record() when processing_mode is 'single'"
163
+ )
164
+ # In batch mode, this method is optional
165
+
166
+ def extract_content_message(self, record: Dict[str, Any]) -> Dict[str, Any]:
167
+ """
168
+ Parse the body of the SQS message
169
+
170
+ When SNS sends messages to SQS, the body has this structure:
171
+ {
172
+ "Type": "Notification",
173
+ "Message": "{...the actual message serialized as JSON string...}",
174
+ "MessageAttributes": {...}
175
+ }
176
+
177
+ This method automatically detects SNS messages and extracts the actual content
178
+ from the "Message" field. If it's not an SNS message, it parses the body directly.
179
+
180
+ Args:
181
+ record: SQS record with 'body' field
182
+
183
+ Returns:
184
+ Parsed body as dict. For SNS messages, returns the parsed content from the "Message" field.
185
+ For regular SQS messages, returns the parsed body directly.
186
+ """
187
+ body = record.get('body', '{}')
188
+
189
+ # Parse the body string to JSON if needed
190
+ if isinstance(body, str):
191
+ try:
192
+ body_json = json.loads(body)
193
+ except json.JSONDecodeError:
194
+ self.logger.warning(f"Could not parse body as JSON: {body}")
195
+ return {'raw': body}
196
+ else:
197
+ body_json = body
198
+
199
+ # Check if this is an SNS notification message
200
+ if isinstance(body_json, dict) and body_json.get('Type') == 'Notification':
201
+ # Extract the actual message from the "Message" field
202
+ message_str = body_json.get('Message', '{}')
203
+
204
+ if isinstance(message_str, str):
205
+ try:
206
+ # Parse the JSON string in the Message field
207
+ message_content = json.loads(message_str)
208
+ self.logger.debug(f"Extracted SNS message content: {message_content}")
209
+ return message_content
210
+ except json.JSONDecodeError:
211
+ self.logger.warning(f"Could not parse SNS Message field as JSON: {message_str}")
212
+ return {'raw': message_str, 'sns_notification': True}
213
+ else:
214
+ # Message field is already a dict
215
+ return message_str
216
+
217
+ # Not an SNS message, return the body as-is
218
+ return body_json
219
+
220
+ def add_message_failed(self, message_id: str, error: str = None):
221
+ """
222
+ Mark a message as failed (for batch mode)
223
+
224
+ Use this method in your process_batch() implementation to mark messages
225
+ that failed during batch processing. The handler will automatically
226
+ create reportBatchItemFailures for these messages.
227
+
228
+ Args:
229
+ message_id: The messageId of the failed message
230
+ error: Optional error message describing the failure
231
+
232
+ Usage:
233
+ async def process_batch(self, records):
234
+ for record in records:
235
+ message_id = record.get('messageId')
236
+ try:
237
+ # Your processing logic
238
+ process_message(record)
239
+ except Exception as e:
240
+ # Mark as failed - no need to manually add to results
241
+ self.add_message_failed(message_id, str(e))
242
+ """
243
+ self._failed_messages.append({
244
+ 'messageId': message_id,
245
+ 'itemIdentifier': message_id,
246
+ 'error': error
247
+ })
248
+ if error:
249
+ self.logger.error(f"Message {message_id} marked as failed: {error}")
250
+ else:
251
+ self.logger.error(f"Message {message_id} marked as failed")
252
+
253
+ async def _process_batch_internal(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
254
+ """
255
+ Internal method called by the handler to process a batch of SQS records
256
+
257
+ This method routes to the appropriate processing based on processing_mode:
258
+ - "single": Processes records one by one using process_record()
259
+ - "batch": Wraps the user's process_batch() implementation with error handling
260
+
261
+ Args:
262
+ records: List of SQS records
263
+
264
+ Returns:
265
+ List of results with success/error for each record
266
+ """
267
+ mode = self.processing_mode
268
+
269
+ if mode == "batch":
270
+ # Reset failed messages list for this batch
271
+ self._failed_messages = []
272
+ # Wrap batch processing with error handling
273
+ return await self._process_batch_wrapper(records)
274
+ else:
275
+ # Single mode: process records one by one using process_record()
276
+ return await self._process_batch_single(records)
277
+
278
+ async def process_batch(self, records: List[Dict[str, Any]]) -> None:
279
+ """
280
+ Process a batch of SQS records (to be overridden by user in batch mode)
281
+
282
+ When processing_mode is "batch", override this method to implement
283
+ your batch processing logic. Use add_message_failed() to mark failed messages.
284
+ The base class will wrap your implementation with error handling automatically.
285
+
286
+ This method should not return any value. Simply process the records and use
287
+ add_message_failed() to mark any messages that fail. All other messages will
288
+ be automatically marked as successful.
289
+
290
+ Args:
291
+ records: List of SQS records
292
+
293
+ Example:
294
+ async def process_batch(self, records):
295
+ for record in records:
296
+ message_id = record.get('messageId')
297
+ try:
298
+ # Your processing logic
299
+ await self.process_message(record)
300
+ except Exception as e:
301
+ # Mark as failed - automatically handled
302
+ self.add_message_failed(message_id, str(e))
303
+ """
304
+ # Base implementation - should be overridden by user when processing_mode is "batch"
305
+ # This method is only called when processing_mode is "batch"
306
+ # If not overridden, all messages will be marked as successful
307
+ # (unless marked as failed via add_message_failed)
308
+ if self.processing_mode == "batch":
309
+ raise NotImplementedError(
310
+ f"You must implement process_batch() when processing_mode is 'batch'"
311
+ )
312
+
313
+ async def _process_batch_wrapper(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
314
+ """
315
+ Wrapper for batch mode processing with automatic error handling
316
+
317
+ This method wraps the user's process_batch() implementation with try-except
318
+ to catch any unhandled exceptions. It also merges failed messages marked
319
+ with add_message_failed() into the results.
320
+
321
+ Internal method used when processing_mode is "batch"
322
+ """
323
+ # Get all message IDs upfront
324
+ message_ids = [record.get('messageId', f'record-{i}') for i, record in enumerate(records)]
325
+ results = []
326
+
327
+ try:
328
+ # Call the user's process_batch implementation (no return value expected)
329
+ await self.process_batch(records)
330
+
331
+ # Build results based on _failed_messages
332
+ # All messages are successful by default, except those marked as failed
333
+ for message_id in message_ids:
334
+ # Check if this message was marked as failed
335
+ failed_msg = next((fm for fm in self._failed_messages if fm['messageId'] == message_id), None)
336
+ if failed_msg:
337
+ # Message was marked as failed
338
+ results.append({
339
+ 'messageId': message_id,
340
+ 'success': False,
341
+ 'error': failed_msg.get('error'),
342
+ 'itemIdentifier': failed_msg['itemIdentifier']
343
+ })
344
+ else:
345
+ # Message was successful
346
+ results.append({
347
+ 'messageId': message_id,
348
+ 'success': True
349
+ })
350
+
351
+ except Exception as e:
352
+ # If the entire batch processing fails, mark all messages as failed
353
+ self.logger.exception(f"Unhandled exception in process_batch: {e}")
354
+ for message_id in message_ids:
355
+ results.append({
356
+ 'messageId': message_id,
357
+ 'success': False,
358
+ 'error': str(e),
359
+ 'itemIdentifier': message_id
360
+ })
361
+
362
+ # Log summary
363
+ success_count = sum(1 for r in results if r.get('success', False))
364
+ failed_count = len(results) - success_count
365
+ self.logger.info(
366
+ f"Batch processing complete: {success_count} successful, {failed_count} failed"
367
+ )
368
+
369
+ return results
370
+
371
+
372
+ async def _process_batch_single(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
373
+ """
374
+ Process records one by one (single mode)
375
+
376
+ If a message fails (raises an exception), it will be reported as failed
377
+ and AWS SQS will retry only that message. Other messages continue processing.
378
+
379
+ Internal method used when processing_mode is "single"
380
+ """
381
+ results = []
382
+
383
+ for i, record in enumerate(records):
384
+ message_id = record.get('messageId', f'record-{i}')
385
+
386
+ try:
387
+ self.logger.info(f"Processing message: {message_id}")
388
+ await self.process_record(record)
389
+ results.append({
390
+ 'messageId': message_id,
391
+ 'success': True
392
+ })
393
+ self.logger.info(f"Successfully processed message: {message_id}")
394
+
395
+ except Exception as e:
396
+ # Log the error but continue with other messages
397
+ self.logger.error(
398
+ f"Error processing message {message_id}: {e}",
399
+ exc_info=True
400
+ )
401
+ results.append({
402
+ 'messageId': message_id,
403
+ 'success': False,
404
+ 'error': str(e),
405
+ 'itemIdentifier': message_id # For reportBatchItemFailures (AWS SQS messageId)
406
+ })
407
+
408
+ # Log summary
409
+ success_count = sum(1 for r in results if r['success'])
410
+ failed_count = len(results) - success_count
411
+ self.logger.info(
412
+ f"Batch processing complete: {success_count} successful, {failed_count} failed"
413
+ )
414
+
415
+ return results
416
+
@@ -0,0 +1,111 @@
1
+ """
2
+ SQS Fetcher - Dynamically load consumers based on name
3
+ """
4
+
5
+ import os
6
+ import importlib.util
7
+ from pathlib import Path
8
+
9
+ class SQSFetcher:
10
+ """
11
+ Dynamically load SQS consumers
12
+
13
+ Searches for consumers as direct files in 'src/consumer/' folder.
14
+
15
+ Convention:
16
+ consumer-name -> src/consumer/consumer_name.py -> ConsumerNameConsumer
17
+
18
+ Examples:
19
+ 'user-created' -> src/consumer/user_created.py -> UserCreatedConsumer
20
+ 'title-indexed' -> src/consumer/title_indexed.py -> TitleIndexedConsumer
21
+ """
22
+
23
+ CONSUMERS_FOLDER = "consumer"
24
+ _cache = {}
25
+
26
+ def __init__(self, consumer_name: str):
27
+ """
28
+ Initialize the fetcher
29
+
30
+ Args:
31
+ consumer_name: Name of the consumer in kebab-case (e.g.: 'user-created')
32
+ """
33
+ self.consumer_name = consumer_name
34
+
35
+ @property
36
+ def file_path(self) -> str:
37
+ """
38
+ Calculate the path of the consumer file
39
+
40
+ Converts 'user-created' to 'user_created.py'
41
+
42
+ Returns:
43
+ Absolute path to the consumer file
44
+ """
45
+ # Convert dashes to underscores for Python file name
46
+ file_name = self.consumer_name.replace('-', '_') + '_consumer.py'
47
+
48
+ base_path = Path(os.getcwd()) / self.CONSUMERS_FOLDER
49
+ file_path = base_path / file_name
50
+
51
+ return str(file_path)
52
+
53
+ def get_consumer(self):
54
+ """
55
+ Load and return an instance of the consumer
56
+
57
+ Returns:
58
+ Instance of the consumer
59
+
60
+ Raises:
61
+ FileNotFoundError: If the file does not exist
62
+ ValueError: If the consumer class is not valid
63
+ """
64
+ file_path = self.file_path
65
+
66
+ # Verify cache
67
+ if file_path in self._cache:
68
+ return self._cache[file_path]()
69
+
70
+ # Verify that the file exists
71
+ if not os.path.exists(file_path):
72
+ raise FileNotFoundError(
73
+ f"Consumer not found: {file_path}\n"
74
+ f"Expected file for consumer '{self.consumer_name}' at {file_path}"
75
+ )
76
+
77
+ # Load module dynamically
78
+ spec = importlib.util.spec_from_file_location("consumer_module", file_path)
79
+ if not spec or not spec.loader:
80
+ raise ImportError(f"Could not load module spec from: {file_path}")
81
+
82
+ module = importlib.util.module_from_spec(spec)
83
+ spec.loader.exec_module(module)
84
+
85
+ # Search for class that inherits from SQSConsumer
86
+ # Expected class name: 'user-created' -> 'UserCreatedConsumer'
87
+ class_name = ''.join(
88
+ word.capitalize() for word in self.consumer_name.split('-')
89
+ ) + 'Consumer'
90
+
91
+ consumer_class = None
92
+ for item_name in dir(module):
93
+ item = getattr(module, item_name)
94
+ if (isinstance(item, type) and
95
+ hasattr(item, 'process_record') and
96
+ item.__name__ not in ['SQSConsumer', 'ABC']):
97
+ consumer_class = item
98
+ break
99
+
100
+ if not consumer_class:
101
+ raise ValueError(
102
+ f"No SQSConsumer class found in {file_path}\n"
103
+ f"Make sure your file exports a class that inherits from SQSConsumer\n"
104
+ f"Expected class name: {class_name}"
105
+ )
106
+
107
+ # Cache the class
108
+ self._cache[file_path] = consumer_class
109
+
110
+ # Return new instance
111
+ return consumer_class()
@@ -0,0 +1,138 @@
1
+ """
2
+ SQS Handler - Generic reusable handler for SQS consumers
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import sys
8
+ from typing import Dict, Any, Callable
9
+
10
+ from .fetcher import SQSFetcher
11
+ from ..utils.serializer import serialize_mongo_types
12
+ from ..database.mongo_manager import MongoManager
13
+
14
+ def setup_logging():
15
+ root_logger = logging.getLogger()
16
+ root_logger.setLevel(logging.INFO)
17
+
18
+ # Solo configurar si no tiene handlers
19
+ if not root_logger.handlers:
20
+ handler = logging.StreamHandler(sys.stderr)
21
+ handler.setFormatter(
22
+ logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
23
+ )
24
+ root_logger.addHandler(handler)
25
+ else:
26
+ # Si ya tiene handlers, solo actualizar el nivel
27
+ for handler in root_logger.handlers:
28
+ handler.setLevel(logging.INFO)
29
+
30
+ # Llamar al inicio del módulo
31
+ setup_logging()
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ def sqs_handler(consumer_name: str) -> Callable:
36
+ """
37
+ Factory that returns a handler for a specific consumer
38
+
39
+ This pattern allows creating specific handlers for each consumer
40
+ while keeping the code DRY.
41
+
42
+ Args:
43
+ consumer_name: Name of the consumer (must exist in consumer/)
44
+
45
+ Returns:
46
+ Configured handler function for that consumer
47
+ """
48
+
49
+ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
50
+ """
51
+ Generic handler for AWS Lambda SQS
52
+
53
+ Args:
54
+ event: SQS event with Records
55
+ context: Lambda context
56
+
57
+ Returns:
58
+ Summary of the processing
59
+ """
60
+
61
+ # Initialize MongoDB connection (only once, reused in subsequent invocations)
62
+ try:
63
+ if not MongoManager.is_initialized():
64
+ MongoManager.initialize()
65
+ except Exception as e:
66
+ logger.warning(f"MongoDB initialization skipped: {e}")
67
+
68
+ try:
69
+ # Load consumer
70
+ fetcher = SQSFetcher(consumer_name)
71
+ consumer = fetcher.get_consumer()
72
+
73
+ # Get records
74
+ records = event.get('Records', [])
75
+
76
+ # Process batch
77
+ # Use get_event_loop() instead of asyncio.run() to avoid closing the loop
78
+ # This is important for AWS Lambda container reuse with Motor/MongoDB
79
+ try:
80
+ loop = asyncio.get_event_loop()
81
+ if loop.is_closed():
82
+ loop = asyncio.new_event_loop()
83
+ asyncio.set_event_loop(loop)
84
+ except RuntimeError:
85
+ loop = asyncio.new_event_loop()
86
+ asyncio.set_event_loop(loop)
87
+
88
+ results = loop.run_until_complete(consumer._process_batch_internal(records))
89
+
90
+ # Count successes and failures
91
+ success_count = sum(1 for r in results if r['success'])
92
+ error_count = len(results) - success_count
93
+
94
+ # Get failed message IDs for reportBatchItemFailures
95
+ # This allows AWS SQS to retry only failed messages, not the entire batch
96
+ failed_message_ids = [
97
+ r.get('itemIdentifier') or r.get('messageId')
98
+ for r in results
99
+ if not r.get('success', True)
100
+ ]
101
+
102
+ response = {
103
+ 'processed': len(results),
104
+ 'successful': success_count,
105
+ 'failed': error_count,
106
+ 'results': results # Include detailed results
107
+ }
108
+
109
+ # If there are failures, add reportBatchItemFailures for partial batch failure reporting
110
+ # This tells AWS SQS to only retry the failed messages
111
+ if failed_message_ids:
112
+ response['batchItemFailures'] = [
113
+ {'itemIdentifier': msg_id} for msg_id in failed_message_ids
114
+ ]
115
+ logger.warning(
116
+ f"Processing complete with {error_count} failures. "
117
+ f"Failed messages will be retried: {failed_message_ids}"
118
+ )
119
+
120
+ # Serialize MongoDB types to JSON-serializable types
121
+ # AWS Lambda will serialize the return value to JSON, so we need to ensure
122
+ # all MongoDB types (ObjectId, datetime, etc.) are converted first
123
+ response = serialize_mongo_types(response)
124
+
125
+ return response
126
+
127
+ except Exception as e:
128
+ logger.exception(f"Unhandled exception in SQS handler: {e}")
129
+
130
+ return {
131
+ 'processed': 0,
132
+ 'successful': 0,
133
+ 'failed': len(event.get('Records', [])),
134
+ 'error': str(e)
135
+ }
136
+
137
+ return handler
138
+
@@ -0,0 +1,9 @@
1
+ """
2
+ Utils Module - Utilities for the framework
3
+ """
4
+
5
+ from .response import ApiResponse
6
+ from .json_encoder import MongoJSONEncoder, mongo_json_dumps
7
+ from .serializer import serialize_mongo_types
8
+
9
+ __all__ = ['MongoJSONEncoder', 'ApiResponse', 'mongo_json_dumps', 'serialize_mongo_types']