easy-faststream 0.2.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.
- easy_faststream/__init__.py +17 -0
- easy_faststream/app.py +467 -0
- easy_faststream/config.py +26 -0
- easy_faststream/context.py +13 -0
- easy_faststream/exceptions.py +11 -0
- easy_faststream/retry.py +22 -0
- easy_faststream/topology.py +83 -0
- easy_faststream/types.py +14 -0
- easy_faststream-0.2.0.dist-info/METADATA +84 -0
- easy_faststream-0.2.0.dist-info/RECORD +12 -0
- easy_faststream-0.2.0.dist-info/WHEEL +4 -0
- easy_faststream-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from easy_faststream.app import StreamApp
|
|
2
|
+
from easy_faststream.config import StreamSettings
|
|
3
|
+
from easy_faststream.context import MessageContext
|
|
4
|
+
from easy_faststream.exceptions import NonRetryableError, RetryableError
|
|
5
|
+
from easy_faststream.retry import RetryPolicy
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MessageContext",
|
|
9
|
+
"NonRetryableError",
|
|
10
|
+
"RetryPolicy",
|
|
11
|
+
"RetryableError",
|
|
12
|
+
"StreamApp",
|
|
13
|
+
"StreamSettings",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
|
easy_faststream/app.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from faststream import AckPolicy, FastStream
|
|
8
|
+
from faststream.rabbit import (
|
|
9
|
+
ExchangeType,
|
|
10
|
+
RabbitBroker,
|
|
11
|
+
RabbitExchange,
|
|
12
|
+
RabbitMessage,
|
|
13
|
+
RabbitQueue,
|
|
14
|
+
)
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from easy_faststream.config import StreamSettings
|
|
18
|
+
from easy_faststream.context import MessageContext
|
|
19
|
+
from easy_faststream.exceptions import NonRetryableError
|
|
20
|
+
from easy_faststream.retry import RetryPolicy
|
|
21
|
+
from easy_faststream.topology import (
|
|
22
|
+
RetryDestination,
|
|
23
|
+
build_retry_destinations,
|
|
24
|
+
declare_retry_topology,
|
|
25
|
+
)
|
|
26
|
+
from easy_faststream.types import ConsumerHandler, SchemaT
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("easy_faststream")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StreamApp:
|
|
32
|
+
RETRY_COUNT_HEADER = "x-easy-retry-count"
|
|
33
|
+
RETRY_HISTORY_HEADER = "x-easy-retry-history"
|
|
34
|
+
|
|
35
|
+
def __init__(self, settings: StreamSettings | None = None) -> None:
|
|
36
|
+
self.settings = settings or StreamSettings()
|
|
37
|
+
|
|
38
|
+
self.broker = RabbitBroker(
|
|
39
|
+
self.settings.rabbitmq_url,
|
|
40
|
+
app_id=self.settings.app_name,
|
|
41
|
+
graceful_timeout=self.settings.graceful_timeout,
|
|
42
|
+
)
|
|
43
|
+
self.app = FastStream(self.broker)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_env(cls) -> "StreamApp":
|
|
47
|
+
return cls(StreamSettings())
|
|
48
|
+
|
|
49
|
+
def consumer(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
event: str,
|
|
53
|
+
schema: type[SchemaT],
|
|
54
|
+
queue: str | None = None,
|
|
55
|
+
exchange: str | None = None,
|
|
56
|
+
routing_key: str | None = None,
|
|
57
|
+
retries: int | None = None,
|
|
58
|
+
retry_delay: float | None = None,
|
|
59
|
+
retry_backoff: float | None = None,
|
|
60
|
+
):
|
|
61
|
+
queue_name = queue or event
|
|
62
|
+
exchange_name = exchange or self.settings.default_exchange
|
|
63
|
+
route = routing_key or event
|
|
64
|
+
|
|
65
|
+
retry_policy = RetryPolicy(
|
|
66
|
+
max_retries=(
|
|
67
|
+
self.settings.max_retries
|
|
68
|
+
if retries is None
|
|
69
|
+
else retries
|
|
70
|
+
),
|
|
71
|
+
delay_seconds=(
|
|
72
|
+
self.settings.retry_delay_seconds
|
|
73
|
+
if retry_delay is None
|
|
74
|
+
else retry_delay
|
|
75
|
+
),
|
|
76
|
+
backoff=(
|
|
77
|
+
self.settings.retry_backoff
|
|
78
|
+
if retry_backoff is None
|
|
79
|
+
else retry_backoff
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
retry_destinations = build_retry_destinations(
|
|
84
|
+
source_queue=queue_name,
|
|
85
|
+
source_exchange=exchange_name,
|
|
86
|
+
source_routing_key=route,
|
|
87
|
+
retry_exchange=self.settings.retry_exchange,
|
|
88
|
+
queue_suffix=self.settings.retry_queue_suffix,
|
|
89
|
+
policy=retry_policy,
|
|
90
|
+
)
|
|
91
|
+
retry_map = {
|
|
92
|
+
destination.retry_number: destination
|
|
93
|
+
for destination in retry_destinations
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
source_exchange = RabbitExchange(
|
|
97
|
+
exchange_name,
|
|
98
|
+
type=ExchangeType.TOPIC,
|
|
99
|
+
durable=True,
|
|
100
|
+
)
|
|
101
|
+
source_queue = RabbitQueue(
|
|
102
|
+
queue_name,
|
|
103
|
+
durable=True,
|
|
104
|
+
routing_key=route,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@self.app.after_startup
|
|
108
|
+
async def setup_retry_topology() -> None:
|
|
109
|
+
await declare_retry_topology(
|
|
110
|
+
broker=self.broker,
|
|
111
|
+
retry_exchange_name=self.settings.retry_exchange,
|
|
112
|
+
destinations=retry_destinations,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
logger.info(
|
|
116
|
+
"retry_topology_declared",
|
|
117
|
+
extra={
|
|
118
|
+
"event": event,
|
|
119
|
+
"queue": queue_name,
|
|
120
|
+
"retry_queues": len(retry_destinations),
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def decorator(handler: ConsumerHandler[SchemaT]):
|
|
125
|
+
parameter_count = len(inspect.signature(handler).parameters)
|
|
126
|
+
|
|
127
|
+
if parameter_count not in (1, 2):
|
|
128
|
+
raise TypeError(
|
|
129
|
+
f"{handler.__name__} must accept "
|
|
130
|
+
"(message) or (message, context)"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@self.broker.subscriber(
|
|
134
|
+
source_queue,
|
|
135
|
+
source_exchange,
|
|
136
|
+
ack_policy=AckPolicy.MANUAL,
|
|
137
|
+
)
|
|
138
|
+
async def wrapped(
|
|
139
|
+
raw_message: Any,
|
|
140
|
+
message: RabbitMessage,
|
|
141
|
+
) -> None:
|
|
142
|
+
context = self._make_context(
|
|
143
|
+
message=message,
|
|
144
|
+
queue=queue_name,
|
|
145
|
+
exchange=exchange_name,
|
|
146
|
+
routing_key=route,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
retry_count = self._read_retry_count(
|
|
150
|
+
context.headers
|
|
151
|
+
)
|
|
152
|
+
retry_history = self._read_retry_history(
|
|
153
|
+
context.headers
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
payload = schema.model_validate(raw_message)
|
|
158
|
+
except ValidationError as error:
|
|
159
|
+
await self._dead_letter_or_nack(
|
|
160
|
+
raw_message=raw_message,
|
|
161
|
+
message=message,
|
|
162
|
+
context=context,
|
|
163
|
+
error=error,
|
|
164
|
+
retry_count=retry_count,
|
|
165
|
+
retry_history=retry_history,
|
|
166
|
+
error_kind="validation",
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
if parameter_count == 1:
|
|
172
|
+
await handler(payload)
|
|
173
|
+
else:
|
|
174
|
+
await handler(payload, context)
|
|
175
|
+
|
|
176
|
+
except NonRetryableError as error:
|
|
177
|
+
await self._dead_letter_or_nack(
|
|
178
|
+
raw_message=raw_message,
|
|
179
|
+
message=message,
|
|
180
|
+
context=context,
|
|
181
|
+
error=error,
|
|
182
|
+
retry_count=retry_count,
|
|
183
|
+
retry_history=retry_history,
|
|
184
|
+
error_kind="non_retryable",
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
except Exception as error:
|
|
189
|
+
if retry_count < retry_policy.max_retries:
|
|
190
|
+
next_retry = retry_count + 1
|
|
191
|
+
destination = retry_map[next_retry]
|
|
192
|
+
|
|
193
|
+
retry_history.append(
|
|
194
|
+
self._retry_record(
|
|
195
|
+
retry_number=next_retry,
|
|
196
|
+
destination=destination,
|
|
197
|
+
error=error,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
await self._schedule_retry_or_nack(
|
|
202
|
+
raw_message=raw_message,
|
|
203
|
+
message=message,
|
|
204
|
+
context=context,
|
|
205
|
+
error=error,
|
|
206
|
+
destination=destination,
|
|
207
|
+
retry_history=retry_history,
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
await self._dead_letter_or_nack(
|
|
212
|
+
raw_message=raw_message,
|
|
213
|
+
message=message,
|
|
214
|
+
context=context,
|
|
215
|
+
error=error,
|
|
216
|
+
retry_count=retry_count,
|
|
217
|
+
retry_history=retry_history,
|
|
218
|
+
error_kind="processing",
|
|
219
|
+
)
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
await message.ack()
|
|
223
|
+
|
|
224
|
+
logger.info(
|
|
225
|
+
"message_processed",
|
|
226
|
+
extra={
|
|
227
|
+
"event": event,
|
|
228
|
+
"message_id": context.message_id,
|
|
229
|
+
"retry_count": retry_count,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return handler
|
|
234
|
+
|
|
235
|
+
return decorator
|
|
236
|
+
|
|
237
|
+
async def _schedule_retry_or_nack(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
raw_message: Any,
|
|
241
|
+
message: RabbitMessage,
|
|
242
|
+
context: MessageContext,
|
|
243
|
+
error: Exception,
|
|
244
|
+
destination: RetryDestination,
|
|
245
|
+
retry_history: list[dict[str, Any]],
|
|
246
|
+
) -> None:
|
|
247
|
+
headers = dict(context.headers)
|
|
248
|
+
headers[self.RETRY_COUNT_HEADER] = (
|
|
249
|
+
destination.retry_number
|
|
250
|
+
)
|
|
251
|
+
headers[self.RETRY_HISTORY_HEADER] = json.dumps(
|
|
252
|
+
retry_history,
|
|
253
|
+
ensure_ascii=False,
|
|
254
|
+
default=str,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
retry_exchange = RabbitExchange(
|
|
258
|
+
self.settings.retry_exchange,
|
|
259
|
+
type=ExchangeType.TOPIC,
|
|
260
|
+
durable=True,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
await self.broker.publish(
|
|
265
|
+
raw_message,
|
|
266
|
+
exchange=retry_exchange,
|
|
267
|
+
routing_key=destination.routing_key,
|
|
268
|
+
message_id=context.message_id,
|
|
269
|
+
correlation_id=context.correlation_id,
|
|
270
|
+
headers=headers,
|
|
271
|
+
persist=True,
|
|
272
|
+
)
|
|
273
|
+
except Exception:
|
|
274
|
+
logger.exception(
|
|
275
|
+
"retry_publish_failed",
|
|
276
|
+
extra={
|
|
277
|
+
"message_id": context.message_id,
|
|
278
|
+
"retry_number": destination.retry_number,
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
await message.nack(requeue=True)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
await message.ack()
|
|
285
|
+
|
|
286
|
+
logger.warning(
|
|
287
|
+
"message_scheduled_for_retry",
|
|
288
|
+
extra={
|
|
289
|
+
"message_id": context.message_id,
|
|
290
|
+
"retry_number": destination.retry_number,
|
|
291
|
+
"delay_seconds": destination.delay_seconds,
|
|
292
|
+
"error_type": type(error).__name__,
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def _dead_letter_or_nack(
|
|
297
|
+
self,
|
|
298
|
+
*,
|
|
299
|
+
raw_message: Any,
|
|
300
|
+
message: RabbitMessage,
|
|
301
|
+
context: MessageContext,
|
|
302
|
+
error: Exception,
|
|
303
|
+
retry_count: int,
|
|
304
|
+
retry_history: list[dict[str, Any]],
|
|
305
|
+
error_kind: str,
|
|
306
|
+
) -> None:
|
|
307
|
+
envelope = {
|
|
308
|
+
"original_payload": self._json_safe(raw_message),
|
|
309
|
+
"failure": {
|
|
310
|
+
"kind": error_kind,
|
|
311
|
+
"error_type": type(error).__name__,
|
|
312
|
+
"error_message": str(error),
|
|
313
|
+
"retry_count": retry_count,
|
|
314
|
+
"retry_history": retry_history,
|
|
315
|
+
"failed_at": datetime.now(UTC).isoformat(),
|
|
316
|
+
},
|
|
317
|
+
"source": {
|
|
318
|
+
"queue": context.queue,
|
|
319
|
+
"exchange": context.exchange,
|
|
320
|
+
"routing_key": context.routing_key,
|
|
321
|
+
"message_id": context.message_id,
|
|
322
|
+
"correlation_id": context.correlation_id,
|
|
323
|
+
"headers": self._json_safe(context.headers),
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
dlq_name = (
|
|
328
|
+
f"{context.queue}"
|
|
329
|
+
f"{self.settings.dlq_queue_suffix}"
|
|
330
|
+
)
|
|
331
|
+
dlq_exchange = RabbitExchange(
|
|
332
|
+
self.settings.dlq_exchange,
|
|
333
|
+
type=ExchangeType.TOPIC,
|
|
334
|
+
durable=True,
|
|
335
|
+
)
|
|
336
|
+
dlq_queue = RabbitQueue(
|
|
337
|
+
dlq_name,
|
|
338
|
+
durable=True,
|
|
339
|
+
routing_key=context.routing_key,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
declared_exchange = (
|
|
344
|
+
await self.broker.declare_exchange(
|
|
345
|
+
dlq_exchange
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
declared_queue = await self.broker.declare_queue(
|
|
349
|
+
dlq_queue
|
|
350
|
+
)
|
|
351
|
+
await declared_queue.bind(
|
|
352
|
+
declared_exchange,
|
|
353
|
+
routing_key=context.routing_key,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
await self.broker.publish(
|
|
357
|
+
envelope,
|
|
358
|
+
exchange=dlq_exchange,
|
|
359
|
+
routing_key=context.routing_key,
|
|
360
|
+
message_id=context.message_id,
|
|
361
|
+
correlation_id=context.correlation_id,
|
|
362
|
+
headers={
|
|
363
|
+
"x-original-queue": context.queue,
|
|
364
|
+
"x-retry-count": retry_count,
|
|
365
|
+
"x-error-type": type(error).__name__,
|
|
366
|
+
},
|
|
367
|
+
persist=True,
|
|
368
|
+
)
|
|
369
|
+
except Exception:
|
|
370
|
+
logger.exception(
|
|
371
|
+
"dead_letter_publish_failed",
|
|
372
|
+
extra={
|
|
373
|
+
"message_id": context.message_id,
|
|
374
|
+
"queue": context.queue,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
await message.nack(requeue=True)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
await message.ack()
|
|
381
|
+
|
|
382
|
+
logger.error(
|
|
383
|
+
"message_dead_lettered",
|
|
384
|
+
extra={
|
|
385
|
+
"message_id": context.message_id,
|
|
386
|
+
"queue": dlq_name,
|
|
387
|
+
"retry_count": retry_count,
|
|
388
|
+
"error_type": type(error).__name__,
|
|
389
|
+
},
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
@classmethod
|
|
393
|
+
def _read_retry_count(
|
|
394
|
+
cls,
|
|
395
|
+
headers: dict[str, Any],
|
|
396
|
+
) -> int:
|
|
397
|
+
value = headers.get(cls.RETRY_COUNT_HEADER, 0)
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
return max(0, int(value))
|
|
401
|
+
except (TypeError, ValueError):
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
@classmethod
|
|
405
|
+
def _read_retry_history(
|
|
406
|
+
cls,
|
|
407
|
+
headers: dict[str, Any],
|
|
408
|
+
) -> list[dict[str, Any]]:
|
|
409
|
+
value = headers.get(cls.RETRY_HISTORY_HEADER)
|
|
410
|
+
|
|
411
|
+
if value is None:
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
if isinstance(value, bytes):
|
|
415
|
+
value = value.decode("utf-8")
|
|
416
|
+
|
|
417
|
+
if not isinstance(value, str):
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
history = json.loads(value)
|
|
422
|
+
except (TypeError, json.JSONDecodeError):
|
|
423
|
+
return []
|
|
424
|
+
|
|
425
|
+
return history if isinstance(history, list) else []
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def _retry_record(
|
|
429
|
+
*,
|
|
430
|
+
retry_number: int,
|
|
431
|
+
destination: RetryDestination,
|
|
432
|
+
error: Exception,
|
|
433
|
+
) -> dict[str, Any]:
|
|
434
|
+
return {
|
|
435
|
+
"retry_number": retry_number,
|
|
436
|
+
"error_type": type(error).__name__,
|
|
437
|
+
"error_message": str(error),
|
|
438
|
+
"delay_seconds": destination.delay_seconds,
|
|
439
|
+
"failed_at": datetime.now(UTC).isoformat(),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
def _json_safe(value: Any) -> Any:
|
|
444
|
+
return json.loads(
|
|
445
|
+
json.dumps(
|
|
446
|
+
value,
|
|
447
|
+
ensure_ascii=False,
|
|
448
|
+
default=str,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _make_context(
|
|
454
|
+
*,
|
|
455
|
+
message: RabbitMessage,
|
|
456
|
+
queue: str,
|
|
457
|
+
exchange: str,
|
|
458
|
+
routing_key: str,
|
|
459
|
+
) -> MessageContext:
|
|
460
|
+
return MessageContext(
|
|
461
|
+
message_id=message.message_id,
|
|
462
|
+
correlation_id=message.correlation_id,
|
|
463
|
+
headers=dict(message.headers or {}),
|
|
464
|
+
queue=queue,
|
|
465
|
+
exchange=exchange,
|
|
466
|
+
routing_key=routing_key,
|
|
467
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StreamSettings(BaseSettings):
|
|
6
|
+
model_config = SettingsConfigDict(
|
|
7
|
+
env_prefix="EASY_STREAM_",
|
|
8
|
+
env_file=".env",
|
|
9
|
+
extra="ignore",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
rabbitmq_url: str = "amqp://guest:guest@localhost:5672/"
|
|
13
|
+
app_name: str = "easy-faststream-app"
|
|
14
|
+
|
|
15
|
+
default_exchange: str = "easy.events"
|
|
16
|
+
retry_exchange: str = "easy.events.retry"
|
|
17
|
+
dlq_exchange: str = "easy.events.dead"
|
|
18
|
+
|
|
19
|
+
retry_queue_suffix: str = ".retry"
|
|
20
|
+
dlq_queue_suffix: str = ".dead"
|
|
21
|
+
|
|
22
|
+
max_retries: int = Field(default=3, ge=0)
|
|
23
|
+
retry_delay_seconds: float = Field(default=1.0, ge=0)
|
|
24
|
+
retry_backoff: float = Field(default=2.0, ge=1)
|
|
25
|
+
|
|
26
|
+
graceful_timeout: float = Field(default=30.0, gt=0)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True, slots=True)
|
|
6
|
+
class MessageContext:
|
|
7
|
+
message_id: str | None
|
|
8
|
+
correlation_id: str | None
|
|
9
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
|
10
|
+
queue: str = ""
|
|
11
|
+
exchange: str = ""
|
|
12
|
+
routing_key: str = ""
|
|
13
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class EasyFastStreamError(Exception):
|
|
2
|
+
"""Base exception for easy-faststream."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RetryableError(EasyFastStreamError):
|
|
6
|
+
"""Raise when processing may succeed on a later attempt."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NonRetryableError(EasyFastStreamError):
|
|
10
|
+
"""Raise when retrying cannot resolve the failure."""
|
|
11
|
+
|
easy_faststream/retry.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True, slots=True)
|
|
5
|
+
class RetryPolicy:
|
|
6
|
+
max_retries: int = 3
|
|
7
|
+
delay_seconds: float = 1.0
|
|
8
|
+
backoff: float = 2.0
|
|
9
|
+
|
|
10
|
+
def __post_init__(self) -> None:
|
|
11
|
+
if self.max_retries < 0:
|
|
12
|
+
raise ValueError("max_retries must be at least 0")
|
|
13
|
+
if self.delay_seconds < 0:
|
|
14
|
+
raise ValueError("delay_seconds must be at least 0")
|
|
15
|
+
if self.backoff < 1:
|
|
16
|
+
raise ValueError("backoff must be at least 1")
|
|
17
|
+
|
|
18
|
+
def delay_for(self, retry_number: int) -> float:
|
|
19
|
+
if retry_number < 1:
|
|
20
|
+
raise ValueError("retry_number must be at least 1")
|
|
21
|
+
return self.delay_seconds * (self.backoff ** (retry_number - 1))
|
|
22
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from faststream.rabbit import (
|
|
4
|
+
ExchangeType,
|
|
5
|
+
RabbitBroker,
|
|
6
|
+
RabbitExchange,
|
|
7
|
+
RabbitQueue,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from easy_faststream.retry import RetryPolicy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RetryDestination:
|
|
15
|
+
retry_number: int
|
|
16
|
+
delay_seconds: float
|
|
17
|
+
routing_key: str
|
|
18
|
+
queue: RabbitQueue
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_retry_destinations(
|
|
22
|
+
*,
|
|
23
|
+
source_queue: str,
|
|
24
|
+
source_exchange: str,
|
|
25
|
+
source_routing_key: str,
|
|
26
|
+
retry_exchange: str,
|
|
27
|
+
queue_suffix: str,
|
|
28
|
+
policy: RetryPolicy,
|
|
29
|
+
) -> list[RetryDestination]:
|
|
30
|
+
destinations: list[RetryDestination] = []
|
|
31
|
+
|
|
32
|
+
for retry_number in range(1, policy.max_retries + 1):
|
|
33
|
+
delay_seconds = policy.delay_for(retry_number)
|
|
34
|
+
retry_routing_key = (
|
|
35
|
+
f"{source_queue}{queue_suffix}.{retry_number}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
retry_queue = RabbitQueue(
|
|
39
|
+
name=retry_routing_key,
|
|
40
|
+
durable=True,
|
|
41
|
+
routing_key=retry_routing_key,
|
|
42
|
+
arguments={
|
|
43
|
+
"x-message-ttl": int(delay_seconds * 1000),
|
|
44
|
+
"x-dead-letter-exchange": source_exchange,
|
|
45
|
+
"x-dead-letter-routing-key": source_routing_key,
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
destinations.append(
|
|
50
|
+
RetryDestination(
|
|
51
|
+
retry_number=retry_number,
|
|
52
|
+
delay_seconds=delay_seconds,
|
|
53
|
+
routing_key=retry_routing_key,
|
|
54
|
+
queue=retry_queue,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return destinations
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def declare_retry_topology(
|
|
62
|
+
*,
|
|
63
|
+
broker: RabbitBroker,
|
|
64
|
+
retry_exchange_name: str,
|
|
65
|
+
destinations: list[RetryDestination],
|
|
66
|
+
) -> RabbitExchange:
|
|
67
|
+
retry_exchange = RabbitExchange(
|
|
68
|
+
retry_exchange_name,
|
|
69
|
+
type=ExchangeType.TOPIC,
|
|
70
|
+
durable=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
declared_exchange = await broker.declare_exchange(retry_exchange)
|
|
74
|
+
|
|
75
|
+
for destination in destinations:
|
|
76
|
+
declared_queue = await broker.declare_queue(destination.queue)
|
|
77
|
+
|
|
78
|
+
await declared_queue.bind(
|
|
79
|
+
declared_exchange,
|
|
80
|
+
routing_key=destination.routing_key,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return retry_exchange
|
easy_faststream/types.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import TypeAlias, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from easy_faststream.context import MessageContext
|
|
7
|
+
|
|
8
|
+
SchemaT = TypeVar("SchemaT", bound=BaseModel)
|
|
9
|
+
|
|
10
|
+
ConsumerHandler: TypeAlias = (
|
|
11
|
+
Callable[[SchemaT], Awaitable[None]]
|
|
12
|
+
| Callable[[SchemaT, MessageContext], Awaitable[None]]
|
|
13
|
+
)
|
|
14
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easy-faststream
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A schema-first FastStream framework with automatic retries and dead-letter handling.
|
|
5
|
+
Author: Ek
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: faststream[cli,rabbit]<0.8,>=0.7.1
|
|
10
|
+
Requires-Dist: pydantic-settings<3,>=2.2
|
|
11
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# easy-faststream
|
|
20
|
+
|
|
21
|
+
`easy-faststream` is a schema-first wrapper around FastStream for RabbitMQ.
|
|
22
|
+
Application developers define a Pydantic schema and a consumer function; the
|
|
23
|
+
framework handles validation, retry, acknowledgements, and dead-letter routing.
|
|
24
|
+
|
|
25
|
+
## Example
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from uuid import UUID
|
|
30
|
+
|
|
31
|
+
from pydantic import BaseModel
|
|
32
|
+
|
|
33
|
+
from easy_faststream import MessageContext, StreamApp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TripRequested(BaseModel):
|
|
37
|
+
order_id: UUID
|
|
38
|
+
passenger_id: UUID | None = None
|
|
39
|
+
service_type: str
|
|
40
|
+
event_at: datetime
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = StreamApp.from_env()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.consumer(
|
|
47
|
+
event="passapp.trip.requested",
|
|
48
|
+
schema=TripRequested,
|
|
49
|
+
queue="p_q.passapp.trip.requested",
|
|
50
|
+
exchange="ex.passapp.event.trip",
|
|
51
|
+
)
|
|
52
|
+
async def consume_trip(
|
|
53
|
+
event: TripRequested,
|
|
54
|
+
context: MessageContext,
|
|
55
|
+
) -> None:
|
|
56
|
+
print(event.order_id, context.message_id)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Run the application through FastStream:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
faststream run app:app.app
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Environment variables
|
|
66
|
+
|
|
67
|
+
```env
|
|
68
|
+
EASY_STREAM_RABBITMQ_URL=amqp://guest:guest@localhost:5672/
|
|
69
|
+
EASY_STREAM_APP_NAME=trip-consumer
|
|
70
|
+
EASY_STREAM_DEFAULT_EXCHANGE=easy.events
|
|
71
|
+
EASY_STREAM_DLQ_EXCHANGE=easy.events.dead
|
|
72
|
+
EASY_STREAM_MAX_RETRIES=3
|
|
73
|
+
EASY_STREAM_RETRY_DELAY_SECONDS=1
|
|
74
|
+
EASY_STREAM_RETRY_BACKOFF=2
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Failure behavior
|
|
78
|
+
|
|
79
|
+
- Invalid schema: publish the original payload and validation details to the DLQ.
|
|
80
|
+
- Processing error: retry according to the configured policy, then publish to DLQ.
|
|
81
|
+
- `NonRetryableError`: skip retries and publish directly to DLQ.
|
|
82
|
+
- DLQ publish error: NACK and requeue the original RabbitMQ message.
|
|
83
|
+
- Success or successful DLQ publish: ACK the original message.
|
|
84
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
easy_faststream/__init__.py,sha256=tHd5_43iPm-fNjYQhFDY2tDM9psFuWKh0_zZ1DnEOQg,428
|
|
2
|
+
easy_faststream/app.py,sha256=jdA-jYOzYt24UxTH_74RUfD8KtDTCFN7QLFrCcOojiE,14350
|
|
3
|
+
easy_faststream/config.py,sha256=-_X51pZc3JesFere4Jsco_11UR2u0pILS-LziD421_U,783
|
|
4
|
+
easy_faststream/context.py,sha256=XjMau6aMZ3d2jD6TB3OZcY58J-BN_qI1jZgX5siSEQk,310
|
|
5
|
+
easy_faststream/exceptions.py,sha256=g4ggg1gHBKDHxq9JDcA9_8b18WdvIzTwkUq3RCSMOGs,300
|
|
6
|
+
easy_faststream/retry.py,sha256=_TgGZYtsWc_c0pHDY-lb78gO8h3Dq_wgcu8jVShG0j0,715
|
|
7
|
+
easy_faststream/topology.py,sha256=vcBJddeCmzmZip0CMdrUFV3FOxdQtMy4KkHGbY9fH1w,2176
|
|
8
|
+
easy_faststream/types.py,sha256=16zoRKgQUOxJzAOiCDll6M1hq_2XW6bt9pGBrC4RzBw,352
|
|
9
|
+
easy_faststream-0.2.0.dist-info/METADATA,sha256=pYrgCxecweJLGs-MwPjSBX5bsF3fgUZXMn0f5GCPbw0,2243
|
|
10
|
+
easy_faststream-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
easy_faststream-0.2.0.dist-info/licenses/LICENSE,sha256=fjl_453_2LoQPw6Pzk0ih3asEJ25c5nf_JPu6ie56oA,1078
|
|
12
|
+
easy_faststream-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|