An outbox ticket is a handle, not a promise of delivery
A notification triggered by a web request should not make that request sit waiting on a provider. Deferring the send does not make it safe, though. The template can still be missing a value, the recipient can be unroutable, the provider can be down, and a quiet window can have opened between the moment the response went out and the moment somebody tried to deliver. The failure shows up after the request is gone, so something has to be able to hold it.
Multi-channel notification services are a component we end up building in almost every system we take on: order and payment events fanning out to messengers, mobile and web push. The fan-out is rarely the part that hurts. What gets designed last is where a deferred send goes when it does not work. In notify-dispatch the answer is a ticket. Enqueueing hands you one back and touches nothing else, and the drain settles it with a receipt or with the exception the send raised.
Enqueue touches nothing#
Outbox.enqueue is deliberately boring. It builds a Ticket out of the recipient, the template, a copy of the context and any keyword Dispatcher.send would accept, stamps enqueued_at from the outbox's clock, appends it to a deque under a Condition, and hands it back.
from notify_dispatch import Outbox, Recipient, TicketState
outbox = Outbox(dispatcher)
ticket = outbox.enqueue(
Recipient(phone="+49151000000", email="ada@example.com"),
confirmation,
{"order_id": "A-1042", "item_count": 3, "total": 59.9},
)
ticket.state is TicketState.PENDING # True
ticket.receipt # None
ticket.error # None
len(outbox) # 1Nothing is rendered yet. No dedup key is claimed and no provider is called. That is what the boundary is for: the request path pays for a hex id, a dict copy and a deque append.
Two details carry weight here. The context is copied into a MappingProxyType, so the dict a caller goes on to mutate after the response is not the dict that gets rendered later. And enqueued_at comes from the outbox's own clock, which keeps arrival order and delivery time as two separate facts you can look at afterwards.
The drain is the ordinary send#
drain pops tickets oldest first and runs the same Dispatcher.send a synchronous caller would have run, on the calling thread. A Receipt fulfils the ticket, any Exception fails it, both set the ticket's internal event, and then the drain moves on to the next one.
handled = outbox.drain()
handled[0] is ticket # True
ticket.is_sent # True
ticket.result().reference # whatever the provider returned
outbox.drain() # () - a second drain finds nothingBecause the failure gets stored rather than raised, one bad ticket does not end the batch:
broken = outbox.enqueue(recipient, confirmation, {"order_id": "A-1042"})
fine = outbox.enqueue(recipient, confirmation, full_context)
outbox.drain()
broken.is_failed # True
type(broken.error) # MissingVariableError
fine.is_sent # Truebroken never reached a provider. The template refused it at render time, which is what would have happened inside the request had the send been synchronous, only recorded on an object instead of thrown at the web layer. drain(limit=...) handles a prefix and leaves the rest queued in arrival order. A limit below 1 raises ValueError, because draining nothing is a bug in the caller rather than a quiet no-op.
Every dispatcher rule fires at drain time#
This is the consequence worth sitting with: a ticket records a deferred decision rather than a scheduled delivery. Everything the dispatcher does, it does when the drain gets there.
- Retries and dead letters. An exhausted provider records a
DeadLetteron the dispatcher's queue and the ticket holds theDeliveryError. The failure exists twice on purpose: the dead letter is for whoever operates the service, the ticket is for whoever enqueued. - Deduplication. A
dedup_keyqueued with a ticket is claimed only when that ticket gets drained. Two tickets naming the same event can both sit in the queue. The first one drained claims the key and sends, and the second gets the first receipt back marked as a duplicate. Dedup is settled by drain order, not by enqueue order. - Quiet hours. The recipient's quiet window is evaluated at the drain moment. A ticket enqueued in the afternoon and drained after a backlog can come out the other side with a
QuietHoursError. If that decision has to belong to the moment the event happened instead,nowhas to be queued as an option with the ticket.
# Drained inside the recipient's quiet window.
silenced = outbox.enqueue(night_recipient, confirmation, full_context)
outbox.drain()
type(silenced.error) # QuietHoursErrorNone of this is surprising once you say it out loud, but it changes where you look. A notification that "went missing" was usually not lost by the outbox. It was refused by a rule that ran minutes after the code which enqueued it had already finished.
Asking a ticket how it went#
Ticket.state is PENDING until the ticket is settled, then SENT when there is no stored error and FAILED otherwise. result() raises the stored error first, and if there is no error and no receipt it raises TicketPendingError, which carries the ticket_id.
That ordering is the API insisting that "it failed" and "nobody has tried yet" are different answers. A handler that treats every missing receipt as a failure will report delivery problems that are really just backlog, and the cheapest place to keep those apart is right here, in the accessor. For the cases that want to look synchronous (a test, or an admin action that reports the outcome) wait(timeout) blocks until the ticket is settled and returns whether it is.
The worker is that drain on a thread#
from notify_dispatch import Worker
worker = Worker(outbox)
worker.start()
...
worker.stop()The loop waits up to its interval (0.05 seconds by default) for an arrival before draining, so an idle process is not spinning and a freshly enqueued ticket is not waiting out a fixed sleep. start on a worker that is already running raises RuntimeError rather than quietly adding a second drainer. stop sets the stopping flag, wakes the outbox so the wait returns at once, and the loop drains one more time on its way out. That last drain matters, because the tickets a naive shutdown is most likely to abandon are the ones enqueued last.
What it costs to run#
The deque lives in memory. That is the whole cost summary, and it deserves to be stated rather than discovered: the deferral buys latency, not durability. A crash or a deploy between enqueue and drain loses the pending tickets, and because nothing was rendered and no provider was contacted, there is no trace of them anywhere else either. For a password reset that is a bad trade. For a best-effort order confirmation sitting behind a durable order record it is often the right one, since the order row is the source of truth and the notification can be re-enqueued from it.
Two more things to size before you run this:
- Nothing trims the queue. Enqueue is cheap and unbounded, so the drain rate has to keep up with the arrival rate or the process grows. The failure side already acknowledges this,
DeadLetterQueue(limit=...)bounds what it keeps, while the outbox side is yours to watch. - A single worker serialises sends. Retry backoff inside one send holds the whole drain, so a slow provider turns into everyone's latency. The lever there is more drainers, or a smaller retry policy on queued sends. It is not a change to the outbox.
If a notification genuinely must survive a restart, the queue has to be a row in a database rather than a deque, and that is a different component with different costs. What the ticket shape gets right either way is probably the part that usually gets skipped: a deferred send has somewhere to fail, with its own identity, its own exception and its own state, instead of a traceback in a worker log that nobody owns.