4343from faststream_outbox .message import ENVELOPE_MANAGED_HEADERS , OutboxInnerMessage , Retry , Terminal
4444from faststream_outbox .parser .parser import OutboxParser
4545from faststream_outbox .publisher .fake import OutboxFakePublisher
46- from faststream_outbox .response import OutboxResponse
46+ from faststream_outbox .response import OutboxResponse , _OutboxConfigError
4747from faststream_outbox .subscriber .config import OutboxSubscriberConfig , OutboxSubscriberSpecificationConfig
4848
4949
@@ -79,14 +79,6 @@ class _PendingFlush:
7979 tags : dict [str , typing .Any ]
8080
8181
82- # Marker exception raised by programming guards inside ``process_message`` (e.g.
83- # the OutboxResponse + foreign-publisher dual-fire guard). Inherits from
84- # ``RuntimeError`` so ``pytest.raises(RuntimeError, ...)`` in tests catches it, but
85- # is a distinct subclass so ``consume()`` and ``dispatch_one`` can re-raise it while
86- # still swallowing plain handler-raised ``RuntimeError``s.
87- class _OutboxConfigError (RuntimeError ): ...
88-
89-
9082# Cap for the ``last_exception`` string written to the DLQ. Some exceptions carry
9183# huge payloads (validation errors with the full request body, asyncpg ``DataError``
9284# with the rejected row, etc.). An unbounded ``repr`` would extend the writer
@@ -643,11 +635,11 @@ async def _worker_inner(self, *, writer_conn: "AsyncConnection | None") -> None:
643635 # P18: a config error (e.g. OutboxResponse + foreign publisher) is not a
644636 # connection failure. Letting it propagate to _run_with_reconnect would tear
645637 # down the writer connection and back off (up to 30s), throttling unrelated
646- # rows. Log it and continue; the row's lease expires and it is reclaimed.
647- # Fix the configuration to stop the error .
638+ # rows. dispatch_one already wrote the recorded outcome, so the retry
639+ # strategy applies; log it and continue until the configuration is fixed .
648640 self ._log (
649641 log_level = logging .ERROR ,
650- message = f"Outbox configuration error (fix required; row left to lease-expiry retry ): { e !r} " ,
642+ message = f"Outbox configuration error (fix required; row retried per its strategy ): { e !r} " ,
651643 exc_info = e ,
652644 )
653645 finally :
@@ -722,14 +714,12 @@ async def dispatch_one( # linear pipeline: guard, consume, branch on outcome, f
722714 try :
723715 await self .consume (row )
724716 except _OutboxConfigError :
725- # _OutboxConfigError escaping consume() is a programming guard (e.g.
726- # the OutboxResponse + foreign-publisher dual-fire guard). Re-raise so
727- # callers see the error immediately — sync test dispatch surfaces it
728- # from outbox.publish; the worker loop's outer except re-raises it via
729- # _run_with_reconnect so it's logged at ERROR level with a reconnect
730- # backoff rather than silently ignored.
717+ # A programming guard tripped after the handler ran. Write the outcome the ack
718+ # middleware recorded, then re-raise: sync test dispatch surfaces it from
719+ # outbox.publish, and _worker_inner logs it at ERROR. Inline (buffer=None),
720+ # because a buffered row would be task_done'd twice once the error unwinds.
731721 row .handler_duration_seconds = time .perf_counter () - start_perf
732- self ._emit_dispatch_abandoned (row )
722+ await self ._write_outcome (row , base , buffer = None , writer_conn = writer_conn )
733723 raise
734724 except Exception as e : # noqa: BLE001
735725 # No ack/nack metric here: the row was never marked terminal/retry, so
@@ -745,6 +735,18 @@ async def dispatch_one( # linear pipeline: guard, consume, branch on outcome, f
745735 self ._emit_dispatch_abandoned (row )
746736 return False
747737 row .handler_duration_seconds = time .perf_counter () - start_perf
738+ return await self ._write_outcome (row , base , buffer = buffer , writer_conn = writer_conn )
739+
740+ async def _write_outcome (
741+ self ,
742+ row : OutboxInnerMessage ,
743+ base : dict [str , typing .Any ],
744+ * ,
745+ buffer : "list[_PendingFlush] | None" ,
746+ writer_conn : "AsyncConnection | None" ,
747+ ) -> bool :
748+ """Write the outcome recorded during consume and emit its metric. Returns True iff buffered."""
749+ logger = self ._outer_config .logger .logger .logger if self ._outer_config .logger else None
748750 # Shutdown race: SubscriberUsecase.consume() returns None without invoking
749751 # process_message when self.running has been flipped to False by stop().
750752 # Detecting that here lets us preserve the row instead of falling through
@@ -1006,10 +1008,9 @@ async def consume(self, msg: OutboxInnerMessage) -> typing.Any:
10061008 ``OutboxResponse + foreign-publisher`` dual-fire check in
10071009 ``process_message``) raise ``_OutboxConfigError`` (a ``RuntimeError``
10081010 subclass) so the guard is distinguishable from handler-raised
1009- ``RuntimeError``s. Re-raising here lets ``dispatch_one`` surface it to
1010- the caller (sync test dispatch exposes it from ``outbox.publish``; the
1011- worker loop re-raises so the error is logged and the lease expires rather
1012- than silently swallowing a configuration mistake).
1011+ ``RuntimeError``s. Re-raising here lets ``dispatch_one`` write the recorded
1012+ outcome and surface the error: sync test dispatch exposes it from
1013+ ``outbox.publish``, and the worker loop logs it at ERROR.
10131014
10141015 # Upstream equivalent (extended):
10151016 # SubscriberUsecase.consume
0 commit comments