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.

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()

# 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.

Diagram

Outbound Resilience

transient 429/503

success

max attempts

🤖 Agent

📤 Send Reply

⏳ Backoff Retry

✅ Delivered

💾 DLQ Park\n(~/.praisonai/state/<platform>/)

🔁 Replay Later

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.

    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.
  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.

    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 override

    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.

    yaml
    bots:
      email:
        outbound_resilience:
          enabled: false

How It Works

Diagram
💾 DLQ📱 Platform API📡 Bot Adapter🤖 Agent💾 DLQ📱 Platform API📡 Bot Adapter🤖 AgentHonour Retry-After, wait 2sExponential backoffalt[Max attempts exhausted]deliver_outbound(reply)send (attempt 1)429 Too Many Requests (Retry-After: 2s)send (attempt 2)503 Service Unavailablesend (attempt 3)200 OK✅ successpark(channel_id, reply_text, thread_id)❌ exception re-raised

When a send fails, the adapter classifies the error:

StateWhat happenedWhat happens next
SuccessPlatform returned 2xxDone — no further action
Transient retry429 / 503 / network errorWait (honouring Retry-After) and retry
Exhausted → DLQMax attempts reached on transient errorParked in the default DLQ (~/.praisonai/state/<platform>/outbound_dlq.sqlite), or the custom dlq_path; exception re-raised
Permanent error → DLQ4xx (not 429)Parked immediately; exception re-raised
Storage transiently unavailableDLQ init failed on this sendDegrades 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: falseOperator opted outOne 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. &lt;original reply text&gt;

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)
Diagram
👤 User📱 Platform API💾 OutboundDLQ📡 Adapter🛠️ Gateway boot👤 User📱 Platform API💾 OutboundDLQ📡 Adapter🛠️ Gateway boot5s grace for transport connectalt[success][re-fails]loop[each parked reply]start_channels()_replay_outbound_dlq(bot)redeliver(send) — oldest firstsend_message(♻️ marker + text)send200 OKrecovered replydelete entrytransient errorkeep entry, attempts++

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.

ChannelRetry/backoffDLQ on exhaust
Telegram✅ default-on✅ default-on¹
Slack✅ default-on✅ default-on¹
Discord✅ default-on✅ default-on¹
WhatsApp✅ default-on✅ default-on¹
Email✅ 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 pathRetry/backoffHonours 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_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).

OptionTypeDefaultDescription
enabledboolTrueSet to False to opt this channel out entirely — one attempt, no retry, no DLQ.
initial_msint1000First backoff delay in milliseconds.
max_msint10000Maximum backoff delay (caps exponential growth).
factorfloat1.5Multiplicative growth factor per retry.
max_attemptsint3Total attempts before parking (1 = no retry).
jitterfloat0.25Random jitter fraction (0.0–1.0) applied to each delay.
dlq_path`str \None`~/.praisonai/state/<platform>/outbound_dlq.sqliteWhere 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:

yaml
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: 3

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.

Diagram
📤 send💾 Storage🔧 DLQ init📤 send📤 send💾 Storage🔧 DLQ init📤 sendlog warningdegrade THIS senddo NOT latch _readystorage recoversbuild OutboundDLQ(path)open sqliteOSError (transient)retry-only path (no park)exception re-raisedbuild OutboundDLQ(path) (retry)open sqliteokDLQ ready, park permanent failures

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:

yaml
bots:
  email:
    outbound_resilience:
      enabled: false   # one attempt, no retry, no DLQ park

Use a shared DLQ path (e.g. a Docker volume):

yaml
bots:
  slack:
    outbound_resilience:
      dlq_path: /var/lib/praisonai/state/slack/outbound_dlq.sqlite

Aggressive retry for a flaky upstream (defaults handle 99% of cases):

yaml
bots:
  whatsapp:
    outbound_resilience:
      max_attempts: 5
      max_ms: 30000
      factor: 2.0

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

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.

yaml
# ✅ 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.sqlite
Don'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