easy-faststream 0.2.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,9 @@
1
+ .env
2
+ .venv/
3
+ .venv-wheel/
4
+ .venv-testpypi/
5
+ __pycache__/
6
+ *.py[cod]
7
+ dist/
8
+ *.egg-info/
9
+ .pytest_cache/
@@ -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.
@@ -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,66 @@
1
+ # easy-faststream
2
+
3
+ `easy-faststream` is a schema-first wrapper around FastStream for RabbitMQ.
4
+ Application developers define a Pydantic schema and a consumer function; the
5
+ framework handles validation, retry, acknowledgements, and dead-letter routing.
6
+
7
+ ## Example
8
+
9
+ ```python
10
+ from datetime import datetime
11
+ from uuid import UUID
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from easy_faststream import MessageContext, StreamApp
16
+
17
+
18
+ class TripRequested(BaseModel):
19
+ order_id: UUID
20
+ passenger_id: UUID | None = None
21
+ service_type: str
22
+ event_at: datetime
23
+
24
+
25
+ app = StreamApp.from_env()
26
+
27
+
28
+ @app.consumer(
29
+ event="passapp.trip.requested",
30
+ schema=TripRequested,
31
+ queue="p_q.passapp.trip.requested",
32
+ exchange="ex.passapp.event.trip",
33
+ )
34
+ async def consume_trip(
35
+ event: TripRequested,
36
+ context: MessageContext,
37
+ ) -> None:
38
+ print(event.order_id, context.message_id)
39
+ ```
40
+
41
+ Run the application through FastStream:
42
+
43
+ ```bash
44
+ faststream run app:app.app
45
+ ```
46
+
47
+ ## Environment variables
48
+
49
+ ```env
50
+ EASY_STREAM_RABBITMQ_URL=amqp://guest:guest@localhost:5672/
51
+ EASY_STREAM_APP_NAME=trip-consumer
52
+ EASY_STREAM_DEFAULT_EXCHANGE=easy.events
53
+ EASY_STREAM_DLQ_EXCHANGE=easy.events.dead
54
+ EASY_STREAM_MAX_RETRIES=3
55
+ EASY_STREAM_RETRY_DELAY_SECONDS=1
56
+ EASY_STREAM_RETRY_BACKOFF=2
57
+ ```
58
+
59
+ ## Failure behavior
60
+
61
+ - Invalid schema: publish the original payload and validation details to the DLQ.
62
+ - Processing error: retry according to the configured policy, then publish to DLQ.
63
+ - `NonRetryableError`: skip retries and publish directly to DLQ.
64
+ - DLQ publish error: NACK and requeue the original RabbitMQ message.
65
+ - Success or successful DLQ publish: ACK the original message.
66
+
@@ -0,0 +1,29 @@
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from easy_faststream import MessageContext, StreamApp
7
+
8
+
9
+ class TripRequested(BaseModel):
10
+ order_id: UUID
11
+ passenger_id: UUID | None = None
12
+ service_type: str
13
+ event_at: datetime
14
+
15
+
16
+ stream = StreamApp.from_env()
17
+
18
+
19
+ @stream.consumer(
20
+ event="passapp.trip.requested",
21
+ schema=TripRequested,
22
+ queue="p_q.passapp.trip.requested",
23
+ exchange="ex.passapp.event.trip",
24
+ )
25
+ async def consume_trip(event: TripRequested, context: MessageContext) -> None:
26
+ print(f"Processing {event.order_id}; message_id={context.message_id}")
27
+
28
+
29
+ app = stream.app
@@ -0,0 +1,33 @@
1
+ import asyncio
2
+
3
+ from faststream.rabbit import ExchangeType, RabbitBroker, RabbitExchange
4
+
5
+ from easy_faststream import StreamSettings
6
+
7
+
8
+ async def main() -> None:
9
+ settings = StreamSettings()
10
+
11
+ exchange = RabbitExchange(
12
+ "ex.easy_faststream.test",
13
+ type=ExchangeType.TOPIC,
14
+ durable=True,
15
+ )
16
+
17
+ async with RabbitBroker(settings.rabbitmq_url) as broker:
18
+ await broker.publish(
19
+ {
20
+ "event_id": "retry-001",
21
+ "message": "This message should retry",
22
+ },
23
+ exchange=exchange,
24
+ routing_key="easy.test",
25
+ message_id="invalid-message-001",
26
+ persist=True,
27
+ )
28
+
29
+ print("Invalid message published")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ asyncio.run(main())
@@ -0,0 +1,32 @@
1
+ import asyncio
2
+
3
+ from faststream.rabbit import ExchangeType, RabbitBroker, RabbitExchange
4
+
5
+ from easy_faststream import StreamSettings
6
+
7
+
8
+ async def main() -> None:
9
+ settings = StreamSettings()
10
+
11
+ exchange = RabbitExchange(
12
+ "ex.easy_faststream.test",
13
+ type=ExchangeType.TOPIC,
14
+ durable=True,
15
+ )
16
+
17
+ async with RabbitBroker(settings.rabbitmq_url) as broker:
18
+ await broker.publish(
19
+ {
20
+ "event_id": "retry-002",
21
+ "message": "Testing durable RabbitMQ retry",
22
+ },
23
+ exchange=exchange,
24
+ routing_key="easy.test",
25
+ message_id="retry-message-001",
26
+ persist=True,
27
+ )
28
+ print("Message published successfully")
29
+
30
+
31
+ if __name__ == "__main__":
32
+ asyncio.run(main())
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "easy-faststream"
7
+ version = "0.2.0"
8
+ description = "A schema-first FastStream framework with automatic retries and dead-letter handling."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Ek" },
15
+ ]
16
+ dependencies = [
17
+ "faststream[rabbit,cli]>=0.7.1,<0.8",
18
+ "pydantic>=2.7,<3",
19
+ "pydantic-settings>=2.2,<3",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "build>=1.2",
25
+ "pytest>=8.0",
26
+ "pytest-asyncio>=0.23",
27
+ "ruff>=0.6",
28
+ ]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/easy_faststream"]
32
+
33
+ [tool.pytest.ini_options]
34
+ pythonpath = ["src"]
35
+ testpaths = ["tests"]
36
+ asyncio_mode = "auto"
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py311"
41
+
@@ -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
+
@@ -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
+
@@ -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
@@ -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,57 @@
1
+ from pydantic import BaseModel
2
+ from faststream.rabbit import (
3
+ ExchangeType,
4
+ RabbitExchange,
5
+ RabbitQueue,
6
+ )
7
+
8
+ from easy_faststream import MessageContext, StreamApp
9
+
10
+
11
+ class TestEvent(BaseModel):
12
+ event_id: str
13
+ message: str
14
+
15
+
16
+ stream = StreamApp.from_env()
17
+
18
+
19
+ @stream.consumer(
20
+ event="easy.test",
21
+ schema=TestEvent,
22
+ queue="p_q.easy_faststream.test",
23
+ exchange="ex.easy_faststream.test",
24
+ routing_key="easy.test",
25
+ )
26
+ async def consume_test(
27
+ event: TestEvent,
28
+ context: MessageContext,
29
+ ) -> None:
30
+ print("Processing:", event.event_id)
31
+
32
+ if event.event_id.startswith("retry-"):
33
+ raise RuntimeError("Testing automatic retry")
34
+
35
+ print("Processed successfully:", event)
36
+
37
+
38
+ dead_exchange = RabbitExchange(
39
+ "ex.easy_faststream.test.dead",
40
+ type=ExchangeType.TOPIC,
41
+ durable=True,
42
+ )
43
+
44
+ dead_queue = RabbitQueue(
45
+ "p_q.easy_faststream.test.dead",
46
+ durable=True,
47
+ routing_key="easy.test",
48
+ )
49
+
50
+
51
+ @stream.broker.subscriber(dead_queue, dead_exchange)
52
+ async def consume_dead_letter(message: dict) -> None:
53
+ print("Received DLQ message:")
54
+ print(message)
55
+
56
+
57
+ app = stream.app
@@ -0,0 +1,25 @@
1
+ import pytest
2
+
3
+ from easy_faststream.retry import RetryPolicy
4
+
5
+
6
+ def test_exponential_retry_delays() -> None:
7
+ policy = RetryPolicy(max_retries=3, delay_seconds=2, backoff=3)
8
+
9
+ assert policy.delay_for(1) == 2
10
+ assert policy.delay_for(2) == 6
11
+ assert policy.delay_for(3) == 18
12
+
13
+
14
+ @pytest.mark.parametrize(
15
+ ("kwargs", "message"),
16
+ [
17
+ ({"max_retries": -1}, "max_retries"),
18
+ ({"delay_seconds": -1}, "delay_seconds"),
19
+ ({"backoff": 0.5}, "backoff"),
20
+ ],
21
+ )
22
+ def test_invalid_retry_policy(kwargs: dict, message: str) -> None:
23
+ with pytest.raises(ValueError, match=message):
24
+ RetryPolicy(**kwargs)
25
+
@@ -0,0 +1,63 @@
1
+ from easy_faststream.retry import RetryPolicy
2
+ from easy_faststream.topology import build_retry_destinations
3
+
4
+
5
+ def test_builds_durable_exponential_retry_queues() -> None:
6
+ policy = RetryPolicy(
7
+ max_retries=3,
8
+ delay_seconds=1,
9
+ backoff=2,
10
+ )
11
+
12
+ destinations = build_retry_destinations(
13
+ source_queue="p_q.easy.test",
14
+ source_exchange="ex.easy.test",
15
+ source_routing_key="easy.test",
16
+ retry_exchange="ex.easy.test.retry",
17
+ queue_suffix=".retry",
18
+ policy=policy,
19
+ )
20
+
21
+ assert len(destinations) == 3
22
+
23
+ assert [
24
+ destination.delay_seconds
25
+ for destination in destinations
26
+ ] == [1, 2, 4]
27
+
28
+ assert [
29
+ destination.queue.name
30
+ for destination in destinations
31
+ ] == [
32
+ "p_q.easy.test.retry.1",
33
+ "p_q.easy.test.retry.2",
34
+ "p_q.easy.test.retry.3",
35
+ ]
36
+
37
+ for destination in destinations:
38
+ arguments = destination.queue.arguments
39
+
40
+ assert arguments["x-message-ttl"] == (
41
+ int(destination.delay_seconds * 1000)
42
+ )
43
+ assert arguments["x-dead-letter-exchange"] == (
44
+ "ex.easy.test"
45
+ )
46
+ assert arguments["x-dead-letter-routing-key"] == (
47
+ "easy.test"
48
+ )
49
+
50
+
51
+ def test_zero_retries_creates_no_retry_queues() -> None:
52
+ policy = RetryPolicy(max_retries=0)
53
+
54
+ destinations = build_retry_destinations(
55
+ source_queue="p_q.easy.test",
56
+ source_exchange="ex.easy.test",
57
+ source_routing_key="easy.test",
58
+ retry_exchange="ex.easy.test.retry",
59
+ queue_suffix=".retry",
60
+ policy=policy,
61
+ )
62
+
63
+ assert destinations == []