async_sqs_consumer 1.1.0__tar.gz

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,132 @@
1
+ Metadata-Version: 2.3
2
+ Name: async_sqs_consumer
3
+ Version: 1.1.0
4
+ Summary:
5
+ Keywords: AWS,AWS,SQS,sqs,consumer,async,worker
6
+ Author: Diego Restrepo
7
+ Author-email: Diego Restrepo <drestrepo@fluidattacks.com>
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: aioboto3>=15.5.0,<16
15
+ Requires-Dist: jsonschema>=4.26.0,<5
16
+ Requires-Dist: pyrate-limiter>=4.0.2,<5
17
+ Requires-Python: >=3.10, <4
18
+ Project-URL: Repository, https://github.com/drestrepom/async_sqs_consumer
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Async SQS consumer
22
+
23
+ Python asynchronous (**async** / **await**) worker for consuming messages
24
+ from AWS SQS.
25
+
26
+ This is a hobby project, if you find the project interesting
27
+ any contribution is welcome.
28
+
29
+ ## Usage
30
+
31
+ You must create an instance of the worker with the url of the queue.
32
+
33
+ Aws credentials are taken from environment variables, you must set the
34
+ following environment variables. Or you can provide a Context object with the
35
+ aws credentials `async_sqs_consumer.types.Context`
36
+
37
+ - `AWS_ACCESS_KEY_ID`
38
+ - `AWS_SECRET_ACCESS_KEY`
39
+
40
+ Example:
41
+
42
+ You can get the queue url with the follow aws cli command
43
+ `aws sqs get-queue-url --queue-name xxxxxx`
44
+
45
+ ```python
46
+ # test_worker.py
47
+
48
+ from async_sqs_consumer.worker import (
49
+ Worker,
50
+ )
51
+
52
+ worker = Worker(
53
+ queue_url="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name"
54
+ )
55
+
56
+
57
+ @worker.task("report")
58
+ async def report(text: str) -> None:
59
+ print(text)
60
+
61
+ if __name__: "__main__":
62
+ worker.start()
63
+ ```
64
+
65
+ Now you can initialize the worker `python test_worker.py`
66
+
67
+ Now you need to post a message for the worker to process
68
+
69
+ ```python
70
+ import json
71
+ import boto3
72
+ import uuid
73
+
74
+ client = boto3.client("sqs")
75
+
76
+ client.send_message(
77
+ QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
78
+ MessageBody=json.dumps(
79
+ {
80
+ "task": "report",
81
+ "id": uuid.uuid4().hex,
82
+ "args": ["hello world"],
83
+ }
84
+ ),
85
+ )
86
+ ```
87
+
88
+ Or you can use aioboto3
89
+
90
+ ```python
91
+ import asyncio
92
+ import json
93
+ import aioboto3
94
+ import uuid
95
+
96
+
97
+ async def main() -> None:
98
+ session = aioboto3.Session()
99
+ async with session.client("sqs") as client:
100
+ await client.send_message(
101
+ QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
102
+ MessageBody=json.dumps(
103
+ {
104
+ "task": "report",
105
+ "id": uuid.uuid4().hex,
106
+ "args": ["hello world"],
107
+ }
108
+ ),
109
+ )
110
+
111
+
112
+ if __name__ == "__main__":
113
+ asyncio.run(main())
114
+ ```
115
+
116
+ To publish the messages they must have the following structure
117
+
118
+ ```json
119
+ {
120
+ "type": "object",
121
+ "properties": {
122
+ "task": {"type": "string"},
123
+ "id": {"type": "string"},
124
+ "args": {"type": "array"},
125
+ "kwargs": {"type": "object"},
126
+ "retries": {"type": "number"},
127
+ "eta": {"type": "string"},
128
+ "expires": {"type": "string"},
129
+ },
130
+ "required": ["task", "id"],
131
+ }
132
+ ```
@@ -0,0 +1,112 @@
1
+ # Async SQS consumer
2
+
3
+ Python asynchronous (**async** / **await**) worker for consuming messages
4
+ from AWS SQS.
5
+
6
+ This is a hobby project, if you find the project interesting
7
+ any contribution is welcome.
8
+
9
+ ## Usage
10
+
11
+ You must create an instance of the worker with the url of the queue.
12
+
13
+ Aws credentials are taken from environment variables, you must set the
14
+ following environment variables. Or you can provide a Context object with the
15
+ aws credentials `async_sqs_consumer.types.Context`
16
+
17
+ - `AWS_ACCESS_KEY_ID`
18
+ - `AWS_SECRET_ACCESS_KEY`
19
+
20
+ Example:
21
+
22
+ You can get the queue url with the follow aws cli command
23
+ `aws sqs get-queue-url --queue-name xxxxxx`
24
+
25
+ ```python
26
+ # test_worker.py
27
+
28
+ from async_sqs_consumer.worker import (
29
+ Worker,
30
+ )
31
+
32
+ worker = Worker(
33
+ queue_url="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name"
34
+ )
35
+
36
+
37
+ @worker.task("report")
38
+ async def report(text: str) -> None:
39
+ print(text)
40
+
41
+ if __name__: "__main__":
42
+ worker.start()
43
+ ```
44
+
45
+ Now you can initialize the worker `python test_worker.py`
46
+
47
+ Now you need to post a message for the worker to process
48
+
49
+ ```python
50
+ import json
51
+ import boto3
52
+ import uuid
53
+
54
+ client = boto3.client("sqs")
55
+
56
+ client.send_message(
57
+ QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
58
+ MessageBody=json.dumps(
59
+ {
60
+ "task": "report",
61
+ "id": uuid.uuid4().hex,
62
+ "args": ["hello world"],
63
+ }
64
+ ),
65
+ )
66
+ ```
67
+
68
+ Or you can use aioboto3
69
+
70
+ ```python
71
+ import asyncio
72
+ import json
73
+ import aioboto3
74
+ import uuid
75
+
76
+
77
+ async def main() -> None:
78
+ session = aioboto3.Session()
79
+ async with session.client("sqs") as client:
80
+ await client.send_message(
81
+ QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
82
+ MessageBody=json.dumps(
83
+ {
84
+ "task": "report",
85
+ "id": uuid.uuid4().hex,
86
+ "args": ["hello world"],
87
+ }
88
+ ),
89
+ )
90
+
91
+
92
+ if __name__ == "__main__":
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ To publish the messages they must have the following structure
97
+
98
+ ```json
99
+ {
100
+ "type": "object",
101
+ "properties": {
102
+ "task": {"type": "string"},
103
+ "id": {"type": "string"},
104
+ "args": {"type": "array"},
105
+ "kwargs": {"type": "object"},
106
+ "retries": {"type": "number"},
107
+ "eta": {"type": "string"},
108
+ "expires": {"type": "string"},
109
+ },
110
+ "required": ["task", "id"],
111
+ }
112
+ ```
File without changes
@@ -0,0 +1,221 @@
1
+ from .resources import (
2
+ get_sqs_client,
3
+ RESOURCE_OPTIONS_SQS,
4
+ SESSION,
5
+ )
6
+ from .types import (
7
+ AwsCredentials,
8
+ )
9
+ from .utils import (
10
+ TASK_NAME_PREFIX,
11
+ )
12
+ from .utils.retry import (
13
+ retry,
14
+ )
15
+ from aiohttp.client_exceptions import (
16
+ ClientConnectorError,
17
+ ClientPayloadError,
18
+ ServerDisconnectedError,
19
+ ServerTimeoutError,
20
+ )
21
+ import asyncio
22
+ from asyncio import (
23
+ get_event_loop,
24
+ sleep,
25
+ )
26
+ from mypy_boto3_sqs.client import (
27
+ SQSClient,
28
+ )
29
+ from botocore.exceptions import (
30
+ ClientError,
31
+ ConnectTimeoutError,
32
+ HTTPClientError,
33
+ ReadTimeoutError,
34
+ )
35
+ from contextlib import (
36
+ suppress,
37
+ )
38
+ import logging
39
+ from typing import (
40
+ Any,
41
+ Callable,
42
+ Coroutine,
43
+ )
44
+
45
+ LOGGER = logging.getLogger(__name__)
46
+ NETWORK_ERRORS = (
47
+ asyncio.TimeoutError,
48
+ ClientConnectorError,
49
+ ClientError,
50
+ ClientPayloadError,
51
+ ConnectionResetError,
52
+ ConnectTimeoutError,
53
+ HTTPClientError,
54
+ ReadTimeoutError,
55
+ ServerDisconnectedError,
56
+ ServerTimeoutError,
57
+ )
58
+
59
+
60
+ def set_max_number_of_messages(max_number_of_messages: int | None) -> int:
61
+ max_number_of_messages = max_number_of_messages or 10
62
+ return min(10, max_number_of_messages)
63
+
64
+
65
+ @retry(exceptions=NETWORK_ERRORS, tries=3, delay=1)
66
+ async def get_queue_messages(
67
+ *,
68
+ queue_url: str,
69
+ credentials: AwsCredentials | None = None,
70
+ client: SQSClient | None = None,
71
+ visibility_timeout: int | None = None,
72
+ wait_time_seconds: int | None = None,
73
+ max_number_of_messages: int | None = None,
74
+ ) -> list[dict[str, object]]:
75
+ client = client or await get_sqs_client(credentials)
76
+ max_number_of_messages = set_max_number_of_messages(max_number_of_messages)
77
+ try:
78
+ response = await client.receive_message( # type: ignore[misc]
79
+ QueueUrl=queue_url,
80
+ MaxNumberOfMessages=max_number_of_messages,
81
+ VisibilityTimeout=visibility_timeout or 60,
82
+ WaitTimeSeconds=wait_time_seconds or 0,
83
+ )
84
+ except Exception as exc: # pylint: disable=broad-except
85
+ LOGGER.error(
86
+ "There was an error with the response",
87
+ extra={
88
+ "extra": {
89
+ "client": client,
90
+ "queue_url": queue_url,
91
+ "max_number_of_messages": max_number_of_messages,
92
+ "exc": exc,
93
+ }
94
+ },
95
+ )
96
+
97
+ return response.get("Messages", [])
98
+
99
+
100
+ @retry(exceptions=NETWORK_ERRORS, tries=3, delay=1)
101
+ async def delete_messages(
102
+ queue_url: str,
103
+ receipt_handle: dict[str, object],
104
+ credentials: AwsCredentials | None = None,
105
+ ) -> None:
106
+ try:
107
+ client = await get_sqs_client(credentials)
108
+ await client.delete_message(
109
+ ReceiptHandle=receipt_handle,
110
+ QueueUrl=queue_url,
111
+ )
112
+ except NETWORK_ERRORS as exc:
113
+ LOGGER.exception(
114
+ "There was an error deleting messages",
115
+ extra={
116
+ "extra": {
117
+ "receipt_handle": receipt_handle,
118
+ "queue_url": queue_url,
119
+ "exc": exc,
120
+ }
121
+ },
122
+ )
123
+
124
+
125
+ class Queue: # pylint: disable=too-many-instance-attributes
126
+ def __init__( # pylint: disable=too-many-arguments
127
+ self,
128
+ url: str,
129
+ priority: int | None = None,
130
+ authentication: AwsCredentials | None = None,
131
+ polling_interval: float | None = None,
132
+ visibility_timeout: int | None = None,
133
+ max_queue_parallel_messages: int | None = None,
134
+ enabled: bool | None = None,
135
+ ) -> None:
136
+ self.url = url
137
+ self.priority = priority or 1
138
+ self.authentication = authentication
139
+ self.polling_interval = polling_interval or 1.0
140
+ self.visibility_timeout = visibility_timeout or 60
141
+ self._max_queue_parallel_messages = max_queue_parallel_messages
142
+ self._polling = False
143
+ self.enabled = True if enabled is None else enabled
144
+
145
+ async def get_messages(
146
+ self, sqs_client: object, max_number_of_messages: int | None = None
147
+ ) -> list[dict[str, object]]:
148
+ with suppress(asyncio.CancelledError):
149
+ messages = await get_queue_messages(
150
+ queue_url=self.url,
151
+ client=sqs_client,
152
+ visibility_timeout=self.visibility_timeout,
153
+ max_number_of_messages=max_number_of_messages,
154
+ )
155
+ return messages
156
+ return []
157
+
158
+ def is_task_limit_exceeded(
159
+ self, max_parallel_messages: int | None
160
+ ) -> bool:
161
+ number_of_tasks = len(
162
+ [
163
+ task
164
+ for task in asyncio.all_tasks(get_event_loop())
165
+ if task.get_name().startswith(TASK_NAME_PREFIX)
166
+ ]
167
+ )
168
+ return number_of_tasks > (
169
+ self._max_queue_parallel_messages or max_parallel_messages or 1024
170
+ )
171
+
172
+ async def start_polling(
173
+ self,
174
+ callback: Callable[
175
+ [dict[str, object], str], Coroutine[object, object, None]
176
+ ],
177
+ queue_alias: str,
178
+ max_parallel_messages: int | None = None,
179
+ ) -> None:
180
+ self._polling = self._polling or True
181
+ async with SESSION.client(**RESOURCE_OPTIONS_SQS) as sqs_client:
182
+ while self._polling:
183
+ if self.is_task_limit_exceeded(max_parallel_messages):
184
+ await sleep(self.polling_interval)
185
+ continue
186
+
187
+ with suppress(asyncio.CancelledError):
188
+ messages = await get_queue_messages(
189
+ queue_url=self.url,
190
+ client=sqs_client,
191
+ visibility_timeout=self.visibility_timeout,
192
+ )
193
+ await asyncio.gather(
194
+ *[
195
+ callback(message, queue_alias)
196
+ for message in messages
197
+ ]
198
+ )
199
+ await sleep(self.polling_interval)
200
+
201
+ def stop_polling(self) -> None:
202
+ self._polling = False
203
+
204
+ async def delete_messages(
205
+ self,
206
+ receipt_handle: dict[str, Any],
207
+ ) -> None:
208
+ async with SESSION.client(**RESOURCE_OPTIONS_SQS) as sqs_client:
209
+ await sqs_client.delete_message(
210
+ ReceiptHandle=receipt_handle,
211
+ QueueUrl=self.url,
212
+ )
213
+ LOGGER.info(
214
+ "Deleted messages parameters",
215
+ extra={
216
+ "extra": {
217
+ "sqs_client": self.url,
218
+ "receipt_handle": receipt_handle,
219
+ }
220
+ },
221
+ )
@@ -0,0 +1,72 @@
1
+ from .types import (
2
+ AwsCredentials,
3
+ )
4
+ import aioboto3 # type: ignore[import-untyped]
5
+ from aiobotocore.config import ( # type: ignore[import-untyped]
6
+ AioConfig,
7
+ )
8
+ from contextlib import (
9
+ AsyncExitStack,
10
+ )
11
+ from typing import (
12
+ Any,
13
+ )
14
+
15
+ RESOURCE_OPTIONS_SQS = {
16
+ "aws_access_key_id": (None),
17
+ "aws_secret_access_key": (None),
18
+ "config": AioConfig(
19
+ # The time in seconds till a timeout exception is thrown when
20
+ # attempting to make a connection. [60]
21
+ connect_timeout=15,
22
+ # Maximum amount of simultaneously opened connections. [10]
23
+ # https://docs.aiohttp.org/en/stable/client_advanced.html#limiting-connection-pool-size
24
+ max_pool_connections=2000,
25
+ # The time in seconds till a timeout exception is thrown when
26
+ # attempting to read from a connection. [60]
27
+ read_timeout=30,
28
+ # https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html
29
+ retries={"max_attempts": 10, "mode": "standard"},
30
+ # Signature version for signing URLs
31
+ # https://boto3.amazonaws.com/v1/documentation/api/1.9.42/guide/s3.html#generating-presigned-urls
32
+ signature_version="s3v4",
33
+ ),
34
+ "endpoint_url": None,
35
+ "region_name": "us-east-1",
36
+ "service_name": "sqs",
37
+ "use_ssl": True,
38
+ "verify": True,
39
+ }
40
+
41
+ SESSION = aioboto3.Session()
42
+ CONTEXT_STACK_SQS = None
43
+ RESOURCE_SQS = None
44
+
45
+
46
+ async def sqs_startup(credentials: AwsCredentials | None = None) -> None:
47
+ # pylint: disable=global-statement
48
+ global CONTEXT_STACK_SQS, RESOURCE_SQS
49
+
50
+ CONTEXT_STACK_SQS = AsyncExitStack()
51
+ if credentials:
52
+ RESOURCE_OPTIONS_SQS["aws_access_key_id"] = credentials.access_key_id
53
+ RESOURCE_OPTIONS_SQS[
54
+ "aws_secret_access_key"
55
+ ] = credentials.secret_access_key
56
+ RESOURCE_OPTIONS_SQS["aws_session_token"] = credentials.session_token
57
+
58
+ RESOURCE_SQS = await CONTEXT_STACK_SQS.enter_async_context(
59
+ SESSION.client(**RESOURCE_OPTIONS_SQS)
60
+ )
61
+
62
+
63
+ async def sqs_shutdown() -> None:
64
+ if CONTEXT_STACK_SQS:
65
+ await CONTEXT_STACK_SQS.aclose()
66
+
67
+
68
+ async def get_sqs_client(credentials: AwsCredentials | None = None) -> Any:
69
+ if RESOURCE_SQS is None:
70
+ await sqs_startup(credentials)
71
+
72
+ return RESOURCE_SQS
@@ -0,0 +1,56 @@
1
+ from enum import (
2
+ Enum,
3
+ )
4
+ from typing import (
5
+ NamedTuple,
6
+ )
7
+
8
+ MESSAGE_SCHEMA = {
9
+ "type": "object",
10
+ "properties": {
11
+ "task": {"type": "string"},
12
+ "id": {"type": "string"},
13
+ "args": {"type": "array"},
14
+ "kwargs": {"type": "object"},
15
+ "retries": {"type": "number"},
16
+ "eta": {"type": "string"},
17
+ "expires": {"type": "string"},
18
+ },
19
+ "required": ["task", "id"],
20
+ }
21
+ SNS_NOTIFICATION_SCHEMA = {
22
+ "type": "object",
23
+ "properties": {
24
+ "Type": {"type": "string"},
25
+ "MessageId": {"type": "string"},
26
+ "TopicArn": {"type": "string"},
27
+ "Message": {"type": "string"},
28
+ "Timestamp": {"type": "string"},
29
+ "SignatureVersion": {"type": "string"},
30
+ "Signature": {"type": "string"},
31
+ "SigningCertURL": {"type": "string"},
32
+ "UnsubscribeURL": {"type": "string"},
33
+ },
34
+ "required": ["Type", "Message", "Timestamp"],
35
+ }
36
+
37
+
38
+ class AwsCredentials(NamedTuple):
39
+ access_key_id: str
40
+ secret_access_key: str
41
+ session_token: str | None = None
42
+
43
+
44
+ class MessageType(Enum):
45
+ CUSTOM = "CUSTOM"
46
+ SNS_NOTIFICATION = "SNS_NOTIFICATION"
47
+
48
+
49
+ class SqsOptions(NamedTuple):
50
+ visibility_timeout: int | None = None
51
+
52
+
53
+ class Context(NamedTuple):
54
+ queue_url: str
55
+ aws_credentials: AwsCredentials | None = None
56
+ sqs: SqsOptions | None = None
@@ -0,0 +1,47 @@
1
+ from ..types import (
2
+ MESSAGE_SCHEMA,
3
+ MessageType,
4
+ SNS_NOTIFICATION_SCHEMA,
5
+ )
6
+ from contextlib import (
7
+ suppress,
8
+ )
9
+ from jsonschema import (
10
+ validate,
11
+ )
12
+ from jsonschema.exceptions import (
13
+ ValidationError,
14
+ )
15
+
16
+ TASK_NAME_PREFIX = "task_queue"
17
+
18
+
19
+ class FailedValidation(Exception):
20
+ pass
21
+
22
+
23
+ def validate_message(
24
+ message: dict[str, object], message_type: MessageType
25
+ ) -> bool:
26
+ with suppress(ValidationError):
27
+ if message_type == MessageType.CUSTOM:
28
+ validate(message, MESSAGE_SCHEMA)
29
+ else:
30
+ validate(message, SNS_NOTIFICATION_SCHEMA)
31
+ return True
32
+ return False
33
+
34
+
35
+ def inverse_proportional_distribution(
36
+ amount: int, priorities: list[int]
37
+ ) -> list[float]:
38
+ inv_priorities = [1 / p for p in priorities]
39
+ total_inv_priority = sum(inv_priorities)
40
+ inv_proportions = [inv_p / total_inv_priority for inv_p in inv_priorities]
41
+ amounts = [amount * p for p in inv_proportions]
42
+ total_amounts = sum(amounts)
43
+ amounts = [
44
+ int(round(a + (amount - total_amounts) / len(amounts)))
45
+ for a in amounts
46
+ ]
47
+ return amounts
@@ -0,0 +1,152 @@
1
+ # pylint: disable=broad-except
2
+ # pylint: disable=too-many-arguments
3
+ import asyncio
4
+ import functools
5
+ from functools import (
6
+ partial,
7
+ )
8
+ import logging
9
+ import random
10
+ import traceback
11
+ from typing import (
12
+ Any,
13
+ Callable,
14
+ Literal,
15
+ Type,
16
+ )
17
+
18
+ logging_logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _log_exception(
22
+ func: partial,
23
+ exc: Exception,
24
+ logger: logging.Logger | None = None,
25
+ log_traceback: bool = False,
26
+ _delay: float | int | None = None,
27
+ ) -> None:
28
+ if logger is not None:
29
+ try:
30
+ func_qualname = func.func.__qualname__
31
+ except AttributeError:
32
+ func_qualname = str(func.func)
33
+ logger.warning(
34
+ "%s: %s in %s.%s, retrying in %s seconds...",
35
+ exc.__class__.__qualname__,
36
+ exc,
37
+ func.func.__module__,
38
+ func_qualname,
39
+ _delay,
40
+ )
41
+ if log_traceback:
42
+ logger.warning(traceback.format_exc())
43
+
44
+
45
+ def _increase_delay(
46
+ _delay: float,
47
+ backoff: float | None = None,
48
+ jitter: Literal[1] | Literal[0] = 0,
49
+ max_delay: float | None = None,
50
+ ) -> float:
51
+ _delay *= backoff or 1
52
+
53
+ if isinstance(jitter, tuple):
54
+ _delay += random.uniform(*jitter)
55
+ else:
56
+ _delay += jitter
57
+
58
+ if max_delay is not None:
59
+ _delay = min(_delay, max_delay)
60
+
61
+ return _delay
62
+
63
+
64
+ async def __retry_internal(
65
+ func: partial,
66
+ tries: int,
67
+ exceptions: Type[Exception] | tuple[type[Exception], ...] | None = None,
68
+ delay: float | None = None,
69
+ max_delay: float | None = None,
70
+ backoff: float | None = None,
71
+ jitter: Literal[1] | Literal[0] = 0,
72
+ logger: logging.Logger | None = None,
73
+ log_traceback: bool = False,
74
+ on_exception: Callable[[Exception], bool] | None = None,
75
+ ) -> None:
76
+ exceptions = exceptions or Exception
77
+ _tries, _delay = tries, (delay or 0.0)
78
+ logger = logger or logging_logger
79
+ while _tries >= 0:
80
+ try:
81
+ return await func()
82
+ except exceptions as exc:
83
+ if on_exception is not None and on_exception(exc):
84
+ break
85
+
86
+ _tries -= 1
87
+ _log_exception(func, exc, logger, log_traceback, _delay)
88
+
89
+ await asyncio.sleep(_delay)
90
+ _delay = _increase_delay(_delay, backoff, jitter, max_delay)
91
+
92
+
93
+ def retry( # pylint: disable=too-many-arguments
94
+ tries: int,
95
+ exceptions: Type[Exception] | tuple[type[Exception], ...] = Exception,
96
+ delay: float | None = None,
97
+ max_delay: float | None = None,
98
+ backoff: float | None = None,
99
+ jitter: Literal[1] | Literal[0] = 0,
100
+ logger: logging.Logger | None = None,
101
+ log_traceback: bool = False,
102
+ on_exception: Callable[[Exception], bool] | None = None,
103
+ ) -> Callable[[Callable], Callable]:
104
+ def decorator(func: Callable[..., str]) -> Any:
105
+ @functools.wraps(func)
106
+ async def wrapper(
107
+ *fargs: list[str], **fkwargs: dict[str, str]
108
+ ) -> None:
109
+ args = fargs if fargs else []
110
+ kwargs = fkwargs if fkwargs else {}
111
+ return await __retry_internal(
112
+ partial(func, *args, **kwargs),
113
+ tries,
114
+ exceptions,
115
+ delay,
116
+ max_delay,
117
+ backoff,
118
+ jitter,
119
+ logger,
120
+ log_traceback,
121
+ on_exception,
122
+ )
123
+
124
+ return wrapper
125
+
126
+ return decorator
127
+
128
+
129
+ async def retry_call( # pylint: disable=too-many-arguments
130
+ func: partial | Callable[..., object],
131
+ tries: int,
132
+ exceptions: Type[Exception] | tuple[type[Exception], ...] = Exception,
133
+ delay: float | None = None,
134
+ max_delay: float | None = None,
135
+ backoff: float | None = None,
136
+ jitter: Literal[1] | Literal[0] = 0,
137
+ logger: logging.Logger | None = None,
138
+ fargs: tuple[object, ...] | None = None,
139
+ fkwargs: dict[str, object] | None = None,
140
+ ) -> None:
141
+ args = fargs if fargs else tuple()
142
+ kwargs = fkwargs if fkwargs else {}
143
+ return await __retry_internal(
144
+ partial(func, *args, **kwargs),
145
+ tries,
146
+ exceptions,
147
+ delay,
148
+ max_delay,
149
+ backoff,
150
+ jitter,
151
+ logger,
152
+ )
@@ -0,0 +1,377 @@
1
+ from .queue import (
2
+ Queue,
3
+ )
4
+ from .resources import (
5
+ RESOURCE_OPTIONS_SQS,
6
+ SESSION,
7
+ )
8
+ from .types import (
9
+ MessageType,
10
+ )
11
+ from .utils import (
12
+ inverse_proportional_distribution,
13
+ TASK_NAME_PREFIX,
14
+ validate_message,
15
+ )
16
+ from .utils.retry import (
17
+ retry_call,
18
+ )
19
+ import asyncio
20
+ from contextlib import (
21
+ suppress,
22
+ )
23
+ from datetime import (
24
+ datetime,
25
+ timezone,
26
+ )
27
+ from functools import (
28
+ partial,
29
+ )
30
+ import json
31
+ import logging
32
+ import math
33
+ import signal
34
+ import sys
35
+ from typing import (
36
+ Any,
37
+ Callable,
38
+ Literal,
39
+ )
40
+ from uuid import (
41
+ uuid4,
42
+ )
43
+
44
+ logging.basicConfig(level=logging.INFO)
45
+
46
+ LOGGER = logging.getLogger(__name__)
47
+
48
+
49
+ class Worker:
50
+ def __init__(
51
+ self, queues: dict[str, Queue], max_workers: int | None = None
52
+ ) -> None:
53
+ self.queues = queues
54
+ self.running = False
55
+ self.handlers_by_queue: dict[
56
+ str, dict[str, Callable[..., object]]
57
+ ] = {}
58
+ self._events: dict[str, list[Callable[[], None]]] = {}
59
+ self._loop: asyncio.AbstractEventLoop | None = None
60
+ self._tasks: list[asyncio.Task] = []
61
+ self._max_workers = max_workers or 1024
62
+
63
+ def _sigint_handler(self, *_args: object) -> None:
64
+ sys.stdout.write("\b\b\r")
65
+ sys.stdout.flush()
66
+ logging.getLogger("system").warning(
67
+ "Received <ctrl+c> interrupt [SIGINT]"
68
+ )
69
+ self._stop_worker()
70
+
71
+ def _sigterm_handler(self, *_args: object) -> None:
72
+ logging.getLogger("system").warning(
73
+ "Received termination signal [SIGTERM]"
74
+ )
75
+ self._stop_worker()
76
+
77
+ def _stop_worker(self, *_: object) -> None:
78
+ if self._loop:
79
+ LOGGER.debug("Stop worker")
80
+ self.running = False
81
+ for queue in self.queues.values():
82
+ queue.stop_polling()
83
+ for event in self._events.get("shutdown", []):
84
+ self._loop.run_until_complete(event()) # type: ignore
85
+
86
+ map(lambda task: task.cancel(), self._tasks)
87
+
88
+ # make sure all tasks are finished before finishing the
89
+ # entire process
90
+ self._loop.stop()
91
+
92
+ def task(
93
+ self, name: str, queue_name: str | None = None
94
+ ) -> Callable[..., None]:
95
+ """A decorator to add a task that could be handled by the worker.
96
+
97
+ Args:
98
+ name (str): tasks received with this name will be
99
+ processed by the decorated function
100
+ Returns:
101
+ Callable[[Callable[[], None]], None]: _description_
102
+ """
103
+
104
+ queue_name = queue_name or "default"
105
+
106
+ def decorator(func: Callable[[], object]) -> None:
107
+ queue_ = queue_name or "default"
108
+ if queue_ not in self.handlers_by_queue:
109
+ self.handlers_by_queue[queue_] = {name: func}
110
+ else:
111
+ self.handlers_by_queue[queue_][name] = func
112
+
113
+ return decorator
114
+
115
+ def on_event(
116
+ self, event_name: Literal["startup", "shutdown"]
117
+ ) -> Callable[[Callable[[], None]], None]:
118
+ """Yo can define event handlers that need to be executed before
119
+ the application startups, or when the application is shutting down.
120
+ The valid events are `startup`, `shutdown`.
121
+
122
+ Args:
123
+ event_name (Literal[&quot;startup&quot;, &quot;shutdown&quot;]):
124
+
125
+ Returns:
126
+ Callable[[Callable[[], None]], None]:
127
+ """
128
+
129
+ def decorator(func: Callable[[], None]) -> None:
130
+ if event_name not in self._events:
131
+ self._events[event_name] = [func]
132
+ else:
133
+ self._events[event_name] = [*self._events[event_name], func]
134
+
135
+ return decorator
136
+
137
+ def start(
138
+ self, event_loop: asyncio.AbstractEventLoop | None = None
139
+ ) -> None:
140
+ """Start the workert
141
+
142
+ Args:
143
+ event_loop (asyncio.AbstractEventLoop | None, optional):
144
+ if an eventloop is already running you can run the worker in it.
145
+ Defaults to None.
146
+ """
147
+ self.running = True
148
+ loop = event_loop or asyncio.get_event_loop()
149
+ self._loop = loop
150
+
151
+ if self._loop and self._loop.is_closed():
152
+ self._loop = asyncio.new_event_loop()
153
+ asyncio.set_event_loop(self._loop)
154
+
155
+ for signame in ("SIGINT", "SIGTERM"):
156
+ self._loop.add_signal_handler(
157
+ getattr(signal, signame), partial(self._stop_worker, self)
158
+ )
159
+
160
+ signal.siginterrupt(signal.SIGTERM, False)
161
+ signal.siginterrupt(signal.SIGUSR1, False)
162
+ signal.signal(signal.SIGINT, partial(self._sigint_handler, self))
163
+ signal.signal(signal.SIGTERM, partial(self._sigterm_handler, self))
164
+
165
+ for event in self._events.get("startup", []):
166
+ self._loop.run_until_complete(event()) # type: ignore
167
+ loop.create_task(self._poll_messages())
168
+ self._loop.run_forever()
169
+
170
+ def _finish_message(
171
+ self,
172
+ _future: asyncio.Future | None,
173
+ *,
174
+ handler_name: str,
175
+ task_id: str,
176
+ queue_alias: str,
177
+ start_time: float,
178
+ receipt_handle: object | None = None,
179
+ ) -> None:
180
+ start_date = datetime.fromtimestamp(start_time, tz=timezone.utc)
181
+ end_date = datetime.now(tz=timezone.utc)
182
+ total_seconds = end_date - start_date
183
+ if _future and _future.exception() is not None:
184
+ LOGGER.warning(
185
+ "Failed to execute task %s[%s]", handler_name, task_id
186
+ )
187
+ LOGGER.exception(
188
+ "Following exception",
189
+ exc_info=_future.exception(),
190
+ )
191
+ else:
192
+ LOGGER.info(
193
+ "Task %s[%s] succeeded in %ss: None",
194
+ handler_name,
195
+ task_id,
196
+ total_seconds.seconds,
197
+ )
198
+ if receipt_handle:
199
+ self._delete_message(receipt_handle, queue_alias)
200
+
201
+ def _delete_message(
202
+ self,
203
+ receipt_handle: object,
204
+ queue_alias: str,
205
+ ) -> None:
206
+ asyncio.ensure_future(
207
+ self.queues[queue_alias].delete_messages(
208
+ receipt_handle # type: ignore[arg-type]
209
+ ),
210
+ loop=self._loop,
211
+ )
212
+
213
+ def _available_workers_to_consume(self) -> int:
214
+ current_tasks = [
215
+ task
216
+ for task in asyncio.all_tasks(asyncio.get_event_loop())
217
+ if task.get_name().startswith(TASK_NAME_PREFIX)
218
+ ]
219
+ return self._max_workers - len(current_tasks)
220
+
221
+ def _get_tasks_by_queue(
222
+ self, queue_alias: str
223
+ ) -> tuple[asyncio.Task, ...]:
224
+ return tuple(
225
+ task
226
+ for task in asyncio.all_tasks(asyncio.get_event_loop())
227
+ if task.get_name().startswith(f"{TASK_NAME_PREFIX}_{queue_alias}")
228
+ )
229
+
230
+ async def queue_message_to_worker(
231
+ self,
232
+ *,
233
+ body: dict[str, Any],
234
+ message_type: MessageType,
235
+ queue_alias: str,
236
+ receipt_handler: str,
237
+ ) -> bool:
238
+ task_id = body.get("id") or uuid4().hex
239
+ task_name = body.get("task") or message_type.value.lower()
240
+ LOGGER.info("Task %s[%s] received", task_name, task_id)
241
+
242
+ if not self._loop:
243
+ return False
244
+
245
+ handler = self.handlers_by_queue.get(queue_alias, {}).get(task_name)
246
+ if not handler:
247
+ LOGGER.warning(
248
+ "The task %s has not a candidate to be proceeded",
249
+ task_name,
250
+ )
251
+ return False
252
+
253
+ task = self._loop.create_task(
254
+ retry_call(
255
+ handler,
256
+ fargs=tuple(body.get("args", [])),
257
+ fkwargs=(
258
+ body
259
+ if message_type == MessageType.SNS_NOTIFICATION
260
+ else body.get("kwargs", {})
261
+ ),
262
+ tries=body.get("retries", 1),
263
+ ),
264
+ name=(
265
+ f"{TASK_NAME_PREFIX}_{queue_alias}"
266
+ f"_{task_name}_{uuid4().hex[:8]}"
267
+ ),
268
+ )
269
+ task.add_done_callback(
270
+ partial(
271
+ self._finish_message,
272
+ receipt_handle=receipt_handler,
273
+ queue_alias=queue_alias,
274
+ start_time=datetime.now().timestamp(),
275
+ handler_name=task_name,
276
+ task_id=task_id,
277
+ )
278
+ )
279
+ return True
280
+
281
+ async def _process_queue_message(
282
+ self, message_content: dict[str, Any], queue_alias: str
283
+ ) -> bool:
284
+ try:
285
+ body = json.loads(message_content["Body"])
286
+ except json.JSONDecodeError:
287
+ return False
288
+
289
+ message_type = (
290
+ MessageType.SNS_NOTIFICATION
291
+ if body.get("Type") == "Notification"
292
+ else MessageType.CUSTOM
293
+ )
294
+ if not validate_message(body, message_type):
295
+ LOGGER.error("Failed to validate message")
296
+ return False
297
+
298
+ await self.queue_message_to_worker(
299
+ body=body,
300
+ message_type=message_type,
301
+ queue_alias=queue_alias,
302
+ receipt_handler=message_content["ReceiptHandle"],
303
+ )
304
+
305
+ return True
306
+
307
+ async def _consumer_callback(
308
+ self, message: dict[str, Any], queue_alias: str
309
+ ) -> None:
310
+ with suppress(asyncio.CancelledError):
311
+ success = await self._process_queue_message(message, queue_alias)
312
+ if not success:
313
+ await self.queues[queue_alias].delete_messages(
314
+ message["ReceiptHandle"]
315
+ )
316
+
317
+ async def _queue_n_messages(
318
+ self,
319
+ *,
320
+ queue: Queue,
321
+ queue_alias: str,
322
+ sqs_client: object,
323
+ required_messages: int,
324
+ ) -> None:
325
+ for _ in range(math.ceil(required_messages / 10)):
326
+ messages = await queue.get_messages(
327
+ sqs_client,
328
+ max_number_of_messages=(required_messages),
329
+ )
330
+
331
+ if len(messages) == 0:
332
+ break
333
+
334
+ await asyncio.gather(
335
+ *[
336
+ self._consumer_callback(message, queue_alias)
337
+ for message in messages
338
+ ]
339
+ )
340
+
341
+ async def _poll_messages(self) -> None:
342
+ queues = list(sorted(self.queues.items(), key=lambda x: x[1].priority))
343
+ queues = [item for item in queues if item[1].enabled]
344
+ async with SESSION.client(**RESOURCE_OPTIONS_SQS) as sqs_client:
345
+ while True:
346
+ if self._available_workers_to_consume() < 1:
347
+ await asyncio.sleep(1)
348
+ continue
349
+ for index, (queue_alias, queue) in enumerate(queues):
350
+ required_messages = math.ceil(
351
+ inverse_proportional_distribution(
352
+ self._available_workers_to_consume(),
353
+ [q[1].priority for q in queues[index:]],
354
+ )[0]
355
+ )
356
+
357
+ # it is necessary to request the messages several times
358
+ # because sqs only allows to obtain 10 messages at the
359
+ # same time
360
+ await self._queue_n_messages(
361
+ queue=queue,
362
+ queue_alias=queue_alias,
363
+ sqs_client=sqs_client,
364
+ required_messages=required_messages,
365
+ )
366
+
367
+ LOGGER.info(
368
+ "Queue messages info",
369
+ extra={
370
+ "extra": {
371
+ "queue": queue,
372
+ "queue_alias": queue_alias,
373
+ "sqs_client": sqs_client,
374
+ "required_messages": required_messages,
375
+ }
376
+ },
377
+ )
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "async_sqs_consumer"
3
+ version = "1.1.0"
4
+ description = ""
5
+ authors = [{ name = "Diego Restrepo", email = "drestrepo@fluidattacks.com" }]
6
+ requires-python = ">=3.10,<4"
7
+ readme = "README.md"
8
+ keywords = [
9
+ "AWS",
10
+ "AWS",
11
+ "SQS",
12
+ "sqs",
13
+ "consumer",
14
+ "async",
15
+ "worker",
16
+ ]
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ ]
25
+ dependencies = [
26
+ "aioboto3>=15.5.0,<16",
27
+ "jsonschema>=4.26.0,<5",
28
+ "pyrate-limiter>=4.0.2,<5",
29
+ ]
30
+
31
+ [project.urls]
32
+ Repository = "https://github.com/drestrepom/async_sqs_consumer"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "prospector>=1.7.7,<2",
37
+ "black>=22.8.0,<23",
38
+ "isort>=5.10.1,<6",
39
+ "mypy>=1.19.1",
40
+ "types-jsonschema>=4.21.0",
41
+ "boto3-stubs[essential]>=1.34.0",
42
+ ]
43
+
44
+ [tool.uv]
45
+
46
+ [tool.uv.build-backend]
47
+ module-root = ""
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.9.26,<0.10.0"]
51
+ build-backend = "uv_build"