DeliveryRouter works exactly as before until you construct and attach a DeadTargetRegistry instance.Quick Start
Persist to a Custom Path
~/.praisonai/state/dead_targets.json. Override it:Share Across Multiple Workers
DeliveryControlStore so every worker sees the same dead-target set. Marking a channel dead in worker A immediately suppresses sends from worker B; a successful send from any worker self-heals across all of them.How It Works
Self-heal timeline:| Phase | Behaviour |
|---|---|
| mark_dead | Target recorded with timestamp. Persisted atomically to JSON. |
| suppressed | Every deliver() call returns False immediately — no API hit. |
| reprobe | After reprobe_seconds, one send is allowed through to test recovery. |
| self-heal | Success clears the registry entry. Next send goes through normally. |
| re-marked | Permanent failure on reprobe resets the clock for another cycle. |
Configuration Options
DeadTargetRegistry constructor
| Option | Type | Default | Description |
|---|---|---|---|
store | DeliveryControlStore | None | None | Shared SQLite store for cross-worker sharing. When set, persist_path is ignored — dead-target state lives entirely in the store (row-based atomic upsert, no whole-file JSON rewrite). Every registry sharing one store must use identical ttl_seconds / max_size (enforced by register_dead_config, raises ValueError on mismatch). See Multi-worker (horizontally-scaled gateway). |
persist_path | Path | None | ~/.praisonai/state/dead_targets.json | JSON file for durability. Parent directories are created automatically. Pass a tmp path for ephemeral / test instances. Ignored when store= is set. |
max_size | int | 10_000 | Maximum dead entries kept. Oldest (by ts) are evicted when exceeded. <= 0 disables the bound. |
ttl_seconds | int | 2_592_000 (30 days) | Entries older than this are dropped lazily on the next access, so a long-dead target is eventually re-probed even if no send ever cleared it. <= 0 disables TTL. |
reprobe_seconds | int | 3600 (1 hour) | Once this elapses since mark_dead, one send is allowed through to test recovery. Success self-heals; repeated permanent failure resets the clock. <= 0 disables re-probing. |
Public methods
| Method | Returns | Description |
|---|---|---|
is_dead(platform, channel_id) | bool | True if currently suppressed. Expired entries are removed lazily and False is returned. |
should_reprobe(platform, channel_id) | bool | True if a dead target is due for a single self-healing re-probe (reprobe_seconds elapsed since last mark_dead). |
mark_dead(platform, channel_id, reason) | None | Record a confirmed-permanent failure. Idempotent — re-marking refreshes the timestamp and resets the re-probe clock. Persisted atomically. |
clear(platform, channel_id) | None | Un-suppress a target. Called automatically on every successful send. No-op if the target is not in the registry. Persisted atomically. |
list_dead() | list[DeadTarget] | Snapshot of currently-suppressed targets (TTL-expired ones pruned first), sorted oldest-first by ts. |
size() | int | Number of currently-suppressed targets (TTL-expired ones pruned first). |
DeadTarget is a frozen dataclass: platform: str, channel_id: str, reason: str, ts: float (Unix epoch).
What Counts as Permanent
is_permanent_target_failure(err, platform) classifies an exception before DeliveryRouter calls mark_dead.
Permanent — target marked dead
| Signal | Details |
|---|---|
HTTP 403 Forbidden | Via err.status, err.status_code, or err.error_code |
HTTP 404 Not Found | Same attribute probe — only if not a message-scoped 404 (see exclusions below) |
HTTP 410 Gone | Same attribute probe |
forbidden | Text pattern (case-insensitive) on the exception message |
bot was kicked | |
bot was blocked | |
user is deactivated | |
chat not found | |
channel not found | |
group chat was deleted | |
the group chat was deleted | |
bot is not a member | |
not enough rights | |
have no rights to send | |
need administrator rights | |
peer_id_invalid | |
account_not_found | |
channel_archived | |
is_archived | |
blocked by the user |
Not permanent — stays on the retry path
| Signal | Why excluded |
|---|---|
| Any transient / recoverable error | 5xx, 429, timeouts, connection resets — checked first, transient wins |
message to edit not found | Message-scoped 404 — does NOT condemn the whole channel |
message not found | Same |
message to delete not found | Same |
message_id_invalid | Same |
thread not found | Same |
unknown message | Same |
reply not found | Same |
message can't be edited | Same |
HTTP 401 Unauthorized | Deliberately excluded. 401 signals an account-/token-level auth problem (revoked or wrong credentials), not the death of one specific channel. Marking channels dead on a 401 would suppress every target the moment a token expires, and the 30-day TTL would keep them suppressed long after the token is fixed. Auth failures stay off the dead-target path. |
mark_dead("Telegram", ...) and is_dead("TELEGRAM", ...) refer to the same entry. Users coming from YAML keys like Telegram: don’t need to worry about casing.Adapter Return Conventions
A channel adapter’ssend_message() can signal delivery outcome three ways:
| Return value | Meaning | Dead-target effect |
|---|---|---|
None / no value | Success (dominant convention) | Counter cleared on success |
False (explicit) | Transient failure — idempotency key NOT recorded, dead-target flag NOT tripped, safe to retry | No change |
| Raises exception | Failure — exception flows to the failure path, dead-target counter increments if a permanent error is detected | Counter increments on permanent errors (403/404/410) |
False convention was formalised in PraisonAI #2578. Adapters that previously returned False silently to signal “send skipped” should now raise an exception or return None to avoid being misclassified as a transient failure.Common Patterns
Long-running broadcast gateway
A gateway pushing periodic updates to many channels suppresses dead ones so they don’t burn rate-limit budget on every cycle.DeliveryControlStore to every worker’s registry and rate limiter. That way, a channel marked dead by worker A immediately stops burning fan-out cycles on workers B/C/D. See Multi-worker (horizontally-scaled gateway).Operator dashboard via list_dead()
Expose suppressed channels in a health or admin endpoint so operators can see what has been suppressed and why.
Custom suppression for one chat
Un-suppress a channel manually after confirming the bot has been re-added.Best Practices
Default-OFF on purpose
Default-OFF on purpose
Keep bounds consistent across workers
Keep bounds consistent across workers
DeliveryControlStore, they must agree on ttl_seconds and max_size. DeliveryControlStore.register_dead_config() enforces this at attach time — the second worker with divergent bounds raises ValueError instead of silently pruning the first worker’s suppressions. Roll config changes through the whole fleet together; if you genuinely need two different retention windows, use two separate store files.401 is intentionally NOT permanent
401 is intentionally NOT permanent
Keep persist_path on a real filesystem
Keep persist_path on a real filesystem
/tmp and not a container tmpfs. The default ~/.praisonai/state/dead_targets.json is the right location; use it as a template.Tune reprobe_seconds to your fan-out cadence
Tune reprobe_seconds to your fan-out cadence
list_dead() is your operator dashboard
list_dead() is your operator dashboard
list_dead() in your gateway’s admin or health endpoint so operators can see suppressed channels at a glance. Pair it with registry.size() for a quick health metric.Use for broadcast / proactive delivery bots
Use for broadcast / proactive delivery bots
Store on a durable volume
Store on a durable volume
Self-healing is automatic
Self-healing is automatic
Keep TTL short for fast-recovering platforms
Keep TTL short for fast-recovering platforms
Size the registry to your user base
Size the registry to your user base
max_size=10_000 uses ~1 MB. For bots with millions of users, increase max_size proportionally or use a persistent backend.Always clear on manual recovery
Always clear on manual recovery
registry.clear(platform, channel_id) so the next send goes through. Without clearing, the entry persists until TTL expiry.Pair with durable delivery
Pair with durable delivery
Set TTL to match your re-subscription window
Set TTL to match your re-subscription window
ttl_seconds=604800. The registry will forget the entry and perform a clean probe attempt rather than waiting the full 30-day default.Monitor the registry size
Monitor the registry size
max_size and starts evicting entries.Persistence is best-effort
Persistence is best-effort

