Inbound Dead-Letter Queue
Never lose a user's message when the LLM fails. Persist, inspect, replay.
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.
Quick Start
- List failed messages
bash praisonai bot dlq list - Replay through your bot
bash praisonai bot dlq replay --config bot.yaml - 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.
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_attemptsand the entry is at leastdead_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):
| Param | Type | Default | Description | |
|---|---|---|---|---|
dead_letter_min_age | float (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` | None | Optional custom policy. Overrides max_attempts + dead_letter_min_age. |
Introduced in PraisonAI PR #3521.
Why you want this
Failed inbound messages are persisted to a SQLite file before the exception bubbles up.
Operator-friendly replayA single CLI command (praisonai bot dlq replay) re-runs failed messages through the agent.
TTL + max_size keep the queue from growing unbounded; oldest entries evict first. Transient LLM outages no longer flood the DLQ — an exhausted entry is only quarantined once it’s both attempt-exhausted and at least dead_letter_min_age (default 6h) old. See Age-gated quarantine.
Uses only stdlib sqlite3. On by default for gateway/bot runs — existing bots are upgraded automatically.
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.sqliteFor 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.sqliteOpt out or override path
Add a delivery: block to any channel in your bot.yaml or gateway.yaml:
channels:
telegram:
token: "${TELEGRAM_BOT_TOKEN}"
delivery:
durable: false # opt out of durable inbound delivery for this channelOverride the store directory:
channels:
telegram:
token: "${TELEGRAM_BOT_TOKEN}"
delivery:
store: /var/lib/praisonai/telegram-state
# → inbound_dlq.sqlite lives in /var/lib/praisonai/telegram-state/CLI
# 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 confirmationAdvanced: manual instantiation
Most users get the DLQ automatically. For custom setups outside build_session_manager, create it directly:
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 DLQAPI reference
str | PathWhere the SQLite file lives. Parent directories are created automatically.
intMaximum number of entries kept. When exceeded, oldest entries are dropped first.
intEntries older than this are evicted on the next enqueue() or evict_expired().
floatMinimum 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.
DeadLetterPolicyProtocol | NoneOptional 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
dlq.size() # int
dlq.list(limit=100) # list[DLQEntry], newest firstMutate
dlq.enqueue(
platform="telegram", user_id="12345",
prompt="hi", error="LLM 503",
)
dlq.purge() # delete all
dlq.evict_expired() # drop entries older than ttlReplay
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
[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
Full reference for the delivery: channel config block — defaults, opt-out, and path override
Deduplicate webhook redeliveries and recover in-flight messages after a crash
Durable Outbound DeliveryOutbound counterpart — persist outgoing messages with retry and idempotency
Outbox Operator CLIOutbound counterpart — praisonai gateway outbox list|stats|retry|purge
Bot setup where the DLQ is wired automatically