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.
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 |
Rows claimed per fetch cycle |
min_fetch_interval |
1.0 s |
Base for the adaptive idle backoff (jittered ±50%, so an actual wait can land below it) and the wait when the inflight queue is full; 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. |
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: ...
OutboxSubscriberConfig.__post_init__ (in subscriber/config.py) warns or
raises on likely-wrong combinations (lease_ttl_seconds <= max_fetch_interval,
max_deliveries without retry, min_fetch_interval > max_fetch_interval,
etc.). Validation lives on the config, not the factory, so every
construction path — @broker.subscriber, @router.subscriber, direct
construction — 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.
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 →
duplicate delivery. Don't hike lease_ttl_seconds globally — 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 or its inflight queue empties — so a
lightly-loaded queue still flushes immediately and batching adds no latency;
batching only engages under sustained load.
What it buys. 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.
The tradeoff — read before enabling. 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 — defeating 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). An undersized pool does not block
broker.start() — start() only schedules the loop tasks and returns;
instead the fetch/worker loops stall on pool checkout and surface as
repeating reconnect ERROR logs with dispatch silently starved. 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.