qena-shared-lib 0.1.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.
@@ -0,0 +1,1292 @@
1
+ from abc import ABC, abstractmethod
2
+ from asyncio import AbstractEventLoop, Future, Lock, Task, iscoroutinefunction
3
+ from dataclasses import dataclass
4
+ from functools import partial
5
+ from inspect import Parameter, signature
6
+ from random import uniform
7
+ from time import time
8
+ from types import MappingProxyType
9
+ from typing import Any, Callable, Collection, TypeVar
10
+
11
+ from pika import BasicProperties
12
+ from pika.adapters.asyncio_connection import AsyncioConnection
13
+ from pika.channel import Channel
14
+ from pika.frame import Method
15
+ from pika.spec import Basic
16
+ from prometheus_client import Counter, Summary
17
+ from punq import Container
18
+ from pydantic import ValidationError
19
+ from pydantic_core import from_json, to_json
20
+
21
+ from ..dependencies.miscellaneous import validate_annotation
22
+ from ..logging import LoggerProvider
23
+ from ..logstash import BaseLogstashSender
24
+ from ..utils import AsyncEventLoopMixin
25
+ from ._channel import BaseChannel
26
+ from ._exceptions import RabbitMQException
27
+ from ._pool import ChannelPool
28
+ from ._utils import TypeAdapterCache
29
+
30
+ __all__ = [
31
+ "BackoffRetryDelay",
32
+ "consume",
33
+ "CONSUMER_ATTRIBUTE",
34
+ "Consumer",
35
+ "execute",
36
+ "FixedRetryDelay",
37
+ "FlowControl",
38
+ "LISTENER_ATTRIBUTE",
39
+ "Listener",
40
+ "ListenerBase",
41
+ "ListenerContext",
42
+ "RetryDelayJitter",
43
+ "RetryDelayStrategy",
44
+ "RetryPolicy",
45
+ "RPC_WORKER_ATTRIBUTE",
46
+ "RpcReply",
47
+ "RpcWorker",
48
+ ]
49
+
50
+
51
+ L = TypeVar("L")
52
+ DEFAULT_EXCHANGE = ""
53
+ LISTENER_ATTRIBUTE = "__rabbitmq_listener__"
54
+ CONSUMER_ATTRIBUTE = "__rabbitmq_consumer__"
55
+ RPC_WORKER_ATTRIBUTE = "__rabbitmq_rpc_worker__"
56
+
57
+
58
+ class FlowControl:
59
+ def __init__(self, channel: Channel, loop: AbstractEventLoop):
60
+ self._channel = channel
61
+ self._loop = loop
62
+ self._lock = Lock()
63
+ self._flow_control_future = None
64
+
65
+ async def request(self, prefetch_count: int):
66
+ async with self._lock:
67
+ self._flow_control_future = self._loop.create_future()
68
+
69
+ self._channel.basic_qos(
70
+ prefetch_count=prefetch_count,
71
+ callback=self._on_prefetch_count_set,
72
+ )
73
+
74
+ await self._flow_control_future
75
+
76
+ def _on_prefetch_count_set(self, method: Method):
77
+ del method
78
+
79
+ if self._flow_control_future is None:
80
+ raise RuntimeError("flow control future not set")
81
+
82
+ self._flow_control_future.set_result(None)
83
+
84
+
85
+ class RpcReply:
86
+ def __init__(
87
+ self,
88
+ channel_pool: ChannelPool,
89
+ reply_to: str,
90
+ correlation_id: str | None = None,
91
+ ):
92
+ self._channel_pool = channel_pool
93
+ self._reply_to = reply_to
94
+ self._correlation_id = correlation_id
95
+ self._replied = False
96
+
97
+ async def reply(self, message: Any):
98
+ base_channel = await self._channel_pool.get()
99
+
100
+ reply_properties = BasicProperties(content_type="application/json")
101
+
102
+ if self._correlation_id is not None:
103
+ reply_properties.correlation_id = self._correlation_id
104
+
105
+ try:
106
+ with base_channel as channel:
107
+ channel.basic_publish(
108
+ exchange=DEFAULT_EXCHANGE,
109
+ routing_key=self._reply_to,
110
+ properties=reply_properties,
111
+ body=to_json(message),
112
+ )
113
+ except:
114
+ return
115
+
116
+ self._replied = True
117
+
118
+ @property
119
+ def replied(self) -> bool:
120
+ return self._replied
121
+
122
+
123
+ @dataclass
124
+ class ListenerContext:
125
+ queue: str
126
+ listener_name: str
127
+ body: bytes
128
+ flow_control: FlowControl
129
+ rpc_reply: RpcReply | None = None
130
+ context_dispose_callback: Callable | None = None
131
+
132
+ def dispose(self):
133
+ if self.context_dispose_callback is not None:
134
+ self.context_dispose_callback(self)
135
+
136
+
137
+ class RetryDelayStrategy(ABC):
138
+ @abstractmethod
139
+ def delay(self, times_rejected: int) -> float:
140
+ pass
141
+
142
+
143
+ @dataclass
144
+ class BackoffRetryDelay(RetryDelayStrategy):
145
+ multiplier: float
146
+ min: float
147
+ max: float
148
+
149
+ def __post_init__(self):
150
+ if self.min > self.max:
151
+ raise ValueError("`min` greater than `max`")
152
+
153
+ def delay(self, times_rejected: int) -> float:
154
+ retry_delay = self.multiplier * float(times_rejected)
155
+
156
+ retry_delay = max(self.min, retry_delay)
157
+ retry_delay = min(self.max, retry_delay)
158
+
159
+ return retry_delay
160
+
161
+
162
+ @dataclass
163
+ class FixedRetryDelay(RetryDelayStrategy):
164
+ retry_delay: float
165
+
166
+ def delay(self, times_rejected: int) -> float:
167
+ del times_rejected
168
+
169
+ return self.retry_delay
170
+
171
+
172
+ @dataclass
173
+ class RetryDelayJitter:
174
+ min: float = 0.5
175
+ max: float = 1.0
176
+
177
+ def __post_init__(self):
178
+ if self.min > self.max:
179
+ raise ValueError("`min` greater than `max`")
180
+
181
+
182
+ @dataclass
183
+ class RetryPolicy:
184
+ exceptions: Collection[type[Exception]]
185
+ max_retry: int
186
+ retry_delay_strategy: RetryDelayStrategy
187
+ retry_delay_jitter: RetryDelayJitter | None = None
188
+
189
+ def can_retry(self, times_rejected: int) -> bool:
190
+ return times_rejected < self.max_retry
191
+
192
+ def next_delay(self, times_rejected: int) -> float:
193
+ retry_delay = self.retry_delay_strategy.delay(times_rejected)
194
+
195
+ if self.retry_delay_jitter is not None:
196
+ retry_delay = retry_delay + uniform(
197
+ self.retry_delay_jitter.min, self.retry_delay_jitter.max
198
+ )
199
+
200
+ return retry_delay
201
+
202
+
203
+ @dataclass
204
+ class ListenerMethodContainer:
205
+ listener_method: Callable
206
+ parameters: MappingProxyType[str, Parameter]
207
+ dependencies: dict[str, type]
208
+ retry_policy: RetryPolicy | None = None
209
+
210
+
211
+ class ListenerChannelAdapter(BaseChannel):
212
+ def __init__(
213
+ self,
214
+ connection: AsyncioConnection,
215
+ on_channel_open_callback: Callable[[Channel], None],
216
+ on_cancel_callback: Callable,
217
+ ):
218
+ super().__init__(
219
+ connection=connection,
220
+ failed_reopen_threshold=None,
221
+ )
222
+
223
+ self._on_channle_open_listener_callback = on_channel_open_callback
224
+ self._on_listener_cancel_callback = on_cancel_callback
225
+
226
+ def _hook_on_channel_opened(self):
227
+ self._on_channle_open_listener_callback(self.channel)
228
+
229
+ def _hook_on_cancelled(self):
230
+ self._on_listener_cancel_callback()
231
+
232
+
233
+ @dataclass
234
+ class ListenerMessageMeta:
235
+ body: bytes
236
+ method: Basic.Deliver
237
+ properties: BasicProperties
238
+ listener_name: str
239
+ listener_method_container: ListenerMethodContainer
240
+ listener_start_time: float
241
+
242
+
243
+ class Listener(AsyncEventLoopMixin):
244
+ LISTENER_SUCCEEDED_COMSUMPTION = Counter(
245
+ name="listener_succeeded_comsumption",
246
+ documentation="Listener succeeded comsumption",
247
+ labelnames=["queue", "listener_name"],
248
+ )
249
+ LISTENER_FAILED_COMSUMPTION = Counter(
250
+ name="listener_failed_comsumption",
251
+ documentation="Listener failed comsumption",
252
+ labelnames=["queue", "listener_name", "exception"],
253
+ )
254
+ LISTENER_PROCESSING_LATENCY = Summary(
255
+ name="listener_processing_latency",
256
+ documentation="Listener processing latency",
257
+ labelnames=["queue", "listener_name"],
258
+ )
259
+
260
+ def __init__(
261
+ self,
262
+ queue: str,
263
+ listener_name_header_key: str,
264
+ prefetch_count: int = 250,
265
+ durable: bool = True,
266
+ purge_on_startup: bool = False,
267
+ retry_policy: RetryPolicy | None = None,
268
+ ):
269
+ self._queue = queue
270
+ self._listener_name_header_key = listener_name_header_key
271
+ self._prefetch_count = prefetch_count
272
+ self._durable = durable
273
+ self._purge_on_startup = purge_on_startup
274
+ self._retry_policy = retry_policy
275
+ self._listeners: dict[str, ListenerMethodContainer] = {}
276
+ self._logger = LoggerProvider.default().get_logger("rabbitmq.listener")
277
+
278
+ @property
279
+ def queue(self) -> str:
280
+ return self._queue
281
+
282
+ @property
283
+ def listener_name_header_key(self) -> str:
284
+ return self._listener_name_header_key
285
+
286
+ @property
287
+ def listeners(self) -> dict[str, ListenerMethodContainer]:
288
+ return self._listeners
289
+
290
+ def add_listener_method(
291
+ self,
292
+ listener_name: str | None,
293
+ listener_method: Callable,
294
+ retry_policy: RetryPolicy | None = None,
295
+ ):
296
+ self._register_listener_method(
297
+ listener_name=listener_name,
298
+ listener_method=listener_method,
299
+ parameters=signature(listener_method).parameters,
300
+ retry_policy=retry_policy,
301
+ )
302
+
303
+ def _register_listener_method(
304
+ self,
305
+ listener_name: str | None,
306
+ listener_method: Callable,
307
+ parameters: MappingProxyType[str, Parameter],
308
+ retry_policy: RetryPolicy | None = None,
309
+ ):
310
+ listener_name = listener_name or "__default__"
311
+
312
+ if listener_name in self._listeners:
313
+ self._logger.warning(
314
+ "listener with the name `%s` already exists", listener_name
315
+ )
316
+
317
+ dependencies = {}
318
+
319
+ for parameter_name, parameter in parameters.items():
320
+ if parameter.annotation is not Parameter.empty:
321
+ if parameter.annotation is ListenerContext:
322
+ continue
323
+
324
+ dependency = validate_annotation(parameter=parameter)
325
+
326
+ if dependency is not None:
327
+ dependencies[parameter_name] = dependency
328
+
329
+ continue
330
+
331
+ TypeAdapterCache.cache_annotation(parameter.annotation)
332
+
333
+ self._listeners[listener_name] = ListenerMethodContainer(
334
+ listener_method=listener_method,
335
+ parameters=parameters,
336
+ dependencies=dependencies,
337
+ retry_policy=retry_policy,
338
+ )
339
+
340
+ async def configure(
341
+ self,
342
+ connection: AsyncioConnection,
343
+ channel_pool: ChannelPool,
344
+ on_exception_callback: Callable[[ListenerContext, BaseException], bool],
345
+ container: Container,
346
+ logstash: BaseLogstashSender,
347
+ global_retry_policy: RetryPolicy | None = None,
348
+ ):
349
+ self._connection = connection
350
+ self._channel_pool = channel_pool
351
+ self._listener_future = self.loop.create_future()
352
+ self._on_exception_callback = on_exception_callback
353
+ self._container = container
354
+ self._logstash = logstash
355
+ self._global_retry_policy = global_retry_policy
356
+ self._listener_channel = ListenerChannelAdapter(
357
+ connection=connection,
358
+ on_channel_open_callback=self._on_channel_opened,
359
+ on_cancel_callback=self._on_cancelled,
360
+ )
361
+ _ = await self._listener_channel.open()
362
+
363
+ await self._listener_future
364
+
365
+ def _on_channel_opened(self, channel: Channel):
366
+ self._channel = channel
367
+ self._flow_control = FlowControl(channel=self._channel, loop=self.loop)
368
+
369
+ self._declare_queue()
370
+
371
+ def _on_cancelled(self):
372
+ self._declare_queue()
373
+
374
+ def _declare_queue(self):
375
+ try:
376
+ self._channel.queue_declare(
377
+ queue=self._queue,
378
+ durable=self._durable,
379
+ callback=self._on_queue_declared,
380
+ )
381
+ except Exception as e:
382
+ self._fail_listener(e)
383
+
384
+ def _on_queue_declared(self, method: Method):
385
+ del method
386
+
387
+ if self._purge_on_startup:
388
+ try:
389
+ self._channel.queue_purge(
390
+ queue=self._queue,
391
+ callback=lambda _: self._set_prefetch_count(),
392
+ )
393
+ except Exception as e:
394
+ self._fail_listener(e)
395
+ else:
396
+ self._set_prefetch_count()
397
+
398
+ def _set_prefetch_count(self):
399
+ try:
400
+ self._channel.basic_qos(
401
+ prefetch_count=self._prefetch_count,
402
+ callback=lambda _: self._register_listener(),
403
+ )
404
+ except Exception as e:
405
+ self._fail_listener(e)
406
+
407
+ def _register_listener(self):
408
+ try:
409
+ _ = self._channel.basic_consume(
410
+ queue=self._queue,
411
+ auto_ack=True,
412
+ on_message_callback=self._on_message,
413
+ )
414
+ except Exception as e:
415
+ self._fail_listener(e)
416
+
417
+ return
418
+
419
+ if not self._listener_future.done():
420
+ self._listener_future.set_result(None)
421
+
422
+ def _fail_listener(self, exception: Exception):
423
+ if not self._listener_future.done():
424
+ self._listener_future.set_exception(exception)
425
+
426
+ if not self._channel.is_closing or not self._channel.is_closed:
427
+ self._channel.close()
428
+
429
+ def _on_message(
430
+ self,
431
+ channel: Channel,
432
+ method: Basic.Deliver,
433
+ properties: BasicProperties,
434
+ body: bytes,
435
+ ):
436
+ del channel
437
+
438
+ if properties.headers is not None:
439
+ listener_name = (
440
+ properties.headers.get(self._listener_name_header_key)
441
+ or "__default__"
442
+ )
443
+ else:
444
+ listener_name = "__default__"
445
+
446
+ self._logger.debug(
447
+ "message recieved from `%s` queue for listener `%s`",
448
+ self._queue,
449
+ listener_name,
450
+ )
451
+
452
+ listener_method_container = self._listeners.get(listener_name)
453
+
454
+ if listener_method_container is None:
455
+ self._logstash.error(
456
+ message=f"no listener registered with the name `{listener_name}` on queue `{self._queue}`",
457
+ tags=[
458
+ "RabbitMQ",
459
+ self._queue,
460
+ listener_name,
461
+ ],
462
+ extra={
463
+ "serviceType": "RabbitMQ",
464
+ "queue": self._queue,
465
+ "listenerName": listener_name,
466
+ },
467
+ )
468
+
469
+ return
470
+
471
+ listener_message_meta = ListenerMessageMeta(
472
+ body=body,
473
+ method=method,
474
+ properties=properties,
475
+ listener_name=listener_name,
476
+ listener_method_container=listener_method_container,
477
+ listener_start_time=time(),
478
+ )
479
+
480
+ self.loop.run_in_executor(
481
+ executor=None,
482
+ func=partial(self._parse_and_execute, listener_message_meta),
483
+ ).add_done_callback(
484
+ partial(self._on_submitted_listener_error, listener_message_meta)
485
+ )
486
+
487
+ def _on_submitted_listener_error(
488
+ self, listener_message_meta: ListenerMessageMeta, future: Future
489
+ ):
490
+ if future.cancelled():
491
+ return
492
+
493
+ exception = future.exception()
494
+
495
+ if exception is not None:
496
+ self._call_exception_callback(
497
+ exception=exception,
498
+ listener_message_meta=listener_message_meta,
499
+ message=f"error occured while submitting listener callback on listener `{listener_message_meta.listener_name}` and queue `{self._queue}`",
500
+ )
501
+
502
+ def _parse_and_execute(self, listener_message_meta: ListenerMessageMeta):
503
+ try:
504
+ listener_method_args, listener_method_kwargs = self._parse_args(
505
+ listener_message_meta
506
+ )
507
+ except Exception as e:
508
+ self._call_exception_callback(
509
+ exception=e,
510
+ listener_message_meta=listener_message_meta,
511
+ message=f"arguments for listener `{listener_message_meta.listener_name}` in queue `{self._queue}` are not valid",
512
+ )
513
+
514
+ return
515
+
516
+ listener_done_callback = partial(
517
+ self._on_listener_done_executing, listener_message_meta
518
+ )
519
+
520
+ if iscoroutinefunction(
521
+ listener_message_meta.listener_method_container.listener_method
522
+ ):
523
+ self.loop.create_task(
524
+ listener_message_meta.listener_method_container.listener_method(
525
+ *listener_method_args, **listener_method_kwargs
526
+ )
527
+ ).add_done_callback(listener_done_callback)
528
+ else:
529
+ self.loop.run_in_executor(
530
+ executor=None,
531
+ func=partial(
532
+ listener_message_meta.listener_method_container.listener_method,
533
+ *listener_method_args,
534
+ **listener_method_kwargs,
535
+ ),
536
+ ).add_done_callback(listener_done_callback)
537
+
538
+ def _parse_args(
539
+ self, listener_message_meta: ListenerMessageMeta
540
+ ) -> tuple[list, dict]:
541
+ try:
542
+ message = from_json(listener_message_meta.body)
543
+ except:
544
+ message = listener_message_meta.body.decode()
545
+
546
+ assigned_args = []
547
+ listener_method_args = []
548
+ listener_method_kwargs = {}
549
+ next_positional_arg = 0
550
+
551
+ if isinstance(message, dict):
552
+ args = message.get("args")
553
+ kwargs = message.get("kwargs")
554
+
555
+ if isinstance(args, list) and isinstance(kwargs, dict):
556
+ for (
557
+ parameter_name,
558
+ parameter,
559
+ ) in listener_message_meta.listener_method_container.parameters.items():
560
+ dependency_key = listener_message_meta.listener_method_container.dependencies.get(
561
+ parameter_name
562
+ )
563
+ dependency = None
564
+ listener_context = None
565
+
566
+ if dependency_key is not None:
567
+ dependency = self._container.resolve(dependency_key)
568
+
569
+ if parameter.annotation is not Parameter.empty and (
570
+ parameter.annotation is ListenerContext
571
+ or dependency is ListenerContext
572
+ ):
573
+ listener_context = ListenerContext(
574
+ queue=self._queue,
575
+ listener_name=listener_message_meta.listener_name,
576
+ body=listener_message_meta.body,
577
+ flow_control=self._flow_control,
578
+ )
579
+
580
+ if (
581
+ parameter.kind is Parameter.POSITIONAL_ONLY
582
+ or parameter.kind is Parameter.POSITIONAL_OR_KEYWORD
583
+ ):
584
+ if (
585
+ listener_context is not None
586
+ or dependency is not None
587
+ ):
588
+ listener_method_args.append(
589
+ listener_context or dependency
590
+ )
591
+ elif next_positional_arg < len(args):
592
+ listener_method_args.append(
593
+ self._validate_parameter(
594
+ parameter=parameter,
595
+ obj=args[next_positional_arg],
596
+ )
597
+ )
598
+
599
+ next_positional_arg += 1
600
+ elif parameter.name in kwargs:
601
+ listener_method_kwargs[parameter.name] = (
602
+ self._assign_kwarg(
603
+ parameter=parameter,
604
+ assigned_args=assigned_args,
605
+ listener_context=listener_context,
606
+ dependency=dependency,
607
+ kwargs=kwargs,
608
+ )
609
+ )
610
+ elif (
611
+ parameter.name not in assigned_args
612
+ and parameter.default is Parameter.empty
613
+ ):
614
+ raise ValueError(
615
+ f"argument {parameter.name} has no default"
616
+ )
617
+ elif parameter.kind is Parameter.VAR_POSITIONAL:
618
+ listener_method_args.extend(
619
+ [
620
+ self._validate_parameter(
621
+ parameter=parameter,
622
+ obj=arg,
623
+ )
624
+ for arg in args[next_positional_arg:]
625
+ ]
626
+ )
627
+
628
+ next_positional_arg += len(args[next_positional_arg:])
629
+ elif (
630
+ parameter.kind is Parameter.KEYWORD_ONLY
631
+ or parameter.kind is Parameter.POSITIONAL_OR_KEYWORD
632
+ ):
633
+ if (
634
+ listener_context is not None
635
+ or dependency is not None
636
+ ):
637
+ listener_method_kwargs[parameter.name] = (
638
+ listener_context or dependency
639
+ )
640
+ elif parameter.name in kwargs:
641
+ listener_method_kwargs[parameter.name] = (
642
+ self._assign_kwarg(
643
+ parameter=parameter,
644
+ assigned_args=assigned_args,
645
+ listener_context=listener_context,
646
+ dependency=dependency,
647
+ kwargs=kwargs,
648
+ )
649
+ )
650
+ elif (
651
+ parameter.name not in assigned_args
652
+ and parameter.default is Parameter.empty
653
+ ):
654
+ raise ValueError(
655
+ f"argument {parameter.name} has no default"
656
+ )
657
+ elif parameter.kind is Parameter.VAR_KEYWORD:
658
+ listener_method_kwargs.update(
659
+ {
660
+ k: self._validate_parameter(
661
+ parameter=parameter,
662
+ obj=v,
663
+ )
664
+ for k, v in kwargs.items()
665
+ if k not in assigned_args
666
+ }
667
+ )
668
+
669
+ assigned_args.append(parameter.name)
670
+
671
+ return listener_method_args, listener_method_kwargs
672
+
673
+ message_consumed = False
674
+
675
+ for (
676
+ parameter_name,
677
+ parameter,
678
+ ) in listener_message_meta.listener_method_container.parameters.items():
679
+ dependency = listener_message_meta.listener_method_container.dependencies.get(
680
+ parameter_name
681
+ )
682
+
683
+ if parameter.annotation is not Parameter.empty and (
684
+ parameter.annotation is ListenerContext
685
+ or dependency is ListenerContext
686
+ ):
687
+ listener_method_args.append(
688
+ ListenerContext(
689
+ queue=self._queue,
690
+ listener_name=listener_message_meta.listener_name,
691
+ body=listener_message_meta.body,
692
+ flow_control=self._flow_control,
693
+ )
694
+ )
695
+ elif dependency is not None:
696
+ listener_method_args.append(self._container.resolve(dependency))
697
+ elif not message_consumed and message is not None:
698
+ listener_method_args.append(
699
+ self._validate_parameter(
700
+ parameter=parameter,
701
+ obj=message,
702
+ )
703
+ )
704
+
705
+ message_consumed = True
706
+ elif parameter.default is Parameter.empty:
707
+ raise ValueError(f"argument {parameter_name} has no default")
708
+
709
+ return listener_method_args, listener_method_kwargs
710
+
711
+ def _assign_kwarg(
712
+ self,
713
+ parameter: Parameter,
714
+ assigned_args: list[str],
715
+ listener_context: ListenerContext | None,
716
+ dependency: Any | None,
717
+ kwargs: dict[str, Any],
718
+ ) -> ListenerContext | Any:
719
+ if parameter.name in assigned_args:
720
+ raise KeyError(f"argument {parameter.name} already assigned")
721
+
722
+ if listener_context is not None or dependency is not None:
723
+ return listener_context or dependency
724
+
725
+ return self._validate_parameter(
726
+ parameter=parameter,
727
+ obj=kwargs[parameter.name],
728
+ )
729
+
730
+ def _validate_parameter(self, parameter: Parameter, obj: Any) -> Any:
731
+ annotation = parameter.annotation
732
+
733
+ if annotation is Parameter.empty:
734
+ return obj
735
+
736
+ annotation_type_adapter = TypeAdapterCache.get_type_adapter(annotation)
737
+
738
+ return annotation_type_adapter.validate_python(obj)
739
+
740
+ def _on_listener_done_executing(
741
+ self,
742
+ listener_message_meta: ListenerMessageMeta,
743
+ task_or_future: Task | Future,
744
+ ):
745
+ if task_or_future.cancelled():
746
+ return
747
+
748
+ self._observe_listener_time(listener_message_meta)
749
+
750
+ exception = task_or_future.exception()
751
+
752
+ if exception is not None:
753
+ if listener_message_meta.properties.reply_to is None:
754
+ retry_policy = (
755
+ listener_message_meta.listener_method_container.retry_policy
756
+ or self._retry_policy
757
+ or self._global_retry_policy
758
+ )
759
+
760
+ if retry_policy is not None and any(
761
+ exception_type in retry_policy.exceptions
762
+ for exception_type in type(exception).mro()
763
+ ):
764
+ times_rejected = None
765
+
766
+ if listener_message_meta.properties.headers is not None:
767
+ try:
768
+ times_rejected = int(
769
+ listener_message_meta.properties.headers.get(
770
+ "times_rejected"
771
+ )
772
+ )
773
+ except:
774
+ pass
775
+
776
+ if times_rejected is None:
777
+ times_rejected = 0
778
+
779
+ if retry_policy.can_retry(times_rejected):
780
+ self._reject_message(
781
+ listener_message_meta=listener_message_meta,
782
+ retry_policy=retry_policy,
783
+ times_rejected=times_rejected,
784
+ )
785
+
786
+ return
787
+
788
+ self._call_exception_callback(
789
+ exception=exception,
790
+ listener_message_meta=listener_message_meta,
791
+ message=f"error occured while executing listener `{listener_message_meta.listener_name}` in queue `{self._queue}`",
792
+ )
793
+
794
+ if (
795
+ listener_message_meta.properties.reply_to is not None
796
+ and exception is None
797
+ ):
798
+ self._reply_response(
799
+ listener_message_meta=listener_message_meta,
800
+ response=task_or_future.result(),
801
+ )
802
+
803
+ self._logger.debug(
804
+ "message from queue `%s` consumed by listener `%s`",
805
+ self.queue,
806
+ listener_message_meta.listener_name,
807
+ )
808
+
809
+ if exception is not None:
810
+ self.LISTENER_FAILED_COMSUMPTION.labels(
811
+ queue=self._queue,
812
+ listener_name=listener_message_meta.listener_name,
813
+ exception=exception.__class__.__name__,
814
+ ).inc()
815
+ else:
816
+ self.LISTENER_SUCCEEDED_COMSUMPTION.labels(
817
+ queue=self._queue,
818
+ listener_name=listener_message_meta.listener_name,
819
+ ).inc()
820
+
821
+ def _observe_listener_time(
822
+ self, listener_message_meta: ListenerMessageMeta
823
+ ):
824
+ self.LISTENER_PROCESSING_LATENCY.labels(
825
+ queue=self._queue, listener_name=listener_message_meta.listener_name
826
+ ).observe(listener_message_meta.listener_start_time - time())
827
+
828
+ def _call_exception_callback(
829
+ self,
830
+ exception: BaseException,
831
+ listener_message_meta: ListenerMessageMeta,
832
+ message: str | None = None,
833
+ ):
834
+ context_dispose_callback = None
835
+ rpc_reply = None
836
+
837
+ if listener_message_meta.properties.reply_to is not None:
838
+
839
+ def on_context_disposed(context: ListenerContext):
840
+ assert listener_message_meta.properties.reply_to is not None
841
+ assert context.rpc_reply is not None
842
+
843
+ if not context.rpc_reply.replied:
844
+ self._reply_response(
845
+ listener_message_meta=listener_message_meta,
846
+ response=self._reponse_from_exception(exception),
847
+ )
848
+
849
+ context_dispose_callback = on_context_disposed
850
+ rpc_reply = RpcReply(
851
+ channel_pool=self._channel_pool,
852
+ reply_to=listener_message_meta.properties.reply_to,
853
+ correlation_id=listener_message_meta.properties.correlation_id,
854
+ )
855
+
856
+ try:
857
+ exception_callback_succeeded = self._on_exception_callback(
858
+ ListenerContext(
859
+ queue=self._queue,
860
+ listener_name=listener_message_meta.listener_name,
861
+ body=listener_message_meta.body,
862
+ flow_control=self._flow_control,
863
+ rpc_reply=rpc_reply,
864
+ context_dispose_callback=context_dispose_callback,
865
+ ),
866
+ exception,
867
+ )
868
+ except:
869
+ self._logstash.exception(
870
+ message=f"error occured while invoking rabbitmq exception handler callback in listener `{listener_message_meta.listener_name}` and queue `{self._queue}`",
871
+ tags=[
872
+ "RabbitMQ",
873
+ self._queue,
874
+ listener_message_meta.listener_name,
875
+ ],
876
+ extra={
877
+ "serviceType": "RabbitMQ",
878
+ "queue": self._queue,
879
+ "listenerName": listener_message_meta.listener_name,
880
+ "section": "exceptionHandlerCallback",
881
+ "raisedException": exception.__class__.__name__,
882
+ },
883
+ )
884
+
885
+ return
886
+
887
+ if not exception_callback_succeeded:
888
+ self._logstash.exception(
889
+ message=(
890
+ message
891
+ or f"error occured while handling event in listener `{listener_message_meta.listener_name}` and queue `{self._queue}`"
892
+ ),
893
+ tags=[
894
+ "RabbitMQ",
895
+ self._queue,
896
+ listener_message_meta.listener_name,
897
+ ],
898
+ extra={
899
+ "serviceType": "RabbitMQ",
900
+ "queue": self._queue,
901
+ "listenerName": listener_message_meta.listener_name,
902
+ "raisedException": exception.__class__.__name__,
903
+ },
904
+ )
905
+
906
+ def _reject_message(
907
+ self,
908
+ listener_message_meta: ListenerMessageMeta,
909
+ retry_policy: RetryPolicy,
910
+ times_rejected: int,
911
+ ):
912
+ message_redelivery_delay = retry_policy.next_delay(times_rejected)
913
+
914
+ self._logger.debug(
915
+ "message will be redelivered to listenr `%s` on queue `%s` after `%f` seconds, times redelivered `%d`",
916
+ listener_message_meta.listener_name,
917
+ self._queue,
918
+ message_redelivery_delay,
919
+ times_rejected,
920
+ )
921
+ self.loop.call_later(
922
+ delay=message_redelivery_delay,
923
+ callback=partial(
924
+ self._on_time_to_redeliver_message,
925
+ listener_message_meta,
926
+ times_rejected,
927
+ ),
928
+ )
929
+
930
+ def _on_time_to_redeliver_message(
931
+ self,
932
+ listener_message_meta: ListenerMessageMeta,
933
+ times_rejected: int,
934
+ ):
935
+ self.loop.create_task(self._channel_pool.get()).add_done_callback(
936
+ partial(
937
+ self._on_redelivery_channel_found,
938
+ listener_message_meta,
939
+ times_rejected,
940
+ )
941
+ )
942
+
943
+ def _on_redelivery_channel_found(
944
+ self,
945
+ listener_message_meta: ListenerMessageMeta,
946
+ times_rejected: int,
947
+ task: Task[BaseChannel],
948
+ ):
949
+ if task.cancelled():
950
+ return
951
+
952
+ exception = task.exception()
953
+
954
+ if exception is not None:
955
+ self._call_exception_callback(
956
+ exception=exception,
957
+ listener_message_meta=listener_message_meta,
958
+ message=f"error occured while getting channel from pool in listener `{listener_message_meta.listener_name}` and queue `{self._queue}` after rejecteed {times_rejected} times",
959
+ )
960
+
961
+ return
962
+
963
+ headers = {
964
+ self._listener_name_header_key: listener_message_meta.listener_name,
965
+ "times_rejected": times_rejected + 1,
966
+ }
967
+
968
+ if listener_message_meta.properties.headers is None:
969
+ listener_message_meta.properties.headers = headers
970
+ else:
971
+ listener_message_meta.properties.headers.update(headers)
972
+
973
+ try:
974
+ with task.result() as channel:
975
+ channel.basic_publish(
976
+ exchange=DEFAULT_EXCHANGE,
977
+ routing_key=self._queue,
978
+ body=listener_message_meta.body,
979
+ properties=listener_message_meta.properties,
980
+ )
981
+ except Exception as e:
982
+ self._call_exception_callback(
983
+ exception=e,
984
+ listener_message_meta=listener_message_meta,
985
+ message=f"error occured while sending event for redelivery in listener `{listener_message_meta.listener_name}` and queue `{self._queue}` after rejecteed {times_rejected} times",
986
+ )
987
+
988
+ return
989
+
990
+ self._logger.debug(
991
+ "message queued for redelivery to `%s` on queue `%s`, times redelivered `%d`",
992
+ listener_message_meta.listener_name,
993
+ self._queue,
994
+ times_rejected + 1,
995
+ )
996
+
997
+ def _reply_response(
998
+ self,
999
+ listener_message_meta: ListenerMessageMeta,
1000
+ response: Any,
1001
+ ):
1002
+ assert listener_message_meta.properties.reply_to is not None
1003
+
1004
+ reponse_properties = BasicProperties(content_type="application/json")
1005
+
1006
+ if listener_message_meta.properties.correlation_id is None:
1007
+ self._logger.warning(
1008
+ "`correlation_id` property not supplied for listener `%s` and queue `%s`",
1009
+ listener_message_meta.listener_name,
1010
+ self._queue,
1011
+ )
1012
+ else:
1013
+ reponse_properties.correlation_id = (
1014
+ listener_message_meta.properties.correlation_id
1015
+ )
1016
+
1017
+ try:
1018
+ response_body = to_json(response)
1019
+ except Exception as e:
1020
+ self._call_exception_callback(
1021
+ exception=e,
1022
+ listener_message_meta=listener_message_meta,
1023
+ message=f"listener response is not json serializable in listener `{listener_message_meta.listener_name}` and queue `{self._queue}`",
1024
+ )
1025
+
1026
+ return
1027
+
1028
+ self.loop.create_task(self._channel_pool.get()).add_done_callback(
1029
+ partial(
1030
+ self._on_reply_channel_found,
1031
+ listener_message_meta,
1032
+ response_body,
1033
+ reponse_properties,
1034
+ )
1035
+ )
1036
+
1037
+ def _reponse_from_exception(self, exception: BaseException) -> dict:
1038
+ match exception:
1039
+ case RabbitMQException() as rabbitmq_exception:
1040
+ code = rabbitmq_exception.code
1041
+ message = rabbitmq_exception.message
1042
+ case ValidationError() as validation_error:
1043
+ code = 0
1044
+ message = validation_error.json()
1045
+ case unknown_execption:
1046
+ code = 0
1047
+ message = str(unknown_execption)
1048
+
1049
+ return {
1050
+ "exception": True,
1051
+ "code": code,
1052
+ "message": message,
1053
+ }
1054
+
1055
+ def _on_reply_channel_found(
1056
+ self,
1057
+ listener_message_meta: ListenerMessageMeta,
1058
+ response_body: bytes,
1059
+ response_properties: BasicProperties,
1060
+ task: Task[BaseChannel],
1061
+ ):
1062
+ if task.cancelled():
1063
+ return
1064
+
1065
+ exception = task.exception()
1066
+
1067
+ if exception is not None:
1068
+ self._call_exception_callback(
1069
+ exception=exception,
1070
+ listener_message_meta=listener_message_meta,
1071
+ message=f"error occured while getting channel for publishing response in listener `{listener_message_meta.listener_name}` and queue `{self._queue}`",
1072
+ )
1073
+
1074
+ return
1075
+
1076
+ try:
1077
+ with task.result() as channel:
1078
+ channel.basic_publish(
1079
+ exchange=DEFAULT_EXCHANGE,
1080
+ routing_key=listener_message_meta.properties.reply_to,
1081
+ properties=response_properties,
1082
+ body=response_body,
1083
+ )
1084
+ except Exception as e:
1085
+ self._call_exception_callback(
1086
+ exception=e,
1087
+ listener_message_meta=listener_message_meta,
1088
+ message=f"error occured while publishing response to rpc call in listener `{listener_message_meta.listener_name}` and queue `{self._queue}`",
1089
+ )
1090
+
1091
+ return
1092
+
1093
+ self._logger.debug(
1094
+ "sent a reply to `%s` for request from queue `%s` and listener `%s`",
1095
+ listener_message_meta.properties.reply_to,
1096
+ self._queue,
1097
+ listener_message_meta.listener_name,
1098
+ )
1099
+
1100
+ def __call__(self, listener: type[L]) -> type[L]:
1101
+ setattr(listener, LISTENER_ATTRIBUTE, self)
1102
+
1103
+ return listener
1104
+
1105
+
1106
+ class Consumer(Listener):
1107
+ def __init__(
1108
+ self,
1109
+ queue: str,
1110
+ prefetch_count: int = 250,
1111
+ retry_policy: RetryPolicy | None = None,
1112
+ ):
1113
+ super().__init__(
1114
+ queue=queue,
1115
+ listener_name_header_key="target",
1116
+ prefetch_count=prefetch_count,
1117
+ retry_policy=retry_policy,
1118
+ )
1119
+
1120
+ def consume(
1121
+ self, target: str | None = None, retry_policy: RetryPolicy | None = None
1122
+ ) -> Callable[[Callable], Callable]:
1123
+ def wrapper(consumer_method: Callable):
1124
+ if not callable(consumer_method):
1125
+ raise TypeError(
1126
+ f"consumer method argument not a callable, got {type(consumer_method)}"
1127
+ )
1128
+
1129
+ self._register_listener_method(
1130
+ listener_name=target,
1131
+ listener_method=consumer_method,
1132
+ parameters=signature(consumer_method).parameters,
1133
+ retry_policy=retry_policy,
1134
+ )
1135
+
1136
+ return consumer_method
1137
+
1138
+ return wrapper
1139
+
1140
+
1141
+ class RpcWorker(Listener):
1142
+ def __init__(self, queue: str, prefetch_count: int = 250):
1143
+ super().__init__(
1144
+ queue=queue,
1145
+ listener_name_header_key="procedure",
1146
+ prefetch_count=prefetch_count,
1147
+ purge_on_startup=True,
1148
+ )
1149
+
1150
+ def execute(
1151
+ self, procedure: str | None = None
1152
+ ) -> Callable[[Callable], Callable]:
1153
+ def wrapper(worker_method: Callable):
1154
+ if not callable(worker_method):
1155
+ raise TypeError(
1156
+ f"worker method argument not a callable, got {type(worker_method)}"
1157
+ )
1158
+
1159
+ self._register_listener_method(
1160
+ listener_name=procedure,
1161
+ listener_method=worker_method,
1162
+ parameters=signature(worker_method).parameters,
1163
+ )
1164
+
1165
+ return worker_method
1166
+
1167
+ return wrapper
1168
+
1169
+
1170
+ @dataclass
1171
+ class ListenerMethodMeta:
1172
+ listener_name: str | None = None
1173
+ retry_policy: RetryPolicy | None = None
1174
+
1175
+
1176
+ class ListenerBase:
1177
+ def get_inner_listener(self) -> Listener:
1178
+ listener = getattr(self, LISTENER_ATTRIBUTE, None)
1179
+
1180
+ if listener is None or not isinstance(listener, Listener):
1181
+ raise TypeError(
1182
+ f"{self.__class__.__name__} not a listener, possibly no annotated with either `Consumer` or `RpcWorker`"
1183
+ )
1184
+
1185
+ return listener
1186
+
1187
+ def register_listener_methods(self) -> Listener:
1188
+ listener = self.get_inner_listener()
1189
+ listener_method_attribute = None
1190
+
1191
+ for attribute_name in dir(self):
1192
+ attribute = getattr(self, attribute_name, None)
1193
+
1194
+ if attribute is None:
1195
+ continue
1196
+
1197
+ listener_method_attribute, listener_method, listener_method_meta = (
1198
+ self._validate_listener_method_attribute(
1199
+ attribute=attribute,
1200
+ listener_method_attribute=CONSUMER_ATTRIBUTE,
1201
+ previous_listener_method_attribute=listener_method_attribute,
1202
+ )
1203
+ )
1204
+
1205
+ if listener_method is None or listener_method_meta is None:
1206
+ (
1207
+ listener_method_attribute,
1208
+ listener_method,
1209
+ listener_method_meta,
1210
+ ) = self._validate_listener_method_attribute(
1211
+ attribute=attribute,
1212
+ listener_method_attribute=RPC_WORKER_ATTRIBUTE,
1213
+ previous_listener_method_attribute=listener_method_attribute,
1214
+ )
1215
+
1216
+ if listener_method is None or listener_method_meta is None:
1217
+ continue
1218
+
1219
+ listener.add_listener_method(
1220
+ listener_name=listener_method_meta.listener_name,
1221
+ listener_method=listener_method,
1222
+ retry_policy=listener_method_meta.retry_policy,
1223
+ )
1224
+
1225
+ return listener
1226
+
1227
+ def _validate_listener_method_attribute(
1228
+ self,
1229
+ attribute: Any,
1230
+ listener_method_attribute: str,
1231
+ previous_listener_method_attribute: str | None,
1232
+ ) -> tuple[str | None, Callable | None, ListenerMethodMeta | None]:
1233
+ listener_method_meta = getattr(
1234
+ attribute, listener_method_attribute, None
1235
+ )
1236
+
1237
+ if listener_method_meta is None:
1238
+ return previous_listener_method_attribute or None, None, None
1239
+
1240
+ if not isinstance(listener_method_meta, ListenerMethodMeta):
1241
+ raise TypeError(
1242
+ f"expected `{listener_method_attribute}` to by of type `ListenerMethodMeta`, got {type(listener_method_meta)}"
1243
+ )
1244
+
1245
+ if (
1246
+ previous_listener_method_attribute is not None
1247
+ and listener_method_attribute != previous_listener_method_attribute
1248
+ ):
1249
+ raise ValueError("listener methods cannot be of different type")
1250
+
1251
+ if not callable(attribute):
1252
+ raise TypeError(
1253
+ f"object annotated with `{listener_method_attribute}` is not callable"
1254
+ )
1255
+
1256
+ return listener_method_attribute, attribute, listener_method_meta
1257
+
1258
+
1259
+ def consume(
1260
+ target: str | None = None, retry_policy: RetryPolicy | None = None
1261
+ ) -> Callable[[Callable], Callable]:
1262
+ def wrapper(consumer_method: Callable) -> Callable:
1263
+ if not callable(consumer_method):
1264
+ raise TypeError(
1265
+ f"consumer method argument not a callable, got {type(consumer_method)}"
1266
+ )
1267
+
1268
+ setattr(
1269
+ consumer_method,
1270
+ CONSUMER_ATTRIBUTE,
1271
+ ListenerMethodMeta(listener_name=target, retry_policy=retry_policy),
1272
+ )
1273
+
1274
+ return consumer_method
1275
+
1276
+ return wrapper
1277
+
1278
+
1279
+ def execute(procedure: str | None = None) -> Callable[[Callable], Callable]:
1280
+ def wrapper(worker_method: Callable) -> Callable:
1281
+ if not callable(worker_method):
1282
+ raise TypeError(
1283
+ f"worker method argument not a callable, got {type(worker_method)}"
1284
+ )
1285
+
1286
+ setattr(
1287
+ worker_method, RPC_WORKER_ATTRIBUTE, ListenerMethodMeta(procedure)
1288
+ )
1289
+
1290
+ return worker_method
1291
+
1292
+ return wrapper