Dead-Target Registry

Self-healing registry that short-circuits permanently-unreachable channels and re-probes them automatically

python
from praisonaiagents import Agent

agent = Agent(name="gateway-agent", instructions="Route outbound bot messages safely.")
agent.start("Send the daily digest to the Telegram group.")

The dead-target registry stops your gateway from wasting requests on channels where the bot was kicked, the group was deleted, or a 403/404 is permanent — and automatically self-heals when a target recovers.

The user triggers an outbound send; the registry skips dead targets and re-probes them when recovery is due.

Diagram

Dead-Target Registry Flow

not dead

dead + reprobe due

dead, not due

success

403/404/410

success

failure

🤖 Outbound Send

📋 Registry\nCheck

📡 Send to Channel

🔍 Re-probe\nonce

⏭️ Skip

✅ Clear dead flag

🔴 Mark dead

Quick Start

  1. Enable the Registry

    Attach a registry to suppress known-dead targets:

    python
    from praisonaiagents import Agent
    from praisonai.bots import TelegramBot
    from praisonai.bots import DeadTargetRegistry, DeliveryRouter
    
    agent = Agent(
        name="Broadcast Agent",
        instructions="Deliver scheduled updates to subscribers.",
    )
    
    registry = DeadTargetRegistry()
    
    bot = TelegramBot(
        token="YOUR_TOKEN",
        agent=agent,
        dead_target_registry=registry,
    )
    await bot.start()
  2. Persist to a Custom Path

    By default the registry persists to ~/.praisonai/state/dead_targets.json. Override it:

    python
    from pathlib import Path
    from praisonai.bots import DeadTargetRegistry
    
    registry = DeadTargetRegistry(
        persist_path=Path("/var/lib/praisonai/dead_targets.json"),
        ttl_seconds=7 * 86400,      # forget after 7 days
        max_size=5000,               # cap at 5 000 dead entries
        reprobe_seconds=1800,        # re-probe every 30 minutes
    )
  3. Share Across Multiple Workers

    For a horizontally-scaled gateway, pass a shared 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.

    python
    from pathlib import Path
    from praisonai.bots import DeadTargetRegistry, DeliveryControlStore
    
    store = DeliveryControlStore(Path("~/.praisonai/state/delivery_control.sqlite"))
    
    registry = DeadTargetRegistry(
        store=store,
        ttl_seconds=7 * 86400,
        max_size=5000,
        reprobe_seconds=1800,
    )
  4. Inspect and Clear Manually
    python
    from praisonai.bots import DeadTargetRegistry
    
    registry = DeadTargetRegistry()
    
    dead = registry.list_dead()
    for target in dead:
        print(f"{target.platform}:{target.channel_id} — {target.reason}")
    
    registry.clear("telegram", "-1001234567890")
    print(f"Registry size: {registry.size()}")

How It Works

Diagram
PlatformDeadTargetRegistryDeliveryRouterPlatformDeadTargetRegistryDeliveryRouterno mark — stays on retry pathalt[success][permanent failure (403 / 404 / 410 / text pattern)][transient (5xx / 429 / timeout) or message-scoped 404]alt[suppressed and not yet re-probeable][not suppressed (or re-probe due)]is_dead(platform, channel_id)?Trueshort-circuit (target_unreachable_suppressed)False (or reprobe=True)send_messageokclear(platform, channel_id) — self-healerrormark_dead(platform, channel_id, reason)error

Self-heal timeline:

Diagram

success

still permanent

T=0: mark_dead

T < 1h: all sends suppressed

T ≈ 1h: one reprobe send allowed

cleared — fully back in service

re-marked dead — clock resets

PhaseBehaviour
mark_deadTarget recorded with timestamp. Persisted atomically to JSON.
suppressedEvery deliver() call returns False immediately — no API hit.
reprobeAfter reprobe_seconds, one send is allowed through to test recovery.
self-healSuccess clears the registry entry. Next send goes through normally.
re-markedPermanent failure on reprobe resets the clock for another cycle.

Configuration Options

DeadTargetRegistry constructor

python
from praisonai.bots import DeadTargetRegistry

registry = DeadTargetRegistry(
    persist_path="~/.praisonai/state/dead_targets.json",
    max_size=10_000,
    ttl_seconds=2_592_000,
    reprobe_seconds=3600,
)
OptionTypeDefaultDescription
storeDeliveryControlStore \NoneNoneShared 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.jsonJSON file for durability. Parent directories are created automatically. Pass a tmp path for ephemeral / test instances. Ignored when store= is set.
max_sizeint10_000Maximum dead entries kept. Oldest (by ts) are evicted when exceeded. <= 0 disables the bound.
ttl_secondsint2_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_secondsint3600 (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

MethodReturnsDescription
is_dead(platform, channel_id)boolTrue if currently suppressed. Expired entries are removed lazily and False is returned.
should_reprobe(platform, channel_id)boolTrue 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)NoneRecord a confirmed-permanent failure. Idempotent — re-marking refreshes the timestamp and resets the re-probe clock. Persisted atomically.
clear(platform, channel_id)NoneUn-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()intNumber 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

SignalDetails
HTTP 403 ForbiddenVia err.status, err.status_code, or err.error_code
HTTP 404 Not FoundSame attribute probe — only if not a message-scoped 404 (see exclusions below)
HTTP 410 GoneSame attribute probe
forbiddenText 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

SignalWhy excluded
Any transient / recoverable error5xx, 429, timeouts, connection resets — checked first, transient wins
message to edit not foundMessage-scoped 404 — does NOT condemn the whole channel
message not foundSame
message to delete not foundSame
message_id_invalidSame
thread not foundSame
unknown messageSame
reply not foundSame
message can't be editedSame
HTTP 401 UnauthorizedDeliberately 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.

Adapter Return Conventions

A channel adapter’s send_message() can signal delivery outcome three ways:

Diagram

Raises exception

Returns value

Yes

No

Yes

send_message call

💀 Dead-target counter++

None?

✅ Success — key recorded

False?

🔁 Transient failure — key NOT recorded

Return valueMeaningDead-target effect
None / no valueSuccess (dominant convention)Counter cleared on success
False (explicit)Transient failure — idempotency key NOT recorded, dead-target flag NOT tripped, safe to retryNo change
Raises exceptionFailure — exception flows to the failure path, dead-target counter increments if a permanent error is detectedCounter increments on permanent errors (403/404/410)

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.

python
import asyncio
from praisonai.bots import BotOS, DeliveryRouter, DeadTargetRegistry

registry = DeadTargetRegistry()
botos    = BotOS(...)
router   = DeliveryRouter(botos, dead_targets=registry)

async def broadcast(channels, message):
    for platform, channel_id in channels:
        await router.deliver(f"{platform}:{channel_id}", message)

Dead channels are skipped silently; recovered channels re-enter service within one reprobe cycle (~1 h by default).

Operator dashboard via list_dead()

Expose suppressed channels in a health or admin endpoint so operators can see what has been suppressed and why.

python
from praisonai.bots import DeadTargetRegistry

registry = DeadTargetRegistry()

@app.get("/admin/dead-targets")
async def dead_targets():
    return [
        {"platform": t.platform, "channel_id": t.channel_id,
         "reason": t.reason, "since": t.ts}
        for t in registry.list_dead()
    ]

Custom suppression for one chat

Un-suppress a channel manually after confirming the bot has been re-added.

python
registry.clear("telegram", "-1001234567890")

Best Practices

Default-OFF on purpose

Attach a registry only on long-running gateways with periodic, broadcast, or scheduled sends. Single-request CLIs and request-response bots don’t need it — adding suppression to short-lived processes just adds complexity with no benefit.

Keep bounds consistent across workers

When several workers share one 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

Never treat HTTP 401 Unauthorized as a dead-target signal. Token expiry would suppress every channel simultaneously, and the 30-day TTL would hide the fix for a month. Auth failures stay on the normal error path so the operator sees and fixes them quickly.

Keep persist_path on a real filesystem

Store the registry on a persistent local path — not /tmp and not a container tmpfs. The default ~/.praisonai/state/dead_targets.json is the right location; use it as a template.

python
registry = DeadTargetRegistry(
    persist_path="/data/praisonai/state/dead_targets.json"
)
Tune reprobe_seconds to your fan-out cadence

The default 1-hour reprobe window suits most periodic-update gateways. Raise it if kicked-bot scenarios are rare and you’d rather not probe recovered channels frequently. Lower it if recovery latency matters (e.g. high-priority alert channels).

python
registry = DeadTargetRegistry(reprobe_seconds=300)
list_dead() is your operator dashboard

Expose 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.

python
print(f"Suppressed channels: {registry.size()}")
for t in registry.list_dead():
    print(f"  {t.platform}:{t.channel_id} — {t.reason}")
Use for broadcast / proactive delivery bots

The registry is most valuable when you push messages to many stored targets (newsletters, alerts). For one-on-one chat bots, the existing retry path is sufficient.

Store on a durable volume

The JSON file survives restarts and preserves which channels are suppressed between runs. Store it on the same persistent volume as your SQLite approval store.

Self-healing is automatic

When a successful send reaches a channel that was in the dead list — even a regular delivery, not only a scheduled re-probe — the channel is cleared automatically. No manual intervention needed.

Keep TTL short for fast-recovering platforms

The 30-day default suits most bots. On platforms where users frequently delete and recreate accounts, use a shorter TTL (e.g. 1–7 days) to allow re-delivery sooner.

Size the registry to your user base

Each entry uses ~100 bytes. 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

If an operator manually restores a blocked channel, call registry.clear(platform, channel_id) so the next send goes through. Without clearing, the entry persists until TTL expiry.

Pair with durable delivery

The registry prevents re-sending to dead targets, but in-flight messages that fail permanently should also go to the outbound DLQ. See Durable Delivery and Delivery Config.

Set TTL to match your re-subscription window

If users typically re-add your bot within 7 days, set 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

A rapidly-growing registry is a signal that your bot is being kicked at scale. Investigate the root cause before the registry fills to max_size and starts evicting entries.

Persistence is best-effort

The registry writes atomically to a JSON file. On failure the write is logged as a warning but never raised — the in-memory state is still correct and the file will be updated on the next successful write.


Related