Skip to content

Relay to a foreign broker

Want a worked end-to-end example? See Tutorial: Add a Kafka relay.

In the outbox pattern, domain code writes a row to the outbox in the same DB transaction as its other writes, and a separate worker relays those rows to a real bus (Kafka, RabbitMQ, NATS, Redis…). faststream-outbox supports this directly via FastStream's cross-broker chain: stack a foreign-broker publisher decorator on an outbox subscriber.

If you don't have a database write to atomically commit alongside, use the foreign broker directly. See Comparison.

Why an outbox relay

When a request must (a) update your database and (b) emit an event onto a message bus, the naive shape (DB commit, then bus publish) leaks events on crashes between the two steps. The outbox pattern fixes this by writing the event as a row in the same transaction as the domain update; a separate worker reads the row and publishes to the bus. The row is the durability boundary, and the relay carries an at-least-once guarantee end to end.

Minimal relay

The snippets below assume engine and outbox_table from the Basic usage setup (make_outbox_table(metadata) + create_async_engine(...)).

from faststream.kafka import KafkaBroker
from faststream_outbox import OutboxBroker

broker_outbox = OutboxBroker(engine, outbox_table=outbox_table)
broker_kafka = KafkaBroker("127.0.0.1:9092")
publisher_kafka = broker_kafka.publisher("kafka_topic")


@publisher_kafka
@broker_outbox.subscriber("outbox_queue")
async def relay(body: dict) -> dict:
    return body

await broker_outbox.publish(body, queue="outbox_queue", session=session) in your domain transaction writes a row; the subscriber dispatches it; the handler returns it; the Kafka publisher decorator picks it up and publishes to kafka_topic. Failure handling, retries, and DLQ are unchanged from the rest of the outbox subscriber's behavior.

Two-broker lifecycle

Both brokers must be started for the relay to work. As a safety net, at start() the outbox broker logs a WARNING (one per unstarted foreign broker) naming the affected queue(s), and a relay to an unstarted foreign broker fails and retries until that broker is started, so the row is never lost. There are two idiomatic shapes:

Mount OutboxRouter and the foreign broker's router on the same FastAPI app. Both auto-start via FastAPI's lifespan.

from fastapi import FastAPI
from faststream.kafka.fastapi import KafkaRouter
from faststream_outbox.fastapi import OutboxRouter

outbox_router = OutboxRouter(engine, outbox_table=outbox_table)
kafka_router = KafkaRouter("127.0.0.1:9092")

publisher_kafka = kafka_router.publisher("kafka_topic")


@publisher_kafka
@outbox_router.subscriber("outbox_queue")
async def relay(body: dict) -> dict:
    return body


app = FastAPI()
app.include_router(outbox_router)
app.include_router(kafka_router)

Standalone

A single FastStream app with the foreign broker's connect hooked into on_startup:

from faststream import FastStream
from faststream.kafka import KafkaBroker
from faststream_outbox import OutboxBroker

broker_outbox = OutboxBroker(engine, outbox_table=outbox_table)
broker_kafka = KafkaBroker("127.0.0.1:9092")
publisher_kafka = broker_kafka.publisher("kafka_topic")


@publisher_kafka
@broker_outbox.subscriber("outbox_queue")
async def relay(body: dict) -> dict:
    return body


app = FastStream(broker_outbox, on_startup=[broker_kafka.connect])

At-least-once contract

If the foreign publish raises (Kafka down, partition unavailable, etc.), the exception propagates through FastStream's AcknowledgementMiddleware, the outbox row is nacked, and the configured retry_strategy reschedules it. The next dispatch re-runs the handler and re-attempts the foreign publish. The net effect is at-least-once delivery to the foreign broker.

Downstream consumers should handle duplicates idempotently, the same way they would behind any at-least-once bus.

Header propagation

By default, FastStream's Response(value) ships with empty headers, so the inbound outbox row's headers (content-type, custom trace keys, etc.) are not forwarded to the foreign publish. Two ways to override:

Set them explicitly, per handler:

from faststream.response import Response
from faststream_outbox.annotations import OutboxMessage


@publisher_kafka
@broker_outbox.subscriber("outbox_queue")
async def relay(body: dict, msg: OutboxMessage) -> Response:
    return Response(body, headers=msg.headers)

Or opt in per subscriber:

@publisher_kafka
@broker_outbox.subscriber("outbox_queue", propagate_inbound_headers=True)
async def relay(body: dict) -> dict:
    return body

With propagate_inbound_headers=True, the subscriber fills Response.headers from the inbound OutboxMessage.headers unless the handler returned a Response(..., headers=...) explicitly.

Using routers

Both halves of the chain can live on routers. The FastAPI shape above already does this with KafkaRouter and OutboxRouter. The constraint is that broker.include_router(router) must happen before the brokers start. Inside FastAPI(..., lifespan=...) the include happens during app construction (before lifespan), so it's automatic.

For the standalone (non-FastAPI) lifecycle, use the plain broker routers (faststream.kafka.KafkaRouter and faststream_outbox.OutboxRouter; the FastAPI routers above only mount on a FastAPI app) and include them before starting:

from faststream.kafka import KafkaRouter
from faststream_outbox import OutboxRouter

kafka_router = KafkaRouter()
outbox_router = OutboxRouter()
publisher_kafka = kafka_router.publisher("kafka_topic")


@publisher_kafka
@outbox_router.subscriber("outbox_queue")
async def relay(body: dict) -> dict:
    return body


broker_kafka.include_router(kafka_router)
broker_outbox.include_router(outbox_router)
# then start

What not to do

Do not combine OutboxResponse(...) and a foreign-publisher decorator:

from faststream_outbox import OutboxResponse


@publisher_kafka
@broker_outbox.subscriber("outbox_queue")
async def relay(body: dict) -> OutboxResponse:
    return OutboxResponse(body=body, queue="next_queue", session=session)  # rejected at dispatch

This would both insert a row into the outbox AND publish to Kafka. The subscriber raises RuntimeError at dispatch time when it detects the combination, so pick one path. The worker catches that error and logs it at ERROR; it does not flush a nack and does not route the row through the retry_strategy. The row's lease expires and a later fetch reclaims it, so the row keeps being retried (not lost) until you fix the configuration.

Do not stack an outbox publisher on a foreign subscriber:

@broker_outbox.publisher("outbox_queue")  # NotImplementedError at decoration
@broker_kafka.subscriber("kafka_topic")
async def relay(body: dict) -> dict:
    return body

This direction would need the Kafka subscriber's dispatch loop to provide an AsyncSession for the outbox insert, and there isn't one without breaking the transactional contract. OutboxPublisher.__call__ raises NotImplementedError at decoration time. Call await broker_outbox.publish(...) inside the handler instead, on a session you opened yourself.

Other foreign brokers

The same pattern works for Confluent, RabbitMQ, NATS, and Redis; only the publisher line changes:

Foreign broker Publisher line
Kafka broker_kafka.publisher("topic")
Confluent broker_confluent.publisher("topic")
RabbitMQ broker_rabbit.publisher("queue")
NATS broker_nats.publisher("subject")
Redis broker_redis.publisher("channel")

Any FastStream broker whose publisher's _publish accepts a generic PublishCommand works as a relay destination. That is the FastStream cross-broker contract, not an outbox-specific feature.