Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Messenger] Add retry and failure transport support to the sync transport - #65902

Open
nicolas-grekas wants to merge 1 commit into
symfony:8.2from
nicolas-grekas:messenger-sync-retry
Open

[Messenger] Add retry and failure transport support to the sync transport#65902
nicolas-grekas wants to merge 1 commit into
symfony:8.2from
nicolas-grekas:messenger-sync-retry

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Sep 8, 2026

Copy link
Copy Markdown
Member
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

This is a proposal, open for discussion. It started from Dariusz Gafka's article Symfony Messenger vs Ecotone: The Real Difference, which describes how Ecotone approaches this. What is proposed here is a free interpretation for Symfony, built on Messenger's own model rather than ported from Ecotone, so it departs from the article where the two models differ. The examples in this description are the article's.

Retries and the failure transport only exist in the worker's event listeners. A message handled synchronously through the sync:// transport, a payment webhook handled in the request for instance, has no retry and no dead letter: an exception is the only outcome, and the per-transport retry_strategy and failure_transport configuration is silently ignored. This PR makes both available to synchronous handling, on an opt-in basis:

framework:
    messenger:
        transports:
            webhooks:
                dsn: 'sync://?retry=true&failure_transport=true'
                retry_strategy:
                    max_retries: 2
                failure_transport: failed
        routing:
            App\Webhook\PaymentReceived: webhooks
  • retry=true: a failing message is handled again right away, as many times as the transport's retry strategy allows. Delays are not honored: the transport runs in the calling process and does not wait between attempts. UnrecoverableExceptionInterface is never retried, and a forced retry (RecoverableExceptionInterface) is bounded by the strategy too, since an unbounded retry without a wait would loop in place. Handlers that already succeeded are not run again.
  • failure_transport=true: a message that still fails is sent to the transport's failure transport (or the global one) instead of throwing; dispatch() returns an envelope carrying SentToFailureTransportStamp, RedeliveryStamp and ErrorDetailsStamp, and the message shows up in messenger:failed:show like any other.

This is the handling-phase half of what #64628 asks for webhooks: route the webhook messages to such a transport and they get retries and a dead letter without a queue. Parse failures happen before any message exists and stay out of scope.

Both options can also be given in the transport options array, which wins over the DSN query. Enabling a flag without a retry strategy or a failure transport configured for that transport fails when the transport is created.

Without the flags, the handling of a message is unchanged: it is dispatched once and the exception propagates. The one thing that happens for every sync:// transport, flags or not, is the new SyncMessageFailedEvent below, which reports a failure that was previously observable only by catching the exception at the call site.

Public API

  • SyncTransport::__construct(MessageBusInterface $messageBus, ?RetryStrategyInterface $retryStrategy = null, ?SenderInterface $failureSender = null, ?EventDispatcherInterface $eventDispatcher = null, ?LoggerInterface $logger = null)
  • SyncTransportFactory::__construct(MessageBusInterface $messageBus, ?ContainerInterface $retryStrategyLocator = null, ?ContainerInterface $failureSenderLocator = null, ?EventDispatcherInterface $eventDispatcher = null, ?LoggerInterface $logger = null), both locators keyed by transport name, and the retry and failure_transport boolean options.
  • Symfony\Component\Messenger\Event\SyncMessageFailedEvent and SyncMessageRetryingEvent (see below).
  • The retry decision of SendFailedMessageForRetryListener moved to an @internal RetryDecider helper so the worker and the sync transport cannot diverge.

Events

Synchronous handling now has the same failure and retry signals the worker has, for monitoring listeners:

  • SyncMessageFailedEvent (getEnvelope(), getTransportName(), getThrowable(), willRetry()) is dispatched for every failed attempt, after the retry decision and before it is acted on.
  • SyncMessageRetryingEvent (getEnvelope(), getTransportName()) is dispatched right before the next attempt, with the envelope carrying the new RedeliveryStamp.

Together with the per-handler HandlerStartingEvent, HandlerSuccessEvent and HandlerFailureEvent already dispatched on 8.2 whatever the transport, this covers #57623. The worker events keep their Worker prefix on purpose: their listeners (retry, failure transport, service reset) act on them, so reusing them for synchronous handling would double-process the message.

Prerequisite, already merged

A failed attempt runs as a nested dispatch inside the calling request, so the messages it queued with DispatchAfterCurrentBusStamp would still be dispatched after a later successful attempt, or after the message went to the failure transport. That rollback was split out of this pull request and merged on its own as #66032, so it is no longer part of this diff.

Checks

  • ./phpunit src/Symfony/Component/Messenger/Tests and ./phpunit src/Symfony/Bundle/FrameworkBundle/Tests: green (usual missing-server skips). The existing SendFailedMessageForRetryListenerTest is unchanged and green.
  • Revert-verified: the transport, factory and helper tests fail on the base sources; the bundle wiring tests fail without the factory arguments; the functional test (a sync://?retry=true&failure_transport=true transport with an in-memory failure transport) fails without the wiring.

Documentation

  • The two options, where to put them, and what happens without them.
  • Retries run immediately; only max_retries (or a custom isRetryable()) bounds them; delays, multiplier, max delay and jitter are ignored; forced retries stay bounded.
  • Detecting the failure-transport outcome on the returned envelope; messenger:failed:show and messenger:failed:retry apply.
  • Messages queued with DispatchAfterCurrentBusStamp during a failed attempt are dropped ([Messenger] Drop the messages queued by a failed nested dispatch #66032).
  • The two events, and that monitoring listeners subscribed to WorkerMessageFailedEvent need SyncMessageFailedEvent too to see synchronous handling.

Comment thread src/Symfony/Component/Messenger/Event/SyncMessageRetriedEvent.php Outdated

@wachterjohannes wachterjohannes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct and well-tested, the shared RetryDecider keeps the worker and this in sync on purpose. Verified locally, all green. Details inline. Also: is there a docs PR for this, and given the DispatchAfterCurrentBusMiddleware fix is a real bug independent of the rest, worth splitting it out for backport as you floated in the description?

// attempts, an unbounded retry would loop in place until the failure goes away
$shouldRetry = $this->retryStrategy && false !== RetryDecider::decideFromException($e) && $this->retryStrategy->isRetryable($envelope, $e);

$this->eventDispatcher?->dispatch(new SyncMessageFailedEvent($envelope, $alias, $e, $shouldRetry));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fires on every failed attempt regardless of $this->retryStrategy/$this->failureSender, and messenger.php wires the dispatcher unconditionally. So the description's "without the flags, nothing changes" isn't quite right: a plain sync:// transport starts dispatching this event after the upgrade. Harmless today since nothing subscribes yet, but worth fixing the claim.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and it is deliberate. A failed handler is worth reporting whether or not the transport retries, and without the event the only way to observe a synchronous failure is to catch the exception at every call site.

What was wrong is the claim, now fixed in the description: without the flags the handling of a message is unchanged, it is dispatched once and the exception propagates, and the event is what the upgrade adds. Nothing in the framework subscribes to it.

@nicolas-grekas

Copy link
Copy Markdown
Member Author

Both answers, thanks for asking.

The DispatchAfterCurrentBusMiddleware change is split out: #66032 proposes it on 6.4 on its own, with a test that fails on the current code because the event queued by the failed nested dispatch reaches its handler anyway. This branch keeps the change until that one is merged up, since the synchronous retries need it. The description says so now.

No docs pull request yet, for this one or for the rest of the series. The public API is still moving under review, so I am waiting for these to be merged before writing them; the points each one has to carry are listed at the end of its description in the meantime.

nicolas-grekas added a commit that referenced this pull request Sep 13, 2026
…spatch (nicolas-grekas)

This PR was merged into the 6.4 branch.

Discussion
----------

[Messenger] Drop the messages queued by a failed nested dispatch

| Q             | A
| ------------- | ---
| Branch?       | 6.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Issues        | -
| License       | MIT

A message dispatched with a `DispatchAfterCurrentBusStamp` from inside a handler is queued and dispatched once the root dispatch is over. When the nested dispatch that queued it fails, the queued messages are dispatched anyway, although the work that queued them never completed.

The root dispatch already drops its queue when handling throws, with the reasoning that the queued messages were likely dependent on the work that queued them. The nested path did not, so the two halves of the same middleware disagreed.

```php
public function __invoke(RegisterUser $command): void
{
    try {
        // this dispatch queues UserRegistered, then throws
        $this->bus->dispatch(new CreateProfile($command->getUuid()));
    } catch (\Throwable) {
        // the caller decides to carry on
    }

    // UserRegistered was dispatched anyway, for a profile that was never created
}
```

Found while working on #65902, which turns each attempt of a synchronous retry into a nested dispatch and therefore meets this on every failed attempt. It is a bug of its own, so it is proposed here separately for the lowest maintained branch.

## Checks

- `./phpunit src/Symfony/Component/Messenger/Tests` on 6.4: 425 tests, green.
- `testMessagesQueuedByAFailedNestedDispatchAreDropped` errors without the fix, because the event queued by the failed nested dispatch reaches the handling middleware a third time.

Commits
-------

c0ccae2 [Messenger] Drop the messages queued by a failed nested dispatch
…port

A message handled through the sync transport had no retry and no dead
letter: an exception was the only outcome, and the retry_strategy and
failure_transport configured for the transport were ignored.

Both features are now opt-in through the transport DSN or options.
"sync://?retry=true" handles a failed message again, immediately and as
many times as the retry strategy of the transport allows.
"sync://?failure_transport=true" sends a message that still fails to the
failure transport of the transport instead of throwing. The delays of
the retry strategy are not honored, since the transport cannot wait
inside the calling process, and a forced retry is bounded by the
strategy for the same reason.

SyncMessageFailedEvent and SyncMessageRetryingEvent are dispatched by the
transport for each failed attempt and before each new attempt, so that
listeners monitoring failures see synchronously handled messages too.
The worker events are not reused because their listeners (retry,
failure transport, service reset) would react to them.

The rules deciding whether an exception can be retried move from
SendFailedMessageForRetryListener to the internal RetryDecider class,
shared with the transport.

MessengerBundle passes the retry strategy locator, the failure senders
locator, the event dispatcher and the logger to the sync transport
factory.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants