cabbagok 1.0.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.
- cabbagok-1.0.0/AUTHORS.md +12 -0
- cabbagok-1.0.0/CHANGELOG.md +65 -0
- cabbagok-1.0.0/CONTRIBUTING.md +6 -0
- cabbagok-1.0.0/MANIFEST.in +5 -0
- cabbagok-1.0.0/PKG-INFO +23 -0
- cabbagok-1.0.0/README.md +1 -0
- cabbagok-1.0.0/cabbagok/__init__.py +8 -0
- cabbagok-1.0.0/cabbagok/amqp.py +445 -0
- cabbagok-1.0.0/cabbagok/test_utils.py +54 -0
- cabbagok-1.0.0/cabbagok/utils.py +38 -0
- cabbagok-1.0.0/cabbagok.egg-info/PKG-INFO +23 -0
- cabbagok-1.0.0/cabbagok.egg-info/SOURCES.txt +23 -0
- cabbagok-1.0.0/cabbagok.egg-info/dependency_links.txt +1 -0
- cabbagok-1.0.0/cabbagok.egg-info/requires.txt +1 -0
- cabbagok-1.0.0/cabbagok.egg-info/top_level.txt +1 -0
- cabbagok-1.0.0/cabbagok.egg-info/zip-safe +1 -0
- cabbagok-1.0.0/requirements.txt +1 -0
- cabbagok-1.0.0/setup.cfg +10 -0
- cabbagok-1.0.0/setup.py +43 -0
- cabbagok-1.0.0/tests/test_client.py +110 -0
- cabbagok-1.0.0/tests/test_connection.py +193 -0
- cabbagok-1.0.0/tests/test_fake_amqp_rpc.py +62 -0
- cabbagok-1.0.0/tests/test_server.py +381 -0
- cabbagok-1.0.0/tests/test_utils.py +28 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
CHANGELOG
|
|
2
|
+
=========
|
|
3
|
+
|
|
4
|
+
1.1.0 (2024-04-04)
|
|
5
|
+
------------------
|
|
6
|
+
|
|
7
|
+
- Add `add_to_start=False` arg for queue subscribe
|
|
8
|
+
- Updated aioamqp requirement
|
|
9
|
+
|
|
10
|
+
1.0.0 (2019-06-04)
|
|
11
|
+
------------------
|
|
12
|
+
|
|
13
|
+
- Public release
|
|
14
|
+
- Updated aiompq requirement
|
|
15
|
+
|
|
16
|
+
0.9.0 (2018-11-30)
|
|
17
|
+
------------------
|
|
18
|
+
|
|
19
|
+
- Fix AsyncAmqpRpc.send_rpc options to allow custom correlation id.
|
|
20
|
+
|
|
21
|
+
0.8.0 (2018-11-14)
|
|
22
|
+
------------------
|
|
23
|
+
|
|
24
|
+
- Added FakeAsyncAmqpRpc class
|
|
25
|
+
|
|
26
|
+
0.7.0 (2018-10-29)
|
|
27
|
+
------------------
|
|
28
|
+
|
|
29
|
+
- Added connect coroutine with extended ssl option
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
0.6.0 (2018-09-07)
|
|
33
|
+
------------------
|
|
34
|
+
|
|
35
|
+
- Python 3.7 support
|
|
36
|
+
- aiompq==0.11
|
|
37
|
+
- Added tests for amqp.py
|
|
38
|
+
|
|
39
|
+
0.5.3 (2018-07-02)
|
|
40
|
+
------------------
|
|
41
|
+
|
|
42
|
+
- Fix duplication messages with unexpected correlation id
|
|
43
|
+
|
|
44
|
+
0.5.2 (2018-06-05)
|
|
45
|
+
------------------
|
|
46
|
+
|
|
47
|
+
- Added blocking mechanism to assure that connection is ready to work
|
|
48
|
+
|
|
49
|
+
0.5.1 (2018-03-27)
|
|
50
|
+
------------------
|
|
51
|
+
|
|
52
|
+
- Removed dependency imports from `setup.py`.
|
|
53
|
+
|
|
54
|
+
0.5 (2018-03-27)
|
|
55
|
+
----------------
|
|
56
|
+
|
|
57
|
+
- Support for cycling through multiple hosts.
|
|
58
|
+
- Per-subscription request handlers.
|
|
59
|
+
- Stop consuming and wait for request handlers to finish on shutdown.
|
|
60
|
+
|
|
61
|
+
0.4 (2018-03-12)
|
|
62
|
+
----------------
|
|
63
|
+
|
|
64
|
+
- Raw mode for request handlers that use binary protocols.
|
|
65
|
+
- Sensible default values for many arguments.
|
cabbagok-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: cabbagok
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: asyncio-based AMQP client and server for RPC.
|
|
5
|
+
Home-page: https://github.com/whiteapfel/cabbagok/
|
|
6
|
+
Download-URL: https://pypi.org/project/cabbagok/
|
|
7
|
+
Maintainer: WhiteApfel
|
|
8
|
+
Maintainer-email: white@pfel.ru
|
|
9
|
+
License: Mozilla Public License 2.0
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: AUTHORS.md
|
|
21
|
+
Requires-Dist: aioamqp~=0.15
|
|
22
|
+
|
|
23
|
+
# cabbagok: RPC over AMQP
|
cabbagok-1.0.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# cabbagok: RPC over AMQP
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import asyncio
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import socket
|
|
7
|
+
import ssl as ssl_module
|
|
8
|
+
import uuid
|
|
9
|
+
from functools import partial
|
|
10
|
+
from itertools import cycle
|
|
11
|
+
from typing import Optional, Callable, Union, Awaitable, Mapping, Dict
|
|
12
|
+
|
|
13
|
+
from aioamqp.channel import Channel
|
|
14
|
+
from aioamqp.envelope import Envelope
|
|
15
|
+
from aioamqp.properties import Properties
|
|
16
|
+
from aioamqp.protocol import AmqpProtocol, CONNECTING, OPEN
|
|
17
|
+
|
|
18
|
+
from .utils import FibonaccianBackoff
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger('cabbagok')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ServiceUnavailableError(Exception):
|
|
24
|
+
"""External service unavailable. """
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AmqpConnection:
|
|
28
|
+
def __init__(self, hosts=None, username='guest', password='guest', virtualhost='/', loop=None, ssl=False):
|
|
29
|
+
"""
|
|
30
|
+
:param hosts: iterable with tuples (host, port), default localhost:5672
|
|
31
|
+
:param username: AMQP login, default guest
|
|
32
|
+
:param password: AMQP password, default guest
|
|
33
|
+
:param virtualhost: AMQP virtual host, default /
|
|
34
|
+
:param loop: asyncio event loop, default current event loop
|
|
35
|
+
:param ssl: bool or SSLContext, uses default ssl context if True, default False
|
|
36
|
+
"""
|
|
37
|
+
self.loop = loop or asyncio.get_event_loop()
|
|
38
|
+
self.username = username
|
|
39
|
+
self.password = password
|
|
40
|
+
self.virtualhost = virtualhost
|
|
41
|
+
self.hosts = hosts if hosts is not None else [('localhost', 5672)]
|
|
42
|
+
self._connection_cycle = self.cycle_hosts()
|
|
43
|
+
self.transport = None
|
|
44
|
+
self.protocol = None
|
|
45
|
+
self.ssl = ssl
|
|
46
|
+
|
|
47
|
+
async def channel(self):
|
|
48
|
+
if self.protocol is not None:
|
|
49
|
+
return await self.protocol.channel()
|
|
50
|
+
|
|
51
|
+
def cycle_hosts(self, shuffle=False):
|
|
52
|
+
if shuffle:
|
|
53
|
+
random.shuffle(self.hosts)
|
|
54
|
+
yield from cycle(self.hosts)
|
|
55
|
+
|
|
56
|
+
async def connect(self):
|
|
57
|
+
"""Connect to AMQP broker. On failure this function will endlessly try reconnecting.
|
|
58
|
+
Do nothing if already connected or connecting.
|
|
59
|
+
"""
|
|
60
|
+
if self.protocol is not None and self.protocol.state in [CONNECTING, OPEN]:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
delay = FibonaccianBackoff(limit=60.0)
|
|
64
|
+
for host, port in self._connection_cycle:
|
|
65
|
+
try:
|
|
66
|
+
self.transport, self.protocol = await aioamqp_connect(
|
|
67
|
+
loop=self.loop,
|
|
68
|
+
host=host,
|
|
69
|
+
port=port,
|
|
70
|
+
login=self.username,
|
|
71
|
+
password=self.password,
|
|
72
|
+
virtualhost=self.virtualhost,
|
|
73
|
+
ssl=self.ssl
|
|
74
|
+
)
|
|
75
|
+
except OSError as e:
|
|
76
|
+
# Connection-related errors are mostly represented by `ConnectionError`,
|
|
77
|
+
# except for some like `socket.gaierror`, which fall into broader `OSError`
|
|
78
|
+
next_delay = delay.next()
|
|
79
|
+
logger.warning(f'failed to connect to {host}:{port}, error <{e.__class__.__name__}> {e}, '
|
|
80
|
+
f'retrying in {next_delay} seconds')
|
|
81
|
+
await asyncio.sleep(next_delay)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f'connection failed, not retrying: <{e.__class__.__name__}> {e}')
|
|
84
|
+
raise
|
|
85
|
+
else:
|
|
86
|
+
logger.info(f'wait connection to {host}:{port}')
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
async def disconnect(self):
|
|
90
|
+
if self.protocol is not None and self.protocol.state in [CONNECTING, OPEN]:
|
|
91
|
+
await self.protocol.close()
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_connected(self):
|
|
95
|
+
"""Property, required for rpc to check readiness"""
|
|
96
|
+
return bool(self.protocol) and self.protocol.state == OPEN
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AsyncAmqpRpc:
|
|
100
|
+
def __init__(self, connection: AmqpConnection,
|
|
101
|
+
exchange_params: Mapping = None, queue_params: Mapping = None,
|
|
102
|
+
subscriptions=None, prefetch_count=1, raw=False, default_response_timeout=15.0,
|
|
103
|
+
shutdown_timeout=60.0, connection_delay: float = 0.1, callback_exchange=''):
|
|
104
|
+
"""
|
|
105
|
+
All arguments are optional. If `request_handler` is not supplied or None, RPC works only in client mode.
|
|
106
|
+
|
|
107
|
+
:param queue_params: options for creating queues, default durable and DLX
|
|
108
|
+
:param exchange_params: options when creating exchanges, default durable and type topic
|
|
109
|
+
:param subscriptions: list of tuples (handler, queue, exchange, routing_key, queue_params, exchange_params)
|
|
110
|
+
Rightmost parameters are optional, you can specify only (handler, queue).
|
|
111
|
+
:param raw: do not attempt decoding, use iff `request_handler` maps `bytes -> bytes`.
|
|
112
|
+
:param prefetch_count: per-consumer prefetch message limit, default 1
|
|
113
|
+
:param default_response_timeout: default timeout for awaiting response when sending remote calls
|
|
114
|
+
:param shutdown_timeout: timeout for handlers to finish gracefully on shutdown
|
|
115
|
+
"""
|
|
116
|
+
self.raw = raw
|
|
117
|
+
self.queue_params = queue_params
|
|
118
|
+
self.exchange_params = exchange_params
|
|
119
|
+
self.start_subscriptions = subscriptions or []
|
|
120
|
+
self.default_response_timeout = default_response_timeout
|
|
121
|
+
self.shutdown_timeout = shutdown_timeout
|
|
122
|
+
self.connection = connection
|
|
123
|
+
self.prefetch_count = prefetch_count
|
|
124
|
+
self.keep_running = True
|
|
125
|
+
self.channel = None
|
|
126
|
+
self.callback_queue = None
|
|
127
|
+
self.callback_exchange = callback_exchange
|
|
128
|
+
self._responses: Dict[str, asyncio.Future] = {}
|
|
129
|
+
self._tasks = set()
|
|
130
|
+
self._subscriptions = set()
|
|
131
|
+
self.connection_delay = connection_delay
|
|
132
|
+
|
|
133
|
+
async def connect(self):
|
|
134
|
+
await self.connection.connect()
|
|
135
|
+
self.channel = await self.connection.channel()
|
|
136
|
+
|
|
137
|
+
result = await self.channel.queue_declare(exclusive=True)
|
|
138
|
+
self.callback_queue = result['queue']
|
|
139
|
+
|
|
140
|
+
# setup client
|
|
141
|
+
if self.callback_exchange != '':
|
|
142
|
+
# operation not permitted on default exchange
|
|
143
|
+
await self.channel.exchange_declare(exchange_name=self.callback_exchange, type_name='topic', durable=True)
|
|
144
|
+
await self.channel.queue_bind(
|
|
145
|
+
queue_name=self.callback_queue,
|
|
146
|
+
exchange_name=self.callback_exchange,
|
|
147
|
+
routing_key=self.callback_queue)
|
|
148
|
+
|
|
149
|
+
await self.channel.basic_consume(
|
|
150
|
+
callback=self._on_response,
|
|
151
|
+
queue_name=self.callback_queue,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
logger.debug(f'listening on callback queue {result["queue"]}')
|
|
155
|
+
|
|
156
|
+
# AMQP server implementation
|
|
157
|
+
|
|
158
|
+
async def declare(self, queue: str, exchange: str = '', routing_key: str = None,
|
|
159
|
+
queue_params: Mapping = None, exchange_params: Mapping = None):
|
|
160
|
+
"""
|
|
161
|
+
Set up necessary objects — exchange, queue, binding, QoS.
|
|
162
|
+
|
|
163
|
+
:param queue: queue name
|
|
164
|
+
:param exchange: exchange name, default '' (default AMQP exchange)
|
|
165
|
+
:param routing_key: routing key, default same as `queue`
|
|
166
|
+
:param queue_params: options for the queue, default durable and DLX
|
|
167
|
+
:param exchange_params: options for the exchange, default durable and type topic
|
|
168
|
+
"""
|
|
169
|
+
if routing_key is None:
|
|
170
|
+
routing_key = queue
|
|
171
|
+
if exchange_params is None:
|
|
172
|
+
exchange_params = dict(type_name='topic', durable=True)
|
|
173
|
+
if queue_params is None:
|
|
174
|
+
queue_params = dict(durable=True, arguments={
|
|
175
|
+
'x-dead-letter-exchange': 'DLX',
|
|
176
|
+
'x-dead-letter-routing-key': 'dlx_rpc',
|
|
177
|
+
})
|
|
178
|
+
if exchange != '':
|
|
179
|
+
# operation not permitted on default exchange
|
|
180
|
+
await self.channel.exchange_declare(exchange_name=exchange, **exchange_params)
|
|
181
|
+
result = await self.channel.queue_declare(queue_name=queue, **queue_params)
|
|
182
|
+
queue = result['queue'] # in case queue name was generated by broker
|
|
183
|
+
if exchange != '':
|
|
184
|
+
# operation not permitted on default exchange
|
|
185
|
+
await self.channel.queue_bind(
|
|
186
|
+
queue_name=queue,
|
|
187
|
+
exchange_name=exchange,
|
|
188
|
+
routing_key=routing_key)
|
|
189
|
+
await self.channel.basic_qos(
|
|
190
|
+
prefetch_count=self.prefetch_count,
|
|
191
|
+
prefetch_size=0,
|
|
192
|
+
connection_global=False,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def subscribe(self, request_handler: Union[
|
|
196
|
+
Callable[[str], Optional[str]],
|
|
197
|
+
Callable[[bytes], Optional[bytes]],
|
|
198
|
+
Callable[[str], Awaitable[Optional[str]]],
|
|
199
|
+
Callable[[bytes], Awaitable[Optional[bytes]]],
|
|
200
|
+
], queue: str, exchange: str = '', routing_key: str = None, add_to_start: bool = False) -> str:
|
|
201
|
+
"""
|
|
202
|
+
Subscribe to a specific queue. Exchange and queue will be created if they do not exist.
|
|
203
|
+
|
|
204
|
+
:param request_handler: request handler, can be a normal or coroutine function
|
|
205
|
+
that maps either str->str or bytes->bytes. If `request_handler` returns None,
|
|
206
|
+
it is taken to mean no response is needed.
|
|
207
|
+
:param exchange: exchange name, default '' (default AMQP exchange)
|
|
208
|
+
:param queue: queue name
|
|
209
|
+
:param routing_key: routing key, default same as `queue`
|
|
210
|
+
:param add_to_start: add to start_subscriptions
|
|
211
|
+
:return: consumer_tag
|
|
212
|
+
"""
|
|
213
|
+
if routing_key is None:
|
|
214
|
+
routing_key = queue
|
|
215
|
+
await self.declare(queue=queue, exchange=exchange, routing_key=routing_key,
|
|
216
|
+
queue_params=self.queue_params, exchange_params=self.exchange_params)
|
|
217
|
+
result = await self.channel.basic_consume(
|
|
218
|
+
callback=partial(self._on_request, request_handler=request_handler),
|
|
219
|
+
queue_name=queue,
|
|
220
|
+
)
|
|
221
|
+
consumer_tag = result['consumer_tag']
|
|
222
|
+
self._subscriptions.add(consumer_tag)
|
|
223
|
+
self.start_subscriptions.append(
|
|
224
|
+
(request_handler, queue, exchange, routing_key)
|
|
225
|
+
)
|
|
226
|
+
logger.debug(f'subscribed to queue {queue}, bound to exchange {exchange} with key {routing_key} '
|
|
227
|
+
f'(consumer tag {consumer_tag})')
|
|
228
|
+
return consumer_tag
|
|
229
|
+
|
|
230
|
+
async def unsubscribe(self, consumer_tag: str):
|
|
231
|
+
"""
|
|
232
|
+
Stop consuming on a queue.
|
|
233
|
+
|
|
234
|
+
:param consumer_tag: consumer tag returned by `subscribe()`
|
|
235
|
+
"""
|
|
236
|
+
logger.debug(f'unsubscribed from a queue (consumer tag {consumer_tag})')
|
|
237
|
+
await self.channel.basic_cancel(consumer_tag=consumer_tag)
|
|
238
|
+
|
|
239
|
+
async def _on_request(self, channel, body, envelope, properties, request_handler):
|
|
240
|
+
"""Run handle_rpc() in background. """
|
|
241
|
+
task = asyncio.ensure_future(self.handle_rpc(channel, body, envelope, properties, request_handler))
|
|
242
|
+
self._tasks.add(task)
|
|
243
|
+
task.add_done_callback(lambda fut: self._tasks.remove(fut))
|
|
244
|
+
|
|
245
|
+
async def handle_rpc(self, channel: Channel, body: bytes, envelope: Envelope, properties: Properties,
|
|
246
|
+
request_handler):
|
|
247
|
+
"""Process request with handler and send response if needed. """
|
|
248
|
+
try:
|
|
249
|
+
data = body if self.raw else body.decode('utf-8')
|
|
250
|
+
logger.debug(f'> handle_rpc: data {data}, routing_key {properties.reply_to}, '
|
|
251
|
+
f'correlation_id {properties.correlation_id}')
|
|
252
|
+
response = request_handler(data)
|
|
253
|
+
if inspect.isawaitable(response):
|
|
254
|
+
response = await response
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(f'handle_rpc. error <{e.__class__.__name__}> {e}, routing_key {properties.reply_to}, '
|
|
257
|
+
f'correlation_id {properties.correlation_id}')
|
|
258
|
+
await channel.basic_client_nack(delivery_tag=envelope.delivery_tag)
|
|
259
|
+
else:
|
|
260
|
+
responding = properties.reply_to is not None and response is not None
|
|
261
|
+
logger.debug(f'{"< " * responding}handle_rpc: responding? {responding}, routing_key {properties.reply_to}, '
|
|
262
|
+
f'correlation_id {properties.correlation_id}, result {response}')
|
|
263
|
+
if responding:
|
|
264
|
+
response_params = dict(
|
|
265
|
+
payload=response if self.raw else response.encode('utf-8'),
|
|
266
|
+
exchange_name='',
|
|
267
|
+
routing_key=properties.reply_to
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if properties.correlation_id:
|
|
271
|
+
response_params['properties'] = {'correlation_id': properties.correlation_id}
|
|
272
|
+
|
|
273
|
+
await channel.basic_publish(**response_params)
|
|
274
|
+
|
|
275
|
+
if channel.is_open:
|
|
276
|
+
await channel.basic_client_ack(delivery_tag=envelope.delivery_tag)
|
|
277
|
+
|
|
278
|
+
async def run_server(self):
|
|
279
|
+
"""Main routine for the server. """
|
|
280
|
+
try:
|
|
281
|
+
while self.keep_running:
|
|
282
|
+
await self.connect()
|
|
283
|
+
# TODO: reconnect to manual subscriptions on lost connection
|
|
284
|
+
for params in self.start_subscriptions:
|
|
285
|
+
await self.subscribe(*params)
|
|
286
|
+
await self.connection.protocol.wait_closed()
|
|
287
|
+
finally:
|
|
288
|
+
await self.connection.disconnect()
|
|
289
|
+
|
|
290
|
+
async def run(self, app=None):
|
|
291
|
+
"""aiohttp-compatible on_startup coroutine. """
|
|
292
|
+
asyncio.ensure_future(self.run_server())
|
|
293
|
+
await self.wait_connected()
|
|
294
|
+
logger.info('Waiting finished. Connected successfully.')
|
|
295
|
+
|
|
296
|
+
async def stop(self, app=None):
|
|
297
|
+
"""aiohttp-compatible on_shutdown coroutine. """
|
|
298
|
+
for consumer_tag in self._subscriptions:
|
|
299
|
+
await self.unsubscribe(consumer_tag)
|
|
300
|
+
if self._tasks:
|
|
301
|
+
logger.info(f'waiting for {len(self._tasks)} task(s) to finish normally')
|
|
302
|
+
done, pending = await asyncio.wait(self._tasks, timeout=self.shutdown_timeout)
|
|
303
|
+
if pending:
|
|
304
|
+
level = logger.warning
|
|
305
|
+
else:
|
|
306
|
+
level = logger.info
|
|
307
|
+
level(f'{len(done)} task(s) finished, {len(pending)} task(s) did not finish in time')
|
|
308
|
+
self.keep_running = False
|
|
309
|
+
await self.connection.disconnect()
|
|
310
|
+
|
|
311
|
+
# AMQP client implementation
|
|
312
|
+
|
|
313
|
+
async def send_rpc(self, destination: str, data: Union[str, bytes], exchange: str = '', await_response=True,
|
|
314
|
+
timeout: float = None, correlation_id: str = None) -> Union[str, bytes, None]:
|
|
315
|
+
"""
|
|
316
|
+
Execute a method on remote server. Sends `data` to `destination` routing key.
|
|
317
|
+
|
|
318
|
+
If `await_response` is True, the call blocks coroutine until the result is returned or until `timeout` seconds
|
|
319
|
+
passed (class default is used if None). AMQP correlation_id is set to `correlation_id` or new UUID if None.
|
|
320
|
+
|
|
321
|
+
Raises `ServiceUnavailableError` on response timeout.
|
|
322
|
+
"""
|
|
323
|
+
# TODO: retry on error?
|
|
324
|
+
if isinstance(data, str):
|
|
325
|
+
payload = data.encode('utf-8')
|
|
326
|
+
raw = False
|
|
327
|
+
else:
|
|
328
|
+
payload = data
|
|
329
|
+
raw = True
|
|
330
|
+
properties = dict()
|
|
331
|
+
if await_response:
|
|
332
|
+
if correlation_id is None:
|
|
333
|
+
correlation_id = str(uuid.uuid4())
|
|
334
|
+
properties = {
|
|
335
|
+
'reply_to': self.callback_queue,
|
|
336
|
+
'correlation_id': correlation_id,
|
|
337
|
+
}
|
|
338
|
+
logger.debug(f'< send_rpc: destination {destination}, data {data}, '
|
|
339
|
+
f'awaiting? {await_response}, timeout {timeout}, properties {properties}')
|
|
340
|
+
await self.channel.basic_publish(
|
|
341
|
+
exchange_name=exchange,
|
|
342
|
+
routing_key=destination,
|
|
343
|
+
properties=properties,
|
|
344
|
+
payload=payload,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if await_response:
|
|
348
|
+
if timeout is None:
|
|
349
|
+
timeout = self.default_response_timeout
|
|
350
|
+
response = await self._await_response(correlation_id=correlation_id, timeout=timeout)
|
|
351
|
+
if not raw:
|
|
352
|
+
response = response.decode('utf-8')
|
|
353
|
+
logger.debug(f'> send_rpc: response {response}')
|
|
354
|
+
return response
|
|
355
|
+
|
|
356
|
+
async def _await_response(self, correlation_id, timeout):
|
|
357
|
+
"""Wait for a response with given correlation id. Blocks current Task. """
|
|
358
|
+
self._responses[correlation_id] = asyncio.Future()
|
|
359
|
+
try:
|
|
360
|
+
await asyncio.wait_for(self._responses[correlation_id], timeout=timeout)
|
|
361
|
+
return self._responses[correlation_id].result()
|
|
362
|
+
except asyncio.TimeoutError:
|
|
363
|
+
logger.warning(f'request {correlation_id} timed out')
|
|
364
|
+
raise ServiceUnavailableError('Request timed out') from None
|
|
365
|
+
finally:
|
|
366
|
+
self._responses.pop(correlation_id)
|
|
367
|
+
|
|
368
|
+
async def _on_response(self, channel: Channel, body: bytes, envelope: Envelope, properties: Properties):
|
|
369
|
+
"""Set response result. Called by aioamqp on a message in callback queue. """
|
|
370
|
+
if properties.correlation_id in self._responses:
|
|
371
|
+
self._responses[properties.correlation_id].set_result(body)
|
|
372
|
+
else:
|
|
373
|
+
logger.warning(f'unexpected message with correlation_id {properties.correlation_id}.')
|
|
374
|
+
|
|
375
|
+
if channel.is_open:
|
|
376
|
+
await channel.basic_client_ack(delivery_tag=envelope.delivery_tag)
|
|
377
|
+
|
|
378
|
+
async def wait_connected(self):
|
|
379
|
+
while not all([self.connection.is_connected, self.channel]):
|
|
380
|
+
logger.debug(f'Waiting connection for {self.connection_delay}s...')
|
|
381
|
+
await asyncio.sleep(self.connection_delay)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def aioamqp_connect(host='localhost', port=None, login='guest', password='guest', virtualhost='/', ssl=False,
|
|
385
|
+
login_method='AMQPLAIN', insist=False, protocol_factory=AmqpProtocol, *, verify_ssl=True,
|
|
386
|
+
loop=None, timeout=None, **kwargs):
|
|
387
|
+
"""Convenient method to connect to an AMQP broker
|
|
388
|
+
:param host: the host to connect to
|
|
389
|
+
:param port: broker port
|
|
390
|
+
:param login: login
|
|
391
|
+
:param password: password
|
|
392
|
+
:param virtualhost: AMQP virtualhost to use for this connection
|
|
393
|
+
:param ssl: bool: Create an SSL connection instead of a plain unencrypted one
|
|
394
|
+
SSLContext: Set custom SSLContext object
|
|
395
|
+
:param verify_ssl: Verify server's SSL certificate (True by default)
|
|
396
|
+
:param login_method: AMQP auth method
|
|
397
|
+
:param insist: Insist on connecting to a server
|
|
398
|
+
:param protocol_factory: Factory to use, if you need to subclass AmqpProtocol
|
|
399
|
+
:param loop: Set the event loop to use
|
|
400
|
+
:param kwargs: Arguments to be given to the protocol_factory instance
|
|
401
|
+
:return: a tuple (transport, protocol) of an AmqpProtocol instance
|
|
402
|
+
"""
|
|
403
|
+
SSL_PORT = 5671
|
|
404
|
+
DEFAULT_PORT = 5672
|
|
405
|
+
|
|
406
|
+
if loop is None:
|
|
407
|
+
loop = asyncio.get_event_loop()
|
|
408
|
+
|
|
409
|
+
def factory(): return protocol_factory(loop=loop, **kwargs)
|
|
410
|
+
|
|
411
|
+
create_connection_kwargs = {}
|
|
412
|
+
|
|
413
|
+
if ssl:
|
|
414
|
+
ssl_context = ssl_module.create_default_context() if isinstance(ssl, bool) else ssl
|
|
415
|
+
if not verify_ssl:
|
|
416
|
+
ssl_context.check_hostname = False
|
|
417
|
+
ssl_context.verify_mode = ssl_module.CERT_NONE
|
|
418
|
+
create_connection_kwargs['ssl'] = ssl_context
|
|
419
|
+
|
|
420
|
+
if port is None:
|
|
421
|
+
if ssl:
|
|
422
|
+
port = SSL_PORT
|
|
423
|
+
else:
|
|
424
|
+
port = DEFAULT_PORT
|
|
425
|
+
|
|
426
|
+
transport, protocol = await loop.create_connection(
|
|
427
|
+
factory, host, port, **create_connection_kwargs
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# these 2 flags *may* show up in sock.type. They are only available on linux
|
|
431
|
+
# see https://bugs.python.org/issue21327
|
|
432
|
+
nonblock = getattr(socket, 'SOCK_NONBLOCK', 0)
|
|
433
|
+
cloexec = getattr(socket, 'SOCK_CLOEXEC', 0)
|
|
434
|
+
sock = transport.get_extra_info('socket')
|
|
435
|
+
if sock is not None and (sock.type & ~nonblock & ~cloexec) == socket.SOCK_STREAM:
|
|
436
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
await protocol.start_connection(host, port, login, password, virtualhost, ssl=ssl, login_method=login_method,
|
|
440
|
+
insist=insist)
|
|
441
|
+
except Exception:
|
|
442
|
+
await protocol.wait_closed(timeout=timeout)
|
|
443
|
+
raise
|
|
444
|
+
|
|
445
|
+
return transport, protocol
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import inspect
|
|
3
|
+
|
|
4
|
+
from cabbagok.amqp import AsyncAmqpRpc, ServiceUnavailableError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FakeAsyncAmqpRpc(AsyncAmqpRpc):
|
|
8
|
+
"""
|
|
9
|
+
Can be used instead of AsyncAmqpRpc class for tests.
|
|
10
|
+
Allows to set expected responses for testing amqp rpc client and to send fake message for testing a server.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *args, **kwargs):
|
|
14
|
+
super().__init__(*args, **kwargs)
|
|
15
|
+
self.subscriptions = {}
|
|
16
|
+
self.responses = {}
|
|
17
|
+
self.call_args = []
|
|
18
|
+
|
|
19
|
+
async def connect(self):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def set_response(self, routing_key, data):
|
|
23
|
+
self.responses[routing_key] = data
|
|
24
|
+
|
|
25
|
+
async def send_rpc(self, destination, data, exchange='', await_response=True, timeout=None, correlation_id=None):
|
|
26
|
+
self.call_args.append((destination, data))
|
|
27
|
+
if not await_response:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
response = self.responses.get(destination)
|
|
31
|
+
|
|
32
|
+
if response is None:
|
|
33
|
+
raise ServiceUnavailableError()
|
|
34
|
+
|
|
35
|
+
return response
|
|
36
|
+
|
|
37
|
+
async def run_server(self):
|
|
38
|
+
for request_handler, queue, *_ in self.start_subscriptions:
|
|
39
|
+
self.subscriptions[queue] = request_handler
|
|
40
|
+
|
|
41
|
+
async def run(self, app=None):
|
|
42
|
+
await self.run_server()
|
|
43
|
+
|
|
44
|
+
async def fake_message(self, queue, data):
|
|
45
|
+
if queue not in self.subscriptions:
|
|
46
|
+
raise ValueError(f'invalid queue name {queue}')
|
|
47
|
+
|
|
48
|
+
request_handler = self.subscriptions[queue]
|
|
49
|
+
response = request_handler(data)
|
|
50
|
+
|
|
51
|
+
if inspect.isawaitable(response):
|
|
52
|
+
response = await response
|
|
53
|
+
|
|
54
|
+
return response
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
class ExponentialBackoff:
|
|
3
|
+
"""
|
|
4
|
+
Exponential backoff with an upper limit.
|
|
5
|
+
|
|
6
|
+
>>> delay = ExponentialBackoff(1, 2, 100)
|
|
7
|
+
>>> [delay.next() for _ in range(10)]
|
|
8
|
+
[1, 2, 4, 8, 16, 32, 64, 100, 100, 100]
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, start, factor, limit):
|
|
13
|
+
self.current = start
|
|
14
|
+
self.factor = factor
|
|
15
|
+
self.limit = limit
|
|
16
|
+
|
|
17
|
+
def __iter__(self):
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __next__(self):
|
|
21
|
+
return self.next()
|
|
22
|
+
|
|
23
|
+
def next(self) -> int:
|
|
24
|
+
try:
|
|
25
|
+
return round(self.current)
|
|
26
|
+
finally:
|
|
27
|
+
self.current = min(self.current * self.factor, self.limit)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FibonaccianBackoff(ExponentialBackoff):
|
|
31
|
+
"""
|
|
32
|
+
Exponential backoff with parameters that produce a Fibonacci-like sequence:
|
|
33
|
+
|
|
34
|
+
1, 1, 2, 3, 5, 8, 13, 21, ...
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, limit):
|
|
38
|
+
super().__init__(start=0.724, factor=1.618, limit=limit)
|