This repository is a reference example of how to work safely with RabbitMQ from a Python service using FastStream, while keeping business logic, transport, cache, and infrastructure concerns separated.
The project demonstrates a worker-service approach where:
- FastStream is responsible only for RabbitMQ integration.
- RabbitMQ is used with durable queues, persistent messages, and DLX/TTL retries.
- Redis stores idempotency state for message processing.
- Application layer depends on interfaces, not on FastStream, Redis, or RabbitMQ.
- Domain layer contains business rules without infrastructure dependencies.
- Code follows SOLID principles: dependencies point inward, and transport details are replaceable.
RabbitMQ provides at-least-once delivery semantics, so the same message can be delivered more than once. A safe consumer must treat duplicate delivery as a normal case, not as an exceptional situation.
This project implements idempotency with two Redis keys per event:
idempotency:processing:{event_id}marks that a consumer is currently working on the event;idempotency:done:{event_id}marks that the event has already been processed successfully.
The processing algorithm is:
- Check whether the
donekey exists. If it exists, the event was already processed and the consumer skips it. - Create a
processingkey with a unique token and a short TTL. This key acts as a distributed lock for the current event. - Check the
donekey again after acquiring the lock. This closes the race where another consumer may have completed the event between the first check and lock acquisition. - Run the business handler.
- If the handler finishes successfully, write the
donekey with a longer TTL. - Remove the
processingkey only if its value is still equal to the current consumer token. - If the handler fails, do not write the
donekey. The exception is propagated, FastStream rejects the message, and RabbitMQ sends it through the retry flow.
The important detail is that a failed attempt does not mark the event as done. Only successful business execution does.
IdempotencyGuard is the application-level context manager that owns this
algorithm:
async with IdempotencyGuard(cache=cache, event_id=event.event_id) as should_process:
if not should_process:
return
await process_business_logic(event.payload)Its responsibilities are intentionally narrow:
- check whether the event is already completed;
- acquire a processing lock before the business handler runs;
- expose
should_processto the caller; - write the done marker only after successful handler execution;
- safely release the processing lock after the block exits.
The guard depends on KeyValueCache[str], not on Redis directly:
class IdempotencyGuard(AbstractAsyncContextManager[bool]):
def __init__(
self,
cache: KeyValueCache[str],
event_id: UUID,
*,
processing_ttl: int = 300,
done_ttl: int = 3600 * 24,
):
...This keeps the idempotency logic inside the application layer and makes Redis an implementation detail.
To integrate idempotency into a new consumer:
- Wrap the handler body with
IdempotencyGuard. - Pass the event id from the event envelope.
- Return immediately when
should_processisFalse. - Keep business logic inside the guarded block.
- Let exceptions propagate so RabbitMQ can retry the message.
Example:
class UserCreatedConsumer:
def __init__(self, cache: KeyValueCache[str]):
self._cache = cache
async def process(self, event: Event[UserCreated]) -> None:
async with IdempotencyGuard(self._cache, event.event_id) as should_process:
if not should_process:
return
await self._create_user_projection(event.payload)The FastStream subscriber should stay thin and delegate to the application consumer:
@rabbit_broker.subscriber(main_queue, ack_policy=AckPolicy.REJECT_ON_ERROR)
async def handle(event: Event[UserCreated]) -> None:
await consumer.process(event)This structure keeps idempotency independent from FastStream and reusable across different RabbitMQ consumers.
# Install dependencies
uv sync
# Prepare environment files
cp .env.example .env
cp rabbit.env.example rabbit.env
# Start RabbitMQ, Redis, and the worker
docker compose up -d --build --force-recreateRabbitMQ Management UI will be available at http://localhost:15673.
Publish a test event from the worker container:
docker exec -it rps-worker python -m rabbitmq_python_solid.infrastructure.faststream.publish_important_messageCompile dependencies for the Docker image:
uv pip compile pyproject.toml -o src/requirements.txtQueue processing often looks simple: subscribe to a queue, receive a message, run a handler. In production, a worker must handle retries, crashes, duplicate deliveries, message durability, backpressure, and a clean separation between business logic and transport details.
This project demonstrates a baseline safe structure:
- messages are published as persistent;
- queues are declared as durable;
- the consumer uses
prefetch_count; - failed processing is not acknowledged as successful;
- retries are implemented through a dedicated queue with TTL and dead-letter routing;
- duplicate deliveries of the same event do not trigger duplicate business processing;
- the application handler does not know about FastStream decorators or RabbitMQ APIs.
The domain layer contains business entities and rules that do not depend on the message broker.
class ImportantMessage(BaseModel):
text: str
author: str
created_at: datetimeThe importance check is isolated from the delivery mechanism:
class ImportanceChecker:
@staticmethod
def is_important(msg: ImportantMessage) -> bool:
return bool(randint(0, 1))In a real project, this layer can contain routing rules, validation, calculations, processing policies, and domain services. The important part is that it does not import FastStream, RabbitMQ, Redis, or environment settings.
The application layer contains use cases and interfaces through which business code communicates with the outside world.
Messages are passed as an envelope with event_id and creation time, not as a
bare payload:
class Event[T](BaseModel):
event_id: UUID = Field(default_factory=uuid4)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
payload: Tevent_id is used for idempotency. This is essential for RabbitMQ because a
consumer must be ready for redelivery.
The application layer knows only the publishing abstraction:
class Publisher[T](ABC):
@abstractmethod
async def publish(self, message: Event[T]) -> None: ...The FastStream implementation lives in the infrastructure layer. This allows use cases to be tested without RabbitMQ and lets the transport be replaced without rewriting business logic.
Idempotency relies on an abstract key-value storage:
class KeyValueCache[T](ABC):
@abstractmethod
async def add(self, key: str, value: T, ttl: int) -> None: ...
@abstractmethod
async def delete_if_value(self, key: str, value: T) -> bool: ...The concrete Redis implementation is hidden behind the interface. This preserves the Dependency Inversion Principle: the application layer does not depend on aiocache or Redis APIs.
The application consumer receives Event[ImportantMessage], not a transport message:
class ImportantMessageConsumer:
def __init__(self, cache: KeyValueCache[str]):
self._cache = cache
async def process(self, msg: Event[ImportantMessage]) -> None:
async with IdempotencyGuard(cache=self._cache, event_id=msg.event_id) as should_process:
if not should_process:
return
is_important = ImportanceChecker.is_important(msg.payload)IdempotencyGuard protects processing from duplicates:
- if the event is already completed, processing is skipped;
- before processing, a processing lock with TTL is created;
- after successful execution, a done marker is created;
- the lock is removed only by its owner through a compare-and-delete Lua script;
- on exception, the done marker is not created, so the message can be retried.
The infrastructure layer contains concrete technology details: FastStream, RabbitMQ, Redis, environment settings, and worker startup.
rabbit_broker = RabbitBroker(settings.rabbitmq.rabbitmq_uri)The broker is created at the infrastructure level and does not leak into domain or application code.
The main queue is durable and dead-letters failed messages into the retry queue:
main_queue = RabbitQueue(
MAIN_QUEUE_NAME,
durable=True,
arguments={
"x-dead-letter-exchange": "",
"x-dead-letter-routing-key": RETRY_QUEUE_NAME,
},
)The retry queue holds the message for a defined time and then routes it back:
retry_queue = RabbitQueue(
RETRY_QUEUE_NAME,
durable=True,
arguments={
"x-message-ttl": 60_000,
"x-dead-letter-exchange": "",
"x-dead-letter-routing-key": MAIN_QUEUE_NAME,
},
)This approach avoids manual sleep calls inside the consumer and does not block
processing of other messages.
The FastStream decorator stays at the infrastructure boundary:
@rabbit_broker.subscriber(
main_queue,
channel=Channel(prefetch_count=10),
ack_policy=AckPolicy.REJECT_ON_ERROR,
persistent=True,
no_reply=True,
)
async def handle(event: Event[ImportantMessage]) -> None:
await consumer.process(event)Important settings:
prefetch_count=10limits the number of unacknowledged messages per consumer;AckPolicy.REJECT_ON_ERRORsends failed messages into the retry flow;persistent=Truematches the durable approach to messages and queues;no_reply=Truemakes it explicit that this is event processing, not RPC.
Publishing uses mandatory=True and persist=True:
await rabbit_broker.publish(message, queue=self._queue_name, mandatory=True, persist=True)persist=True asks RabbitMQ to persist the message to disk for durable queues.
mandatory=True helps avoid silently ignoring cases where a message cannot be
routed to a queue.
Redis is used as an infrastructure mechanism for duplicate-processing protection,
not as business storage. The delete_if_value method removes the lock only if
the stored value matches the current handler token:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0This protects against a stale consumer deleting a lock that already belongs to another process after TTL expiration.
- Single Responsibility Principle: domain owns rules, application owns use cases, infrastructure owns RabbitMQ/Redis/FastStream integration.
- Open/Closed Principle: new consumers, publishers, or cache implementations can be added without rewriting existing business logic.
- Liskov Substitution Principle:
PublisherandKeyValueCacheimplementations can be replaced with test or production adapters. - Interface Segregation Principle: application code uses narrow interfaces, not large RabbitMQ or Redis clients.
- Dependency Inversion Principle: high-level scenarios depend on abstractions, while FastStream and Redis are implementation details.
src/rabbitmq_python_solid/
+-- domain/
| +-- important_message.py # Domain message model
| +-- check_importance.py # Domain importance rule
+-- application/
| +-- consumers/
| | +-- important_message.py # Message-processing use case
| | +-- idempotency_guard.py # Duplicate-processing protection
| +-- exceptions/ # Application-layer errors
| +-- interfaces/
| +-- common/
| +-- event.py # Event envelope
| +-- publisher.py # Publishing interface
| +-- key_value_cache.py # Key-value storage interface
+-- infrastructure/
| +-- aiocache/
| | +-- key_value_cache.py # Redis adapter through aiocache
| +-- faststream/
| +-- app.py # FastStream app
| +-- broker.py # RabbitBroker
| +-- consumers.py # FastStream subscriber bindings
| +-- publisher.py # FastStream publisher adapter
| +-- rabbit_queues.py # Durable queues and retry topology
| +-- publish_important_message.py
| +-- run_worker.py # Worker entrypoint
+-- utils/
+-- config/
| +-- settings.py # Environment settings
+-- logging/ # Logging setup
Main environment variables are listed in .env.example:
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=user
RABBITMQ_PASSWORD=password
RABBITMQ_IMPORTANT_MESSAGES_QUEUE=important-messages
RABBITMQ_IMPORTANT_MESSAGES_RETRY_QUEUE=important_messages.retry
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_INDEX=0RabbitMQ container variables are listed in rabbit.env.example.
Formatting and linting:
uv run ruff format .
uv run ruff check . --fixRun the worker stack:
docker compose up -d --build --force-recreateSend a test event:
docker exec -it rps-worker python -m rabbitmq_python_solid.infrastructure.faststream.publish_important_message