Skip to content

Add opt-in receipt-confirmed sends - #207

Merged
lesnik512 merged 4 commits into
mainfrom
feature/send-receipt-confirmation
Sep 17, 2026
Merged

lesnik512 merged 4 commits into
mainfrom
feature/send-receipt-confirmation

Conversation

@lesnik512

@lesnik512 lesnik512 commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Follows #204: Client.send() can now wait for the broker to accept the message, the way subscribe() already can.

Why

A successful send() means only that the frame was written to the socket. Callers that acknowledge upstream work on the strength of a publish (a Kafka consumer acknowledging a command, say) are acknowledging something the broker may never have received.

stompman

await client.send(body, "destination", receipt_timeout=3.0)

The client generates the receipt header, waits for the matching RECEIPT, and raises SendReceiptError with reason rejected, timeout or connection_lost. receipt_timeout=None (the default) is byte-for-byte the old path; a timeout must be finite and positive, and passing your own receipt header alongside it is a ValueError.

Only rejected is definitive. A frame is fully buffered before the socket is drained, so on timeout or connection_lost the broker may well have taken the message. The confirmed write is therefore attempted exactly once: connect_retry_attempts still applies while acquiring a connection, but write_retry_attempts does not, because neither Connection nor WebSocketConnection can distinguish a failure before the bytes left the process from one after they reached the broker. Nothing is replayed silently; retrying is the caller's decision, and the README says so and points at deduplication by a stable application event ID.

Transaction.send() and batch publication are deliberately untouched: a receipt for a transactional SEND says nothing about whether the COMMIT succeeded.

Design: one receipt registry, not two

Pending receipts move out of ActiveSubscriptions into PendingReceipts (receipts.py): connection-scoped, epoch-keyed, dispatching RECEIPT / ERROR / connection-loss to whichever waiter registered the ID. Subscriptions keep their own failure semantics as one waiter; send.py is a much simpler second one. The subscription state machine itself is unchanged.

Sharing the registry is not only tidiness. Client._reserve_handler_slot lets the frame reader run ahead of max_concurrent_handlers only while receipts are pending. Had sends tracked their receipts separately, a confirmed send() issued while handlers were saturated would have hung until its own timeout, because the reader was parked on the semaphore and never read the RECEIPT. There is a regression test for exactly that.

An ERROR without receipt-id fails every pending confirmation on that connection, matching the policy #204 established for subscriptions. on_error_frame still sees every broker error.

faststream-stomp

receipt_timeout on the broker, on declared publishers, and per call, with the same precedence as add_content_length (per-call, then publisher, then broker), defaulting to None everywhere. publish_batch does not take it and ignores any configured default rather than claiming a confirmation it cannot make. TestStompBroker never waits for a real receipt. AsyncAPI output is unaffected.

Tests

Deterministic tests at the real client/frame-dispatch seam (the queue-backed connection harness from #204, hoisted into conftest.py so both suites share it): receipt matching, unrelated receipts, concurrent sends, correlated and uncorrelated ERROR, timeout, connection loss and cancellation both during and after the write, a receipt that wins the race against the deadline, stale-epoch receipts, invalid timeouts, and the header conflict. receipts.py and send.py are at full line coverage. An Artemis/ActiveMQ Classic integration test proves a confirmed send returns and the message arrives.

Full suite with both brokers: 476 passed, 1 skipped. mypy and ruff clean.

Note on the last commit

Bump dev dependencies and disable ruff's suppression-style rules is independent maintenance and can be dropped or split out. Beside the version bumps it turns off two preview rules that select = ["ALL"] pulls in: RUF105 rewrites # noqa: comments to # ruff: ignore[...], and RUF201 rewrites rule codes in this configuration to rule names. Both are stylistic, and with fix = true and unsafe-fixes = true they rewrite the repo on every just lint, so they are now ignored and the existing noqa comments and rule codes stay as they are. just lint is idempotent and reports no errors.

faststream-stomp's floor moves to stompman>=3.16.0, on the assumption that this ships as stompman-3.16.0; adjust that line before tagging if you pick a different number, and release stompman before faststream-stomp. The floor is load-bearing: StompProducer.publish passes receipt_timeout to Client.send on every publish, so an older stompman would raise TypeError for every message. CI cannot catch a wrong floor, because the workspace source always resolves stompman locally.

Client.send(receipt_timeout=...) now waits for the broker's STOMP RECEIPT
instead of returning once the frame is written to the socket. Failures are
reported as SendError with reason rejected, timeout or connection_lost; a
confirmed send is attempted once and never replayed, so an ambiguous outcome
stays the caller's decision.

Pending receipts move from ActiveSubscriptions into a connection-scoped,
epoch-aware PendingReceipts registry shared by subscriptions and sends. The
frame reader's handler-concurrency backpressure consults the same registry,
so a confirmed send still completes while message handlers are saturated.

faststream-stomp exposes receipt_timeout on the broker, on declared
publishers and per call, with the same precedence as add_content_length.
Batch publishing goes through a transaction and does not claim confirmation.
The error only ever arises from receipt confirmation, so the name says
which failure it reports.
anyio, faker, hypothesis and ruff move to their current releases.

ruff 0.16.8 selects two preview rules through `select = ["ALL"]` that
rewrite suppression comments to `ruff: ignore[...]` and rule codes in this
configuration to rule names. Both are stylistic, so ignore RUF105 and
RUF201 and keep `noqa` comments and rule codes.
@lesnik512
lesnik512 force-pushed the feature/send-receipt-confirmation branch from 968765a to 26b1022 Compare September 17, 2026 14:19
StompProducer.publish passes receipt_timeout to Client.send on every
publish, so an older stompman raises TypeError for every message, not only
for opted-in ones. The workspace source hides this in CI, where the local
stompman is always used.
@lesnik512
lesnik512 merged commit 7ea9c90 into main Sep 17, 2026
6 checks passed
@lesnik512
lesnik512 deleted the feature/send-receipt-confirmation branch September 17, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant