Skip to main content
Outbound Resilience makes sure your agent’s reply actually reaches the user on every channel — retrying transient send errors with bounded backoff, and parking permanently-failed replies in a durable per-platform DLQ by default so they can be replayed instead of silently lost. No configuration is required.
Safe by default (as of PraisonAI #3447 / PR #3446). The outbound reply is a durable delivery obligation by default — symmetric with the inbound journal. A permanent or exhausted send failure is automatically parked at ~/.praisonai/state/<platform>/outbound_dlq.sqlite so it can be replayed, without any configuration. To disable durable parking entirely, set outbound_resilience.enabled = false.
The user sends a message on Slack; transient send failures retry with backoff until the reply is delivered or parked in the DLQ — and a parked reply is automatically redelivered on the next gateway boot.

Quick Start

1

Retry and durable parking are both on by default

Every bot adapter retries transient failures with backoff and parks a permanent / exhausted failure at a canonical per-platform DLQ so nothing is silently lost — with no config on your side.
2

Override the defaults only when you need to

Set outbound_resilience in praisonai.yml only to override defaults — e.g. more attempts, a longer cap, or a custom DLQ path.
The default DLQ path is ~/.praisonai/state/<platform>/outbound_dlq.sqlite — only set dlq_path if you need it elsewhere (e.g. a shared volume in Docker).
3

Opt out with a single flag

enabled: false disables both retry and durable parking on that channel — a permanently-failed reply on that channel is lost, matching the pre-safe-by-default behaviour.

How It Works

When a send fails, the adapter classifies the error:

Automatic crash-recovery on start

Every gateway-managed channel redelivers any reply parked by a crash on a previous run — no operator step, symmetric with the inbound journal’s replay() sweep. The gateway schedules WebSocketGateway._replay_outbound_dlq(bot) about 5 seconds after each channel task starts (a grace period for the transport to connect), then calls OutboundDLQ.redeliver(send) — draining parked replies oldest-first through the adapter’s own send_message, so the platform’s normal retry/backoff still applies. A redelivery that fails again is kept for the next boot, bounded by the DLQ’s existing TTL / max_size / attempts invariants. Failure never blocks channel start — the behaviour degrades to today’s “parked until manual replay”. Users receive redelivered replies with a visible duplicate marker so an ambiguous redelivery is never silent:
♻️ Recovered after restart — this reply may be a duplicate. <original reply text>
The marker is prepended idempotently — a parked reply that already starts with it is not double-marked. When something is recovered you’ll see this line in the gateway log:
Only the exact reply the drainer is currently redelivering is suppressed from re-parking. A genuinely-new reply that fails concurrently during the boot drain is still parked — the durable-outbound contract holds even mid-recovery.
Automatic crash-recovery introduced in PraisonAI #3862. Separate from this boot-time DLQ drainer, ChannelSupervisor also re-drains the durable outbox when a channel recovers from a transient outage — see Channel supervision → Recovery-triggered outbox re-drain.

Which Channels Does This Apply To?

Both retry/backoff and durable DLQ parking are on by default for every adapter — no dlq_path opt-in required. ¹ Default DLQ location: ~/.praisonai/state/<platform>/outbound_dlq.sqlite. Override with outbound_resilience.dlq_path; disable with outbound_resilience.enabled = false.
Safe by default (PR #3447). The outbound DLQ is now enabled on every adapter without any configuration — mirroring the inbound journal, which has been durable-by-default since PraisonAI #1915. A permanent send failure is parked at ~/.praisonai/state/<platform>/outbound_dlq.sqlite for replay instead of silently dropped. The operator escape hatch is outbound_resilience.enabled = false.
Both text and media now inherit the same policy:

Media uploads share this policy

The same retry wrapper covers outbound media uploads (DeliveryRouter.send_media → Telegram send_photo, Slack files_upload_v2, Discord file send). A transient transport failure on an image or file attachment is retried with the same bounded backoff and Retry-After honouring as the text path.
  • The adapter’s configured _outbound_backoff BackoffPolicy is reused; if the adapter has no policy, a default of initial_ms=1000, max_ms=10000, factor=1.5, max_attempts=3 applies.
  • Adapters with no upload primitive still return False immediately — no wasted retries.
  • Permanent errors surface as before after the attempt budget is spent.
See Outbound Media Delivery → Retry & backoff on transient failures for the media-specific sequence diagram and behaviour matrix.

Configuration Options

All settings live under outbound_resilience in your channel config (in praisonai.yml or via BotConfig). The canonical default path is resolved via resolve_durable_store_dir(<platform>). Set the PRAISONAI_HOME environment variable to relocate the whole ~/.praisonai/ root (useful in tests or containers).
Before PraisonAI #3447 you had to set dlq_path to opt into a DLQ park. On #3447+ that step is unnecessary — the default path is already applied. Existing configs that set dlq_path continue to work unchanged (an explicit path always wins over the default).
Full YAML example:

Transient DLQ Init Recovery

If the default DLQ path is briefly unwritable when the adapter tries to initialise it (SQLite lock, disk permission race, transient FS error), Outbound Resilience does not latch itself into a permanently-degraded state. The failed send this turn is retried at the transport level and the DLQ init is re-attempted on the next send — so a first-turn storage blip does not silently disable durable parking for the life of the process. You will see a Failed to initialize outbound DLQ (will retry on next send): <error> warning in the log the first time this happens; a subsequent send will silently succeed if storage has recovered.

Common Patterns

Opt one channel out entirely:
Use a shared DLQ path (e.g. a Docker volume):
Aggressive retry for a flaky upstream (defaults handle 99% of cases):
DLQ replay — failed replies parked in the SQLite DLQ can be replayed with the same tooling as the inbound DLQ. See Inbound DLQ for the analogous replay command.

Best Practices

The default DLQ path is ~/.praisonai/state/<platform>/outbound_dlq.sqlite. If your process runs as a user with no home directory, or ~/.praisonai/ is on a container tmpfs, parked failures are still logged but lost on restart — set PRAISONAI_HOME to a persistent path or override dlq_path explicitly.
The defaults (initial_ms=1000, max_ms=10000, max_attempts=3) handle the vast majority of transient failures. Only adjust them after observing DLQ entries accumulating in your SQLite file — that signals the defaults are too aggressive or too conservative for your traffic.
The default ~/.praisonai/state/<platform>/outbound_dlq.sqlite is already persistent on any conventional deployment. Set dlq_path when you need a shared filesystem (multi-replica) or a distinct file per instance.
Slow retries usually mean the platform backoff is long (e.g., Retry-After: 60). The mixin already honours Retry-After headers automatically. Increase max_ms to allow longer waits rather than disabling resilience entirely — enabled: false also throws away durable parking, which is almost never what you want.
Outbound Resilience now parks permanent and exhausted failures automatically. You don’t need to set dlq_path or opt in to a separate “durable delivery” mode — the mixin uses the same canonical ~/.praisonai/state/<platform>/outbound_dlq.sqlite store as the inbound journal. Set outbound_resilience.enabled = false only if you genuinely want the pre-#3447 retry-without-park behaviour (fire-and-forget channels, ephemeral test bots).
Once a permanent / exhausted failure is parked in the DLQ, the gateway automatically redelivers it on the next boot (labelled ♻️ Recovered after restart — this reply may be a duplicate.). See Automatic crash-recovery on start. The only remaining loss window is a crash that hits after the send failed but before the entry was persisted — the same narrow window covered by Durable Delivery’s SQLite outbox. Reach for Durable Delivery when you need that pre-park guarantee; for the typical exhausted-retry case, Outbound Resilience alone is now crash-safe.

Durable Outbound Delivery

SQLite outbox for crash-safe delivery with send_durable() and startup drain

Inbound DLQ

Dead-letter queue for failed inbound message processing

Bot Rate Limiting

Per-user and per-channel rate limiting for bot commands

Gateway Channel Config

Full reference for all per-channel configuration options