Inbound Dead-Letter Queue

Never lose a user's message when the LLM fails. Persist, inspect, replay.

python
from praisonaiagents import Agent

agent = Agent(name="dlq-agent", instructions="Process messages from the dead-letter queue.")
agent.start("Inspect and retry failed messages from the dead-letter queue.")

When agent.chat() fails, the Inbound DLQ persists the user’s message so operators can inspect and replay it later.

The user sends a channel message; if the LLM fails, the message is queued for operator replay.

Diagram

Inbound DLQ

📋 User Message

🤖 BotSessionManager

💾 InboundDLQ

✅ Operator Replay

Quick Start

  1. List failed messages
    bash
    praisonai bot dlq list
  2. Replay through your bot
    bash
    praisonai bot dlq replay --config bot.yaml
  3. Purge when resolved
    bash
    praisonai bot dlq purge --yes

How It Works

The user sends a message; if the LLM call fails, the message is persisted to the DLQ so an operator can replay it later.

Diagram
OperatorDLQBotUserOperatorDLQBotUserSend messageagent.chat() failsenqueue(message, error)praisonai bot dlq replayRe-run messageReply on success

Age-gated quarantine

InboundJournal.replay() and its redelivery path no longer quarantine on attempts alone — they consult the same age-gated policy as Durable Outbound Delivery.

An in-flight stale-claimed entry only moves to quarantine when:

  • The failure is a known-permanent error class (short-circuits immediately), or
  • attempts >= max_attempts and the entry is at least dead_letter_min_age (default 6h) old.

Otherwise replay() returns the entry to the pending pool and it will be reprocessed on the next tick. In practice this means a routine LLM outage that burns the claim budget in seconds keeps being retried, instead of every in-flight user message landing in the DLQ.

Two new keyword-only params on InboundJournal(...) (also honoured by build_session_manager when threading through channel config):

ParamTypeDefaultDescription
dead_letter_min_agefloat (seconds)21600 (6h)Minimum wall-clock age since first receipt before an attempt-exhausted transient failure is quarantined. 0 restores pre-#3521 attempt-only behaviour.
dead_letter_policy`DeadLetterPolicyProtocol \None`NoneOptional custom policy. Overrides max_attempts + dead_letter_min_age.

Introduced in PraisonAI PR #3521.


Why you want this

Default behaviour (no config needed)

Every bot started through build_session_manager (all shipped adapters) automatically gets a DLQ at:

~/.praisonai/state/<platform>/inbound_dlq.sqlite

For example, a Telegram bot stores failed messages at ~/.praisonai/state/telegram/inbound_dlq.sqlite. A Discord bot uses ~/.praisonai/state/discord/inbound_dlq.sqlite. Each platform is fully isolated.

Set PRAISONAI_HOME to override the base directory:

$PRAISONAI_HOME/state/<platform>/inbound_dlq.sqlite

Opt out or override path

Add a delivery: block to any channel in your bot.yaml or gateway.yaml:

yaml
channels:
  telegram:
    token: "${TELEGRAM_BOT_TOKEN}"
    delivery:
      durable: false   # opt out of durable inbound delivery for this channel

Override the store directory:

yaml
channels:
  telegram:
    token: "${TELEGRAM_BOT_TOKEN}"
    delivery:
      store: /var/lib/praisonai/telegram-state
      # → inbound_dlq.sqlite lives in /var/lib/praisonai/telegram-state/

CLI

bash
# List failed messages (newest first)
praisonai bot dlq list

# List from a custom path
praisonai bot dlq list --path /var/lib/myapp/dlq.sqlite --limit 50

# Replay through your bot's configured agent
praisonai bot dlq replay --config bot.yaml

# Purge everything (asks for confirmation)
praisonai bot dlq purge
praisonai bot dlq purge --yes  # skip confirmation

Advanced: manual instantiation

Most users get the DLQ automatically. For custom setups outside build_session_manager, create it directly:

python
from praisonai.bots import BotSessionManager, InboundDLQ

dlq = InboundDLQ(path="~/.praisonai/state/telegram/inbound_dlq.sqlite")
mgr = BotSessionManager(platform="telegram", dlq=dlq)
# ↑ that's it — failed agent.chat() now lands in the DLQ

API reference

path str | Path

Where the SQLite file lives. Parent directories are created automatically.

max_size int

Maximum number of entries kept. When exceeded, oldest entries are dropped first.

ttl_seconds int

Entries older than this are evicted on the next enqueue() or evict_expired().

dead_letter_min_age float

Minimum wall-clock age (seconds) an exhausted transient failure must reach before it is quarantined into the DLQ. 0 restores the pre-#3521 attempt-only behaviour.

dead_letter_policy DeadLetterPolicyProtocol | None

Optional custom policy from praisonaiagents.gateway. When supplied, overrides max_attempts + dead_letter_min_age. Must implement should_dead_letter(*, attempts, first_seen_epoch, now_epoch, error_class) -> DeadLetterDecision.

DLQEntry

Fields
  • id: int — primary key, monotonic.
  • ts: float — UNIX time of failure.
  • platform: str — bot platform (telegram, discord, etc).
  • user_id: str — platform user id.
  • prompt: str — the original user message.
  • chat_id: str, thread_id: str, user_name: str — metadata when known.
  • error: str — the error string that caused the failure.
  • attempts: int — how many times replay has been attempted (and failed).

Methods

Inspect

python
dlq.size()                  # int
dlq.list(limit=100)         # list[DLQEntry], newest first

Mutate

python
dlq.enqueue(
    platform="telegram", user_id="12345",
    prompt="hi", error="LLM 503",
)
dlq.purge()                 # delete all
dlq.evict_expired()         # drop entries older than ttl

Replay

python
async def handler(entry):
    try:
        await mgr.chat(agent, entry.user_id, entry.prompt,
                       chat_id=entry.chat_id,
                       user_name=entry.user_name)
        return True   # success → entry deleted
    except Exception:
        return False  # keep entry, increment attempts

succeeded, failed = await dlq.replay(handler)

Real LLM smoke test

text
[1] Sending failing message: 'What is 2 plus 2? Answer with a single digit.'
   Caught expected error: simulated LLM 503
   DLQ size after fail: 1  ✅

[2] Replaying DLQ via real LLM …
   succeeded=1, failed=0, remaining=0

[Real LLM reply] 4

PASS: DLQ → replay → real LLM produced expected '4'.

Best Practices

Tune retention for your SLO

Set max_size and ttl_seconds to match how long you need to retain failed messages. Chronic LLM outages can fill disk quickly.

Retry inline before the DLQ

For transient failures, use BackoffPolicy to retry inline first. The DLQ is the last resort, not the first response.

Alert on DLQ growth

Wrap dlq.enqueue() with your tracer (e.g. OTEL span). A non-zero dlq.size() is a strong SLO trip-wire.

Combine with durable outbound

Pair inbound DLQ with Durable Outbound Delivery so both sides of a conversation survive failures.

Combining with other features

With Cross-Platform Mirror (W1)

The DLQ records platform, user_id, and (if W1’s IdentityResolver is wired) the same user_id resolves the same human across platforms. Replay restores the exact session.

With BackoffPolicy (resilience)

For transient failures use praisonai.bots._resilience.BackoffPolicy to retry inline before falling back to the DLQ. The DLQ is the last resort, not the first.

With observability

Wrap dlq.enqueue() with your tracer (e.g. OTEL span) to alert on DLQ growth. A non-zero dlq.size() is a great SLO trip-wire.

Paid upgrade path

OSS now

File-backed SQLite DLQ — single-host deploys.

Cloud (planned)

Multi-region replicated DLQ with web dashboard, automatic alerting, and one-click bulk replay.


Related