Handling Transient Failures in Symfony Messenger with Retry Strategies and Failure Transports
Learn how to configure Symfony Messenger's built‑in retry strategy and failure transport to automatically handle transient errors, keep failed messages inspectable, and replay them safely — without writing custom retry loops.
18 Oct 2025, 19:05 UTC

The problem: transient downstream errors break async pipelines
When a message handler calls an external API, a temporary network glitch or a rate‑limit response can cause the handler to throw. Without a systematic retry mechanism the message is lost, the job stays stuck, and operators have to manually re‑queue it. Symfony Messenger ships with a declarative retry strategy and an optional failure transport that together turn this operational headache into configuration.
Built‑in retry strategy
Each transport can declare a retry_strategy block. The four keys control exponential backoff:
- max_retries – how many attempts after the first failure (default 3).
- delay – initial wait in milliseconds (default 1000).
- multiplier – factor applied to the previous delay (default 2).
- max_delay – ceiling for the calculated delay (default 0, meaning unlimited).
Because the retry logic lives in the transport layer, your handler code stays pure – no try/catch loops, no sleep calls. The transport will re‑dispatch the same envelope up to max_retries times before giving up.
Routing exhausted messages to a failure transport
When retries are exhausted, Messenger can forward the envelope to a separate transport named via failure_transport. That transport is typically a Doctrine‑backed queue so the failed messages land in a database table where they can be inspected, replayed, or discarded with console commands.
The failure transport is not enabled automatically; you must define a transport (e.g. failed) and reference it in the primary transport’s configuration.
Worked example: Doctrine transport with exponential backoff
Assume a Symfony 6.4/7.x project using the Doctrine transport for both the main queue and the failure queue. The following config/packages/messenger.yaml snippet shows a complete, production‑ready setup.
framework:
messenger:
transports:
# Primary async transport
async: 'doctrine://default?queue_name=async'
# Failure transport – same DB, different queue
failed: 'doctrine://default?queue_name=failed'
routing:
App\Message\ProcessOrder: async
# Retry + failure config on the primary transport
transports:
async:
dsn: 'doctrine://default?queue_name=async'
retry_strategy:
max_retries: 5
delay: 2000 # 2 s initial
multiplier: 3 # 2 s → 6 s → 18 s → 54 s → 162 s
max_delay: 60000 # cap at 60 s
failure_transport: failed
What this does
- Messages sent to
asyncare persisted in themessenger_messagestable withqueue_name = 'async'. - If the handler throws, Messenger waits 2 s, retries, then 6 s, 18 s, … up to five retries.
- After the fifth retry the envelope is moved to the
failedqueue (table rows withqueue_name = 'failed').
Verifying the effective config
# Run in the project root (requires PHP CLI and Symfony console)
bin/console debug:config framework messenger
The command prints the merged configuration, confirming that retry_strategy and failure_transport are active for the async transport.
Operational workflow for failed messages
Once messages sit in the failure queue you can inspect and replay them without writing SQL.
bin/console messenger:failed:show– lists each failed envelope with its class, error message, and retry count.bin/console messenger:failed:retry <id>– re‑queues a specific message back to its original transport (hereasync) after you’ve fixed the downstream issue.bin/console messenger:failed:remove <id>– discards a message you decide is unrecoverable.
All three commands require the same permissions as any other console command (typically the application’s runtime user). They read/write the Doctrine transport tables, so the database user must have SELECT, INSERT, UPDATE, DELETE on the messenger tables.
Trade‑offs and limitations
| Aspect | Benefit | Risk / Mitigation |
|---|---|---|
| Aggressive retries | Higher chance of eventual success for flaky services. | Can amplify load on an already struggling API. Start with modest max_retries (3‑5) and a sensible max_delay. |
| Idempotency requirement | Retries re‑execute the handler, so duplicate side effects (e.g. double charge) are possible. | Design handlers to be idempotent (use deterministic IDs, upsert patterns, or external deduplication). |
| Failure transport not automatic | Gives you full control over where dead letters live. | If you forget to define failure_transport, exhausted messages are simply dropped. Always pair a primary transport with a failure transport. |
| Version‑specific YAML keys | Symfony 6.x/7.x use the structure shown above. | Older versions (5.x) used retry_strategy under transport rather than transports.async.retry_strategy. Check debug:config output for your exact version. |
Actionable next steps
- Add a
failedtransport tomessenger.yamlif it’s missing. - Configure
retry_strategyon your primary async transport with conservative defaults (e.g.max_retries: 3,delay: 1000,multiplier: 2,max_delay: 30000). - Run
bin/console debug:config framework messengerto verify the merged config. - Trigger a test failure (e.g. temporarily make the external API return 503) and confirm the message appears via
messenger:failed:show. - After fixing the root cause, replay with
messenger:failed:retry <id>and watch the handler succeed on the next attempt.
By codifying retries and dead‑letter handling in configuration you keep handler logic clean, gain observability through the database, and give operators a safe, repeatable way to recover from transient outages.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.