Skip to content

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:

@broker.subscriber(["orders", "refunds"])
async def handle(body: dict) -> None: ...

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:

(fetch_batch_size / max_workers) × P99(handler)  ≪  lease_ttl_seconds

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); keep lease_ttl_seconds sized 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.