~/.praisonai/state/<platform>/outbound_dlq.sqlite so it can be replayed, without any configuration. To disable durable parking entirely, set outbound_resilience.enabled = false.Quick Start
Retry and durable parking are both on by default
Override the defaults only when you need to
outbound_resilience in praisonai.yml only to override defaults — e.g. more attempts, a longer cap, or a custom DLQ path.~/.praisonai/state/<platform>/outbound_dlq.sqlite — only set dlq_path if you need it elsewhere (e.g. a shared volume in Docker).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.How It Works
When a send fails, the adapter classifies the error: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’sreplay() 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. <original reply text>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:
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 — nodlq_path opt-in required.
~/.praisonai/state/<platform>/outbound_dlq.sqlite. Override with outbound_resilience.dlq_path; disable with outbound_resilience.enabled = false.
~/.praisonai/state/<platform>/outbound_dlq.sqlite for replay instead of silently dropped. The operator escape hatch is outbound_resilience.enabled = false.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_backoffBackoffPolicyis reused; if the adapter has no policy, a default ofinitial_ms=1000,max_ms=10000,factor=1.5,max_attempts=3applies. - Adapters with no upload primitive still return
Falseimmediately — no wasted retries. - Permanent errors surface as before after the attempt budget is spent.
Configuration Options
All settings live underoutbound_resilience in your channel config (in praisonai.yml or via BotConfig).
resolve_durable_store_dir(<platform>). Set the PRAISONAI_HOME environment variable to relocate the whole ~/.praisonai/ root (useful in tests or containers).
dlq_path to opt into a DLQ park. On #3447+ that step is unnecessary — the default path is already applied. Existing configs that set dlq_path continue to work unchanged (an explicit path always wins over the default).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. You will see aFailed 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:Best Practices
Keep the default ~/.praisonai/state/ writable
Keep the default ~/.praisonai/state/ writable
~/.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
Default values are sane — only tune when you see real DLQ growth
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.Don't disable resilience just because retries are slow — increase factor or max_ms instead
Don't disable resilience just because retries are slow — increase factor or max_ms instead
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
Durable park is the default — no separate mode to opt into
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
A parked reply now survives a restart on its own — Durable Delivery is only needed for the mid-retry gap
♻️ 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.
