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.
@@ -0,0 +1,12 @@
1
+ Contributors
2
+ ------------
3
+
4
+ - Anton Ostapenko <olsnod@gmail.com>
5
+ - Ivan Mikhailov <mikhailov@angrydev.ru>
6
+ - Andrey Zakharov <andrey.v.zakharov@yandex.ru>
7
+
8
+
9
+ Thanks also to
10
+ --------------
11
+
12
+ - WhiteApfel <white@pfel.ru>
@@ -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.
@@ -0,0 +1,6 @@
1
+ CONTRIBUTING
2
+ ------------
3
+
4
+ - increment the version number in `cabbage/__init__.py` file
5
+ - update `CHANGELOG.md`
6
+ - if necessary, update the “Contributors” and “Thanks also to” sections of `AUTHORS.md`.
@@ -0,0 +1,5 @@
1
+ include *.md
2
+ include requirements.txt
3
+ graft cabbage
4
+ global-exclude __pycache__
5
+ global-exclude *.py[co]
@@ -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
@@ -0,0 +1 @@
1
+ # cabbagok: RPC over AMQP
@@ -0,0 +1,8 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from cabbagok import test_utils
4
+ from .amqp import AmqpConnection, AsyncAmqpRpc, ServiceUnavailableError
5
+
6
+ __all__ = ['ServiceUnavailableError', 'AmqpConnection', 'AsyncAmqpRpc', 'test_utils']
7
+
8
+ __version__ = '1.0.0'
@@ -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)