Subscriber¶
Use @broker.subscriber(queue, ...) to register a handler for a queue.
Basic example¶
from faststream import FastStream
from faststream_outbox import OutboxBroker
broker: OutboxBroker = ...
app = FastStream(broker)
@broker.subscriber("orders")
async def handle(order_id: int) -> None:
print(f"order {order_id}")
Multiple queues per subscriber¶
The first argument is queues: str | list[str]. Pass a list to fan one
handler across several queues:
The subscriber claims rows from any of its queues in a single fetch. Its
connection budget is unchanged: max_workers + 1
pool connections regardless of how many queues it serves.
In the AsyncAPI document it appears as one channel per queue
(orders:Handle, refunds:Handle), each addressed by that queue, rather
than one channel for the subscriber.
Do not register two subscribers on the same queue: they compete for
the same rows, and registration emits a warning to that effect. To run more
than one handler over a queue, attach them to a single subscriber; to scale
throughput, raise max_workers.
Body types¶
FastStream deserializes the message body into the annotated type. Any JSON-serializable type works:
from dataclasses import dataclass
@dataclass
class Order:
order_id: str
amount: float
@broker.subscriber("orders")
async def handle(body: Order) -> None:
print(f"order {body.order_id} for {body.amount}")
Annotated handler params¶
faststream_outbox.annotations exports Annotated[..., Context(...)]
shortcuts so handler signatures stay concise:
from faststream_outbox.annotations import OutboxBroker, OutboxMessage
@broker.subscriber("orders")
async def handle(msg: OutboxMessage, broker: OutboxBroker) -> None: ...
OutboxMessage, OutboxBroker, OutboxProducer, and OutboxClient are
all available. For FastAPI handlers, import the same names from
faststream_outbox.fastapi. They resolve via the same Context() paths
but go through FastAPI's dependency resolver so Depends(...) and these
shortcuts can be mixed freely.
Subscriber options¶
Per-subscriber knobs, passed to @broker.subscriber("…", …):
| Parameter | Default | Description |
|---|---|---|
max_workers |
1 |
Concurrent handlers per subscriber |
fetch_batch_size |
10 |
Most rows held in memory and claimed per fetch. The next fetch runs once workers have taken half of them. |
min_fetch_interval |
1.0 s |
Base for the adaptive idle backoff (jittered ±50%, so an actual wait can land below it); no sleep at all while fetches keep returning rows |
max_fetch_interval |
10.0 s |
Ceiling for the adaptive idle backoff (with jitter) |
lease_ttl_seconds |
60.0 s |
How long a claim is valid before another fetch may reclaim it. Must exceed your handler's P99 with margin. |
max_deliveries |
None (unbounded) |
Total claims (including lease-expiry re-claims) after which the row is dropped without invoking the handler. Defends against handlers that consistently wedge. Must be at least 1; use None, not 0, for no cap. |
terminal_flush_batch_size |
1 (off) |
Coalesce completed terminal DELETEs into one DELETE … RETURNING per N rows. 1 is one round-trip per message (unchanged). Higher trades a wider crash-redelivery window for far fewer round-trips. See Batching terminal deletes. |
ack_policy |
AckPolicy.NACK_ON_ERROR |
See Ack policy |
retry_strategy |
ExponentialRetry(...) |
See Retry strategies |
propagate_inbound_headers |
False |
Relay-only. When True, fills Response.headers from the inbound message if the handler returned a Response with empty headers (user-set headers always win). See Relay. |
@broker.subscriber(
"high-priority",
max_workers=8,
fetch_batch_size=50,
min_fetch_interval=0.1,
max_fetch_interval=1.0,
lease_ttl_seconds=120.0,
)
async def handle_urgent(body: dict) -> None: ...
Subscriber options are validated when the subscriber is created. Values that
would delete data or never consume raise: a max_deliveries below 1, a NaN or
infinite interval, a bool passed as a count, and a queue name that is empty,
longer than 255 characters, or listed twice. Likely-wrong combinations
(lease_ttl_seconds <= max_fetch_interval, max_deliveries with NoRetry(),
min_fetch_interval > max_fetch_interval, etc.) warn or raise. Every
construction path (@broker.subscriber, @router.subscriber, OutboxRoute)
is checked.
The table above lists the outbox-specific knobs. The standard FastStream
subscriber kwargs pass through unchanged too: dependencies, parser,
decoder, and the AsyncAPI title_ / description_ / include_in_schema.
title_ names both the AsyncAPI channel and its operation. On a subscriber
spanning several queues it prefixes each channel (Ingest:orders,
Ingest:refunds), since one title cannot name several channels on its own.
Slow handlers: dedicated queue¶
When a handler's tail latency exceeds the subscriber's lease_ttl_seconds,
the row's lease expires mid-flight and another fetch reclaims it,
causing duplicate delivery. Don't hike lease_ttl_seconds globally, because
that delays reclaim of actually stuck rows everywhere. Instead, segregate slow work
onto its own subscriber with a longer TTL:
@broker.subscriber("slow_q", lease_ttl_seconds=600) # 10 minutes
async def heavy_job(msg): ...
@broker.subscriber("fast_q", lease_ttl_seconds=30)
async def quick_job(msg): ...
Pick lease_ttl_seconds strictly greater than that subscriber's P99 handler
duration, with margin for clock skew. The tight TTL on the fast queue keeps
stuck-row reclaim fast; the tall TTL on the slow queue tolerates outliers
without slowing reclaim of genuinely stuck rows elsewhere. Producers route
to the appropriate queue at publish time.
Account for queue depth, not just per-row latency
A fetch claims up to fetch_batch_size rows at once and each takes its
lease at fetch time, then they wait their turn in an in-memory queue drained
by max_workers handlers. A row's lease clock runs while it waits, so the
relevant bound is the serialized time to reach it, not one handler's P99:
With the defaults (fetch_batch_size=10, max_workers=1) the 10th row waits
behind nine others before dispatch. If that wait can exceed lease_ttl_seconds,
a competing fetch reclaims the still-queued row → duplicate (self-correcting)
delivery. Either keep fetch_batch_size close to max_workers, or size the TTL
for the whole batch.
See also Troubleshooting § event=lease_lost.
Batching terminal deletes¶
By default each processed row is deleted with its own DELETE, one round-trip
per message. At max_workers=1 those deletes serialise, and the round-trip
(not the database work) is the throughput ceiling. Set
terminal_flush_batch_size above 1 to coalesce completed rows and flush them
as a single DELETE … WHERE (id, acquired_token) IN (…) RETURNING id:
@broker.subscriber("orders", terminal_flush_batch_size=100)
async def handle(order: dict) -> None: ...
A worker buffers completed rows and flushes when the buffer reaches
terminal_flush_batch_size, when its inflight queue empties, or when the oldest
buffered row was claimed half of lease_ttl_seconds ago. A lightly-loaded queue
still flushes immediately and batching adds no latency; batching only engages
under sustained load, and a buffered row is deleted before its lease can expire.
In the benchmark (5 000 messages, fetch_batch_size=100), the
terminal round-trips drop from one per message to one per batch, a 100×
reduction in terminal DELETEs (5 000 → 50) with the same rows deleted. Because
the terminal write stops being the bottleneck, a single batched worker
out-throughputs a four-worker per-row subscriber, so you reach high throughput
without spending the extra connection budget that more
workers cost. The win is largest at low max_workers (where per-row deletes
serialise) and narrows as worker parallelism rises.
Read the tradeoff before enabling it. Batching holds completed-but-undeleted
rows in memory until the flush. On a graceful stop the buffer is flushed
(no redelivery). But on an ungraceful crash (SIGKILL / OOM / power loss),
up to terminal_flush_batch_size rows that already ran their handler are
redelivered when another replica reclaims them. The outbox is already
at-least-once (handlers must be idempotent), so this is not a new failure
class, only a wider window: from at most one at-risk row (per-row) to up to a
full batch. Two further effects to size for:
- The outbox table shows completed-but-undeleted rows as still present until the
flush, so a backlog-depth query (or an autoscaler keyed on it) reads inflated
by up to
terminal_flush_batch_size × max_workers. - Buffered rows hold their leases until the flush, so the lease ceiling grows to
fetch_batch_size + max_workers × (terminal_flush_batch_size + 1); keeplease_ttl_secondssized against that.
It is off by default (terminal_flush_batch_size=1 is byte-for-byte the
per-row path). Enable it per subscriber when the queue is high-throughput and
its handler is idempotent; leave it off for low-volume or
exactly-once-sensitive queues.
For where this sits among the outbox's tuning levers and which workloads want it, see the Performance guide.
Ack policy¶
The default is AckPolicy.NACK_ON_ERROR: on a handler exception, the retry
strategy decides whether to schedule another attempt or terminally drop the
row.
| Policy | Effect |
|---|---|
AckPolicy.NACK_ON_ERROR (default) |
Consult the retry strategy on handler exceptions |
AckPolicy.REJECT_ON_ERROR |
Delete on the first failure (the retry strategy is ignored) |
AckPolicy.MANUAL |
Handler must call await msg.ack() / nack() / reject() itself |
AckPolicy.ACK_FIRST |
Not supported. Passing it raises ValueError at registration |
ACK_FIRST would delete the row before the handler runs, so a handler
crash silently drops the message, which defeats the outbox reliability
guarantee. The factory rejects it at registration.
from faststream import AckPolicy
from faststream_outbox.annotations import OutboxMessage
@broker.subscriber("audit", ack_policy=AckPolicy.MANUAL)
async def handle(msg: OutboxMessage, body: dict) -> None:
try:
await write_audit(body)
await msg.ack()
except TransientError:
await msg.nack() # retry
except PermanentError:
await msg.reject() # terminal delete
MANUAL: returning without acking is a terminal reject
Under AckPolicy.MANUAL, a handler that returns without calling
ack() / nack() / reject() (and without raising) is treated as a
terminal reject: the row is deleted (or written to the DLQ with
failure_reason="rejected" if a dlq_table is configured), not retried.
A handler that raises is nacked through the retry strategy instead, so
only the silent-return path is destructive. Always ack/nack/reject on
every branch.
Retry strategies¶
A subscriber with no explicit retry_strategy defaults to
ExponentialRetry(initial_delay_seconds=1.0, multiplier=2.0,
max_delay_seconds=300.0, max_attempts=10, jitter_factor=0.2). Defaulting
to "delete on first error" is the wrong contract for an outbox; users
wanting that behavior must explicitly pass NoRetry().
from faststream_outbox import ExponentialRetry, ConstantRetry, LinearRetry, NoRetry
@broker.subscriber(
"orders",
retry_strategy=ExponentialRetry(
initial_delay_seconds=1.0,
max_delay_seconds=300.0,
max_attempts=5,
jitter_factor=0.5,
),
)
async def handle(order_id: int) -> None: ...
@broker.subscriber("audit", retry_strategy=NoRetry()) # opt out of retries
async def handle_audit(payload: dict) -> None: ...
ConstantRetry and LinearRetry accept jitter_factor (default 0.0);
when non-zero, the computed delay is multiplied by 1 +
U(-jitter_factor/2, +jitter_factor/2) to spread out retries, matching
ExponentialRetry's shape.
Strategy parameters¶
| Strategy | Required | Optional (default) |
|---|---|---|
NoRetry |
- | - |
ConstantRetry |
delay_seconds |
jitter_factor (0.0) |
LinearRetry |
initial_delay_seconds, step_seconds |
jitter_factor (0.0) |
ExponentialRetry |
initial_delay_seconds |
multiplier (2.0), max_delay_seconds (None), jitter_factor (0.0) |
Every strategy except NoRetry also accepts the shared caps max_attempts
(default None) and max_total_delay_seconds (default None); reaching
either returns None, which is terminal (the row is deleted, or DLQ'd).
Retry only on transient errors¶
Strategies receive the raised exception so users may subclass for
"retry only on transient errors":
class TransientOnly(ExponentialRetry):
def get_next_attempt_delay(
self,
*,
first_attempt_at: datetime,
last_attempt_at: datetime,
attempts_count: int,
exception: BaseException | None = None,
) -> float | None:
if exception and not isinstance(exception, TransientError):
return None # terminal — DELETE
return super().get_next_attempt_delay(
first_attempt_at=first_attempt_at,
last_attempt_at=last_attempt_at,
attempts_count=attempts_count,
exception=exception,
)
Returning None from get_next_attempt_delay signals a terminal failure.
The base strategy also enforces max_attempts and
max_total_delay_seconds for you.
Connection budget¶
Each subscriber holds max_workers + 1 long-lived SQLAlchemy pool
connections (one writer per worker + one fetch), plus one raw asyncpg
connection for LISTEN when available. Size your engine pool for
Σ subscribers × (max_workers + 1). With a fixed-size pool (SQLAlchemy's
default QueuePool), broker.start() raises RuntimeError when
pool_size + max_overflow is below that sum. NullPool and an unlimited
max_overflow=-1 aren't checked. The check counts only the subscribers, so
leave room for the application's own sessions: if those take the remaining
connections, the fetch and worker loops stall on pool checkout and show up as
repeating reconnect ERROR logs. SQLAlchemy's default pool_size=5,
max_overflow=10 covers a handful of single-worker subscribers; raise it for
larger fleets.
Server-side, the footprint is one larger: the raw asyncpg LISTEN
connection lives outside the pool, so each subscriber consumes
max_workers + 2 Postgres connections. The budget is per process:
each replica opens its own pool and LISTEN connections, so your Postgres
max_connections needs to cover replicas × Σ subscribers × (max_workers +
2), otherwise additional replicas (or rolling deployments) are refused at
startup with FATAL: too many connections.
Operator-side: Production checklist § Sizing.
Read-only inspection¶
subscriber.get_one() and async for msg in subscriber: are not
supported on OutboxSubscriber; both raise NotImplementedError.
They would acquire a lease and bump deliveries_count, surprising
semantics for a peek API. Use
broker.fetch_unprocessed(session=..., queue=...) for lease-free reads of
the current table state.