This page covers outbound durability (
DurableDelivery / OutboundQueue). For inbound durability — which is now on by default for all gateway/bot runs — see Inbound Journal and Inbound DLQ.Durable Delivery vs Outbound Resilience. Durable Delivery is the heavy option — a SQLite outbox that survives crashes and lets you
send_durable() explicitly. The lighter Outbound Resilience is now on by default in every bot adapter and retries transient failures without changing any code. Use Outbound Resilience for typical “don’t drop replies on a 429” scenarios; reach for Durable Delivery when you also need to survive process crashes.Quick Start
Easiest path — DurableAdapterMixin
Add three lines to any existing adapter and every
send_durable() call is crash-safe:How It Works
Each message moves through statuses:pending → sending → sent (or failed / permanent_failure).
State Machine
The outbox tracks six statuses:pending, sending, recovered, sent, failed, and permanent_failure.
On restart, stale sending entries transition to recovered instead of pending. This preserves the information that the send was in-flight when the crash occurred.
recovered entries are retryable like pending, but when a reconciler is supplied to drain(), they are offered to it first — allowing adapters that can check the platform to confirm delivery and avoid re-sending an already-delivered message (effectively-once). Without a reconciler, recovered entries are re-sent as normal (at-least-once, unchanged behaviour).
Status-update writes (
mark_sent, mark_failed, _claim_entry) now call conn.commit(). Without this, a terminal status could be lost on a crash, causing an already-delivered message to be redelivered. This is a reliability fix — no API change is required.Effectively-Once Delivery
Adapters that can confirm whether a prior send actually landed can upgrade durable delivery from at-least-once to effectively-once.Choosing the Right Primitive
| I need to… | Use |
|---|---|
| Auto-retry transient failures — no config, works everywhere | OutboundResilienceMixin (on by default in every adapter) |
| Manual retry with explicit backoff policy | deliver_with_retry() |
| Send a message that may exceed platform length limits | deliver_chunked() |
| Survive process crashes / platform outages with replay | DurableDelivery or DurableAdapterMixin |
| Build a brand-new custom adapter with durability built in | DurableAdapterMixin |
Configuration Options
OutboundQueue
SQLite-backed outbox. All parameters after path are keyword-only.
| Parameter | Type | Default | Description |
|---|---|---|---|
path | str | Path | (required) | SQLite file path. Parent dirs are created automatically. |
max_size | int | 50_000 | Max entries kept. Oldest sent entries are evicted when exceeded. |
ttl_seconds | int | 604800 (7 days) | Sent entries older than this are evicted. |
max_attempts | int | 5 | Max delivery attempts before marking permanent failure. |
backoff | BackoffPolicy | BackoffPolicy() | Retry backoff configuration. |
OutboundQueue.drain() signature
| Parameter | Type | Default | Description |
|---|---|---|---|
sender | async callable | (required) | Async callable that sends the message. |
limit | int | None | None | Max entries to drain in this call. |
reconciler | async callable | None | None | Called only for recovered entries. Returns True → mark sent without re-dispatch (effectively-once). Returns False → re-send as normal. Raises → falls back to re-send (logged at WARNING). |
DurableDelivery
Wraps an OutboundQueue and an adapter to provide a simple .send() / .drain_pending() API.
| Parameter | Type | Default | Description |
|---|---|---|---|
outbox | OutboundQueue | (required) | The outbound queue to persist messages. |
adapter | adapter instance | (required) | Bot adapter with a send_message(channel_id, content) method. |
platform | str | "" | Platform name for platform-aware error classification. |
backoff | BackoffPolicy | BackoffPolicy() | Retry backoff configuration. |
max_attempts | int | 3 | Max delivery attempts. |
DurableAdapterMixin.setup_durable_delivery()
Call once in your adapter’s __init__ to wire up the outbox.
| Parameter | Type | Default | Description |
|---|---|---|---|
outbox_path | str | None | None | Path to SQLite outbox. None disables durability. |
platform | str | "" | Platform name for error classification. |
max_attempts | int | 3 | Max delivery attempts per message. |
max_size | int | 50_000 | Max messages in outbox. |
ttl_seconds | int | 604800 (7 days) | TTL for sent messages. |
deliver_with_retry()
Bounded retry without persistence — on a recoverable failure, the delay is the server-mandated wait (server_retry_after(err)) when present, otherwise compute_backoff(policy, attempt).
| Parameter | Type | Default | Description |
|---|---|---|---|
send_func | async callable | (required) | Async callable to execute (the send operation). |
policy | BackoffPolicy | BackoffPolicy(max_attempts=3) | Retry backoff configuration. |
is_recoverable | callable | None | None | Function to classify errors as transient. Defaults to is_recoverable_error(e, platform). |
platform | str | "" | Platform name for platform-specific error rules. |
rate_limiter | Optional[RateLimiter] | None | When supplied, a server-mandated wait (Telegram retry_after, HTTP Retry-After) triggers await rate_limiter.penalise(channel_id, delay) so subsequent sends to the same channel hold off until the platform’s window elapses. |
parked_store | Any | None | None | Optional DLQ for failed sends. |
reply_data | dict | None | None | Optional metadata for DLQ storage. |
server_retry_after()
Extracts a server-mandated wait (seconds) from an error or response. Used internally by deliver_with_retry, ConnectionMonitor.record_error, and OutboundQueue.drain to honour explicit throttle signals over the policy backoff.
| Source | Where it’s read | Notes |
|---|---|---|
Telegram RetryAfter | err.retry_after attribute | python-telegram-bot exposes this directly. |
| Telegram raw API 429 | err.parameters["retry_after"] | Raw API shape. |
HTTP Retry-After | err.headers, err.response.headers, or err.resp.headers | Integer seconds or HTTP-date (parsed via email.utils.parsedate_to_datetime; missing tz treated as UTC; clamped >= 0). |
| Text fallback | regex on str(err) | Matches retry after <n> / retry_after: <n>. |
None when no hint is present — callers fall back to the policy backoff.
deliver_chunked()
Splits a long message at paragraph boundaries and sends each chunk separately. Returns the number of chunks sent.
| Parameter | Type | Default | Description |
|---|---|---|---|
adapter | adapter instance | (required) | Bot adapter with send_message(channel_id, content). |
channel_id | str | (required) | Target channel. |
content | str | (required) | Message text to split and send. |
max_length | int | 4096 | Max characters per chunk (Telegram limit is 4096). |
preserve_fences | bool | True | Keep code fence blocks intact even if they exceed max_length. |
BackoffPolicy
Controls retry timing for both deliver_with_retry and OutboundQueue.drain.
| Attribute | Type | Default | Description |
|---|---|---|---|
initial_ms | float | 2000.0 | Initial delay in milliseconds. |
max_ms | float | 30000.0 | Maximum delay in milliseconds. |
factor | float | 1.8 | Multiplicative factor per attempt. |
jitter | float | 0.25 | Random jitter fraction (0.0–1.0). |
max_attempts | int | 0 | Max retry attempts. 0 = unlimited. |
Idempotency & Drain on Startup
Idempotency Keys
Every message has anidempotency_key — a UUID generated automatically if you omit it. Reusing the same key for the same logical message prevents double-sends across retries.
send() is called again with the same key, the outbox skips the enqueue (SQLite UNIQUE constraint) and marks the existing row sent.
Drain on Startup
Calldrain_pending() once at adapter startup to replay anything that was queued before the last crash:
max_attempts. The retry gate is max(compute_backoff(policy, attempts + 1), server_retry_after(stored_err)) — the platform’s mandated wait survives across process restarts.
The hint is recovered from the stored error string, not the live exception. Only hints that survive
str(err) (python-telegram-bot RetryAfter repr, HTTP headers folded into the error message, text-form “retry after N”) are honoured on drain.Best Practices
Set reconciles_unknown_send=True only if you can answer the question reliably
Set reconciles_unknown_send=True only if you can answer the question reliably
A flaky
was_delivered that returns False for an already-delivered message will cause a duplicate send. A reconciler that raises falls back to at-least-once re-send (safe, but logged at WARNING). Only opt in when your platform provides a reliable message-status API.Don't rely on supports_idempotency_token alone for dedupe
Don't rely on supports_idempotency_token alone for dedupe
supports_idempotency_token=True is informational only — the outbox does not currently forward the token on resend. Adapters relying on provider-side deduplication should also set reconciles_unknown_send=True to get effectively-once delivery.Always set platform= for accurate error classification
Always set platform= for accurate error classification
The
is_recoverable_error() function checks platform-specific patterns (e.g., Telegram’s HTTP 409 conflict, rate-limit “retry after” responses) when a platform name is provided. Without it, only generic patterns are checked and some transient errors may be misclassified as permanent.Use a stable idempotency_key derived from the inbound message
Use a stable idempotency_key derived from the inbound message
When bridging a webhook to an outbound reply, derive the key from the inbound message ID. This ensures webhook redeliveries don’t produce duplicate outbound sends.
Keep the outbox on persistent local disk
Keep the outbox on persistent local disk
Store the outbox on a persistent, local filesystem path — not
/tmp and not a Docker tmpfs. The default suggestion is ~/.praisonai/state/outbox.sqlite.Call drain_pending() exactly once per adapter start
Call drain_pending() exactly once per adapter start
Multiple concurrent drainers fight over the same rows via SQLite’s
status = 'sending' claim mechanism. A 5-minute claim timeout releases stale claims, but concurrent drainers still produce redundant work and log noise.Proactive Path
The proactive path (BotOS.deliver) shares the reply-path rate limiter — but does not support idempotency deduplication. For durable, deduped sends use the reply-path outbox (delivery.send).
| Property | Reply-path outbox (delivery.send) | Proactive path (BotOS.deliver) |
|---|---|---|
| Persistence | SQLite outbox, survives restart | Not persisted — fire-and-forget |
| Cross-worker dedup | Yes (UNIQUE on idempotency_key) | ❌ not supported |
| Idempotency key | ✅ | ❌ not a parameter |
| Rate limiter shared | ✅ same bucket | ✅ same bucket (post PraisonAI #2578) |
Adapter was_delivered reconciliation | ✅ | ❌ not applicable |
Use
delivery.send(...) when you need durability across restarts, workers, or deduplication. Use BotOS.deliver(...) for simple agent-initiated sends where rate-limiting is enough.Related
Dead Target Registry
Suppress permanently-dead channels — the permanent-failure complement to durable retry
Inbound Journal
Inbound counterpart — deduplicate webhook redeliveries and recover in-flight messages
Inbound DLQ
Dead-letter queue for failed inbound message processing
Delivery Config
Configure outbound resilience for all six bot channels
Bot Streaming Replies
Live-edit streaming UX for bot responses
Messaging Bots
Top-level guide to building bots with PraisonAI

