Durable Outbound Delivery

Retry-with-backoff and dead-letter queue for all bot channels so transient errors never drop agent replies

python
from praisonaiagents import Agent

agent = Agent(name="support", instructions="Reply helpfully on Slack or Telegram.")
agent.start("Tell the user their ticket was updated.")

Every bot reply now survives transient channel errors — 5xx responses, 429 rate limits, and network blips are automatically retried with bounded exponential backoff before parking in a Dead Letter Queue on permanent failure.

The user sends a bot reply; transient channel errors retry with backoff before a permanent failure lands in the dead-letter queue.

Diagram

Durable Outbound Delivery

success

transient error

permanent fail

🤖 Agent Reply

🔄 Retry\nw/ backoff

✅ Delivered

⏳ Wait\nRetry-After

📦 Dead Letter\nQueue

Quick Start

  1. Zero Configuration (Retry + Durable Park)

    Every adapter now retries transient failures and parks permanent / exhausted failures in a per-platform DLQ by default — no config needed:

    python
    from praisonaiagents import Agent
    from praisonai.bots import TelegramBot
    
    agent = Agent(
        name="Support Agent",
        instructions="Answer user questions helpfully.",
    )
    
    bot = TelegramBot(token="YOUR_TOKEN", agent=agent)
    await bot.start()
    # Transient errors retry with backoff; permanent / exhausted failures are
    # parked at ~/.praisonai/state/telegram/outbound_dlq.sqlite automatically.

    Slack, Discord, WhatsApp, Email, Linear, and AgentMail behave identically — each writes to its own ~/.praisonai/state/<platform>/outbound_dlq.sqlite file.

  2. Override the default DLQ path or tune retries

    dlq_path is optional — set it only if you need the DLQ on a different filesystem (e.g. a shared volume, a specific bind mount). If you just want to tune retries, all the other keys work the same as before.

    python
    from praisonaiagents import Agent
    from praisonai.bots import SlackBot, BotConfig, OutboundResilienceConfig
    
    config = BotConfig(
        outbound_resilience=OutboundResilienceConfig(
            # dlq_path is optional; default is ~/.praisonai/state/slack/outbound_dlq.sqlite
            dlq_path="/mnt/state/slack-outbound-dlq.sqlite",
            max_attempts=5,
            initial_ms=1000,
            max_ms=30000,
        )
    )
    
    agent = Agent(name="Slack Agent", instructions="Help the team.")
    bot = SlackBot(token="YOUR_TOKEN", agent=agent, config=config)
    await bot.start()

    To disable durable parking entirely for one channel:

    python
    config = BotConfig(
        outbound_resilience=OutboundResilienceConfig(enabled=False)
    )
  3. Tune Backoff Parameters

    Fine-tune retry behaviour per channel:

    python
    from praisonai.bots import BotConfig, OutboundResilienceConfig
    
    config = BotConfig(
        outbound_resilience=OutboundResilienceConfig(
            initial_ms=500,       # first retry after 500ms
            max_ms=15000,         # cap at 15s
            factor=2.0,           # double each attempt
            max_attempts=4,       # give up after 4 tries
            jitter=0.3,           # add 30% random jitter
            dlq_path="~/.praisonai/state/dlq.db",
        )
    )

How It Works

Diagram
DLQChannelAdapterAgentDLQChannelAdapterAgentwait 5s (honours Retry-After)exponential backoffreply textsend (attempt 1)429 Too Many Requests (Retry-After: 5s)send (attempt 2)503 Service Unavailablesend (attempt 3)✅ 200 OKsuccess

The mixin (OutboundResilienceMixin) wraps each adapter’s raw send with deliver_outbound(). State is initialised lazily from self.config.outbound_resilience. When no dlq_path is configured, the mixin falls back to resolve_durable_store_dir(<platform>) / "outbound_dlq.sqlite" so the default is durable-by-default without touching existing adapter constructors.


Channel Support

All six channels now share the same durable delivery path:

ChannelRetry + backoffDLQ supportNotes
Telegram✅✅Had this previously; unchanged
Slack✅✅New in PR #2484
Discord✅✅New in PR #2484
WhatsApp✅✅Previously swallowed errors silently — now fixed
Email✅✅New in PR #2484
Linear✅✅New in PR #2484
AgentMail✅✅New in PR #2484

What Gets Retried

Error typeRetried?Notes
HTTP 5xx✅Full backoff sequence
HTTP 429✅Waits for Retry-After header first
Network blips / timeout✅Transient
HTTP 401 Unauthorized❌ → DLQToken/account issue, not per-message
HTTP 403 Forbidden❌ → DLQPermanent — bot kicked or blocked
HTTP 404 Not Found❌ → DLQChat deleted or not found
HTTP 410 Gone❌ → DLQPermanently removed
Platform patterns (“bot was kicked”)❌ → DLQPlatform-specific classification

Structured error classification

Each failure is tagged with a machine-readable SendErrorKind before the retry decision. Permanent kinds (forbidden, target_not_found, auth_fatal, invalid_request) short-circuit immediately rather than exhausting max_retries against a dead target; transient and rate-limited failures still retry with backoff.

Diagram
ChannelAdapterAgentChannelAdapterAgentclassify → forbidden (not retryable)reply textsend (attempt 1)403 Forbidden (bot kicked)short-circuit — no further attempts

See Send Error Taxonomy for the full kind reference and how to classify your own adapter’s native exceptions.


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.

Users receive redelivered replies with a visible duplicate marker:

♻️ Recovered after restart — this reply may be a duplicate. &lt;original reply text&gt;

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

When Held Replies Drain

The durable outbox re-attempts held replies on three triggers:

  1. Adapter startup — replays anything queued before the last crash.
  2. Channel recovery (Issue #4043) — when ChannelSupervisor sees a channel come back after a transient outage, it re-drains the outbox in the background so held replies go out promptly, without waiting for the next inbound turn.
  3. Lazy on next inbound turn — the next chat() turn also drains opportunistically.

See Durable Delivery → When the Outbox Drains for the full explanation and behavioural notes.


Configuration Options

OptionTypeDefaultDescription
initial_msint1000First retry delay in milliseconds
max_msint10000Maximum retry delay cap
factorfloat1.5Backoff multiplier per attempt
max_attemptsint3Total attempts before parking in DLQ
jitterfloat0.25Random jitter fraction (0–1)
dlq_pathstr_(canonical per-platform path — see below)_Override the DLQ file location. Defaults to ~/.praisonai/state/<platform>/outbound_dlq.sqlite so a permanent failure is always parked without configuration.
enabledboolTrueSet False to opt a channel out of durable delivery

The default path is resolved by the shared resolve_durable_store_dir(<platform>) helper (the same store the inbound journal uses). Set the PRAISONAI_HOME environment variable to relocate the whole ~/.praisonai/ root — handy in tests or containers.


Best Practices

Durable park is on by default — override the path only when you need to

Permanent and exhausted failures are parked in ~/.praisonai/state/<platform>/outbound_dlq.sqlite automatically. Override dlq_path only if you need the DLQ on a specific filesystem (shared volume, bind mount, SSD). To disable durable parking entirely, set outbound_resilience.enabled = false.

Respect rate limits with Retry-After

The mixin reads the Retry-After response header and waits exactly that long before the next attempt, so your bot stays within platform rate limits without sleeping longer than necessary.

Opt channels out individually

Set outbound_resilience.enabled = False in a channel’s config to disable durable delivery for that channel only — useful for fire-and-forget channels where retries would send duplicates.

Monitor your DLQ

Parked entries are permanent failures. Set up alerts on DLQ growth to detect channels that are consistently unreachable (e.g. bots kicked from a workspace).


Related