Outbound Resilience
Retry transient send failures on every bot channel and park permanently-failed replies in a per-platform durable DLQ — safe by default, no config needed.
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.
from praisonaiagents import Agent
from praisonai.bots import SlackBot
agent = Agent(
name="Support Bot",
instructions="Answer customer questions politely.",
)
bot = SlackBot(token="xoxb-...", agent=agent)
bot.start()
# If the gateway crashes mid-send, the reply is parked in the outbound DLQ.
# On the next boot, it is automatically redelivered with a marker:
#
# ♻️ Recovered after restart — this reply may be a duplicate.
#
# <original reply text>
#
# No operator step, no code change — symmetric with the inbound journal.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
- 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.
python from praisonaiagents import Agent from praisonai.bots import SlackBot agent = Agent( name="Support Bot", instructions="Answer customer questions politely.", ) bot = SlackBot(token="xoxb-...", agent=agent) bot.start() # Transient Slack 5xx / rate-limit responses are retried. # A permanently-failed reply (bad channel, revoked bot, etc.) is parked at # ~/.praisonai/state/slack/outbound_dlq.sqlite # for later replay — with no config on your side. - Override the defaults only when you need to
Set
outbound_resilienceinpraisonai.ymlonly to override defaults — e.g. more attempts, a longer cap, or a custom DLQ path.yaml bots: slack: outbound_resilience: initial_ms: 1000 max_ms: 30000 factor: 2.0 max_attempts: 5 jitter: 0.25 # dlq_path: /var/lib/praisonai/slack-dlq.sqlite # optional overrideThe default DLQ path is
~/.praisonai/state/<platform>/outbound_dlq.sqlite— only setdlq_pathif you need it elsewhere (e.g. a shared volume in Docker). - Opt out with a single flag
enabled: falsedisables both retry and durable parking on that channel — a permanently-failed reply on that channel is lost, matching the pre-safe-by-default behaviour.yaml bots: email: outbound_resilience: enabled: false
How It Works
When a send fails, the adapter classifies the error:
| State | What happened | What happens next |
|---|---|---|
| Success | Platform returned 2xx | Done — no further action |
| Transient retry | 429 / 503 / network error | Wait (honouring Retry-After) and retry |
| Exhausted → DLQ | Max attempts reached on transient error | Parked in the default DLQ (~/.praisonai/state/<platform>/outbound_dlq.sqlite), or the custom dlq_path; exception re-raised |
| Permanent error → DLQ | 4xx (not 429) | Parked immediately; exception re-raised |
| Storage transiently unavailable | DLQ init failed on this send | Degrades to retry-only for this send; state does not latch — the next send re-attempts DLQ init and parks the reply once storage recovers |
enabled: false | Operator opted out | One attempt, no retry, no parking; original exception re-raised |
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:
Outbound crash-recovery: redelivered N parked repl(y/ies) on start (marked as possible duplicates)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.
| Channel | Retry/backoff | DLQ on exhaust |
|---|---|---|
| Telegram | ✅ default-on | ✅ default-on¹ |
| Slack | ✅ default-on | ✅ default-on¹ |
| Discord | ✅ default-on | ✅ default-on¹ |
| ✅ default-on | ✅ default-on¹ | |
| ✅ default-on | ✅ default-on¹ | |
| Linear | ✅ default-on | ✅ default-on¹ |
| AgentMail | ✅ default-on | ✅ default-on¹ |
¹ Default DLQ location: ~/.praisonai/state/<platform>/outbound_dlq.sqlite. Override with outbound_resilience.dlq_path; disable with outbound_resilience.enabled = false.
Both text and media now inherit the same policy:
| Send path | Retry/backoff | Honours Retry-After |
|---|---|---|
Text reply (send_message) | ✅ via OutboundResilienceMixin | ✅ |
Media upload (DeliveryRouter.send_media) | ✅ via deliver_with_retry | ✅ |
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_backoffBackoffPolicyis reused; - if the adapter has no policy, a default of
initial_ms=1000, max_ms=10000,factor=1.5,max_attempts=3applies.- Adapters with no upload primitive still return
Falseimmediately — - 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).
| Option | Type | Default | Description | |
|---|---|---|---|---|
enabled | bool | True | Set to False to opt this channel out entirely — one attempt, no retry, no DLQ. | |
initial_ms | int | 1000 | First backoff delay in milliseconds. | |
max_ms | int | 10000 | Maximum backoff delay (caps exponential growth). | |
factor | float | 1.5 | Multiplicative growth factor per retry. | |
max_attempts | int | 3 | Total attempts before parking (1 = no retry). | |
jitter | float | 0.25 | Random jitter fraction (0.0–1.0) applied to each delay. | |
dlq_path | `str \ | None` | ~/.praisonai/state/<platform>/outbound_dlq.sqlite | Where permanent / exhausted failures are parked. Defaults to the canonical per-platform store (mirrors the inbound journal). Set to override for a shared volume, a custom filesystem, or a distinct file per instance. |
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).
Full YAML example:
bots:
slack:
outbound_resilience:
# dlq_path is optional — defaults to ~/.praisonai/state/slack/outbound_dlq.sqlite
max_attempts: 5
max_ms: 30000
factor: 2.0
discord:
outbound_resilience:
# Custom DLQ path for a shared volume
dlq_path: /var/lib/praisonai/state/discord/outbound_dlq.sqlite
max_attempts: 3Transient 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:
bots:
email:
outbound_resilience:
enabled: false # one attempt, no retry, no DLQ parkUse a shared DLQ path (e.g. a Docker volume):
bots:
slack:
outbound_resilience:
dlq_path: /var/lib/praisonai/state/slack/outbound_dlq.sqliteAggressive retry for a flaky upstream (defaults handle 99% of cases):
bots:
whatsapp:
outbound_resilience:
max_attempts: 5
max_ms: 30000
factor: 2.0DLQ 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
Keep the default ~/.praisonai/state/ writable
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.
Default values are sane — only tune when you see real DLQ growth
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.
Override dlq_path only for shared or per-instance filesystems
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.
# ✅ Default — durable, per-platform, isolated
# (omit dlq_path)
# ✅ Shared volume — multi-replica setup
dlq_path: /var/lib/praisonai/state/slack/outbound_dlq.sqlite
# ❌ Bad — lost on container restart
dlq_path: /tmp/slack-dlq.sqliteDon't disable resilience just because retries are slow — increase factor or max_ms instead
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.
Durable park is the default — no separate mode to opt into
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).
A parked reply now survives a restart on its own — Durable Delivery is only needed for the mid-retry gap
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.
Related
SQLite outbox for crash-safe delivery with send_durable() and startup drain
Inbound DLQDead-letter queue for failed inbound message processing
Outbox Operator CLIInspect, retry, and purge individual outbox entries — praisonai gateway outbox
Per-user and per-channel rate limiting for bot commands
Gateway Channel ConfigFull reference for all per-channel configuration options