Unified Degraded-Capability Registry

One consolidated list of every degraded owner in the gateway — channel, provider, capability, route, or gateway itself — surfaced through health()

When any part of the gateway degrades — a channel, an LLM provider, an MCP capability, a route, or the gateway itself — it shows up once in a single, redacted degraded_owners list with an exact next action.

Diagram

Degraded-Capability Registry

📨 Channel

🗂️ Registry

🤖 Provider

🔌 Capability

🛤️ Route

🩺 health.degraded_owners

Before this registry the gateway recorded degradation in several disconnected places but surfaced only degraded channels through health(). Provider credential failures, unresolved SecretRef capabilities, and route-level failures were classified but never exposed — a partial degraded state where some failures stayed invisible until a message silently went nowhere. A single process-local registry now collects every degraded owner at the boundary that owns it, so health() lists them all with a consistent, redacted shape and an actionable next step. The registry now covers both sides of the contract — the write side (record degradation where it happens) and the read side (guard a dispatch path before it targets a degraded owner).


Quick Start

  1. See it in health()

    The 90% path — read the surface, no code to write. Any degraded owner (channel, provider, capability, route, or gateway) appears in one place.

    python
    from praisonaiagents import Agent
    from praisonai_bot.gateway import BotGateway   # or the wrapper your setup uses
    
    agent = Agent(name="assistant", instructions="Be helpful.")
    gateway = BotGateway(config="gateway.yaml")
    
    # Any degraded owner — channel / provider / capability / route / gateway —
    # now shows up in one place under health()["degraded_owners"].
    print(gateway.health().get("degraded_owners", []))

    Example output when a Slack token is unavailable at boot and an MCP capability failed to resolve a secret:

    json
    [
      {"owner_kind": "capability", "owner_id": "mcp:notion",
       "state": "cold", "reason": "secret unresolved",
       "retry_hint": "praisonai gateway doctor --fix"},
      {"owner_kind": "channel", "owner_id": "slack",
       "state": "cold", "reason": "credential unavailable",
       "retry_hint": "praisonai gateway doctor --fix"}
    ]
  2. Record your own degraded owner

    Advanced — for integrations building on the gateway. Mark degradation at the boundary that owns it, then clear on recovery.

    python
    from praisonaiagents.gateway import DegradedCapabilityRegistry, DegradedOwner
    
    registry = DegradedCapabilityRegistry()
    
    # Mark degradation at the boundary that owns it
    registry.mark(DegradedOwner(
        owner_kind="provider",         # channel | provider | capability | route | gateway
        owner_id="openai",             # stable identity
        state="cold",                  # cold (no last-known-good) | stale (reusing LKG)
        reason="auth rejected (401)",  # redacted, operator-safe
        retry_hint="re-set OPENAI_API_KEY",
    ))
    
    # Clear on recovery
    registry.clear("provider", "openai")
    
    # Read a stable, sorted snapshot
    for owner in registry.list_degraded():
        print(owner.owner_kind, owner.owner_id, owner.reason)
  3. Guard a dispatch path (fail-closed)

    Call the guard before an agent targets an owner so a recorded degradation short-circuits into a typed, redacted outcome instead of a silent failure.

    python
    from praisonaiagents import Agent
    from praisonaiagents.gateway import (
        DegradedCapabilityRegistry, DegradedOwner,
        OwnerUnavailable, assert_owner_available,
    )
    
    registry = DegradedCapabilityRegistry()
    
    # Somewhere else marked this provider as degraded:
    registry.mark(DegradedOwner(
        owner_kind="provider", owner_id="openai", state="cold",
        reason="auth rejected (401)",
        retry_hint="re-set OPENAI_API_KEY then: praisonai gateway doctor --fix",
    ))
    
    agent = Agent(name="assistant", instructions="Be helpful.", llm="openai/gpt-4o")
    
    try:
        assert_owner_available(registry, "provider", "openai")   # fail-closed guard
        response = agent.start("Summarise today's incidents.")
    except OwnerUnavailable as err:
        # Typed, redacted outcome — safe to render straight to an operator surface.
        print(err.to_dict())

How It Works

Each owner writes into the registry at its own boundary; health() reads a sorted snapshot on demand.

Diagram
Operatorgateway.health()DegradedCapabilityRegistryOwner (channel / provider / capability)Operatorgateway.health()DegradedCapabilityRegistryOwner (channel / provider / capability)keyed on (owner_kind, owner_id)last write wins per keymark(DegradedOwner(…))health()list_degraded()sorted snapshot{“degraded_owners”: […]}clear(owner_kind, owner_id) on recovery

The registry is process-local and thread-safe, keyed on (owner_kind, owner_id) so a repeated failure for the same owner updates the existing entry instead of stacking duplicates.


Fields Reference

DegradedOwner is a frozen dataclass with five fields — retry_hint is the only one with a default.

FieldTypeDefaultDescription
owner_kindstrrequiredOne of channel, provider, capability, route, gateway. Anything else raises ValueError at construction.
owner_idstrrequiredStable identity for the owner (e.g. telegram:main, openai, mcp:notion).
statestrrequiredEither cold (no last-known-good) or stale (reusing last-known-good). Anything else raises ValueError.
reasonstrrequiredRedacted, operator-safe explanation. Must never leak token/secret material.
retry_hintstr""The exact next action, e.g. praisonai gateway doctor --fix. Every retry_hint MUST name a command that exists — praisonai gateway doctor --fix is the canonical example (see Gateway CLI › Auto-repair).

Closed Vocabularies

Two tuples define the only valid values — import them instead of hard-coding string literals.

ConstantValues
OWNER_KINDS("channel", "provider", "capability", "route", "gateway")
DEGRADED_STATES("cold", "stale")

Both are exported from praisonaiagents.gateway, so callers can validate against them before constructing a DegradedOwner.

Concrete owners shipped with the gateway

owner_kindowner_idReason surfaced
channel<platform>Channel credential unavailable or supervision marked it degraded.
provider<llm>LLM provider auth rejected or unreachable.
capabilitymcp:<name>MCP capability failed to resolve a secret.
routeredis-pubsubCross-instance HA transport (Redis pub/sub) is disconnected; reconnecting with bounded backoff.
gatewayfleetFleet-level crash-loop breaker tripped.
gatewaydurability:idempotencyDurable webhook-idempotency store unavailable; running in-memory (dedup does not survive restart).
gatewaydurability:idempotencystore_backend: redis set but Redis was unreachable (or the redis package is missing) at build time; running per-replica on the durable SQLite fallback. Cross-replica dedup only holds if replicas share the SQLite state file. See Cross-Replica Idempotency.
gatewaydurability:idempotencystore_backend: redis was live but Redis dropped at runtime; the store fails open (admits) so dedup is best-effort until Redis recovers. This row auto-clears on the next successful Redis op after recovery.
gatewaydurability:sessionDurable session store unavailable; running in-memory (session history does not survive restart).

Registry Methods

DegradedCapabilityRegistry is the default in-process implementation of the DegradedCapabilityProtocol.

MethodSignatureBehaviour
markmark(owner: DegradedOwner) -> NoneRecord (or update) a degraded owner. Last write wins per (owner_kind, owner_id) key. Thread-safe.
clearclear(owner_kind: str, owner_id: str) -> NoneRemove on recovery. Idempotent — clearing a missing key is a no-op.
list_degradedlist_degraded() -> List[DegradedOwner]Stable, sorted-by-(owner_kind, owner_id) snapshot.
findfind(owner_kind: str, owner_id: str) -> Optional[DegradedOwner]Point read: the record if degraded, else None. Thread-safe.
assert_owner_availableassert_owner_available(owner_kind: str, owner_id: str) -> NoneFail-closed guard: raises OwnerUnavailable if degraded, no-op if healthy.
to_listto_list() -> List[Dict[str, str]]Same as list_degraded() rendered as plain dicts for JSON.

cold vs stale

Pick the state by whether a last-known-good value exists.

Diagram

No — never resolved

Yes — reusing it while degraded

Do we have a
last-known-good?

state = ‘cold’

state = ‘stale’

Use cold when the owner never resolved a working value (a token that was never set). Use stale when it is still serving from a last-known-good value while degraded.


The health() Surface

degraded_owners sits alongside the existing channels map and lists every degraded owner in one redacted shape.

json
{
  "status": "healthy",
  "channels": { "telegram": {"running": true}, "slack": {"status": "degraded"} },
  "degraded_owners": [
    {
      "owner_kind": "channel",
      "owner_id": "slack",
      "state": "cold",
      "reason": "credential unavailable",
      "retry_hint": "praisonai gateway doctor --fix"
    },
    {
      "owner_kind": "provider",
      "owner_id": "openai",
      "state": "cold",
      "reason": "auth rejected (401)",
      "retry_hint": "re-set OPENAI_API_KEY"
    }
  ]
}

The gateway / fleet owner

When the fleet-level crash-loop breaker trips, the health monitor records exactly one gateway owner — owner_id = "fleet" — so a fleet-wide reconnect storm shows up as a single, actionable fact instead of one entry per channel.

json
{
  "owner_kind": "gateway",
  "owner_id": "fleet",
  "state": "stale",
  "reason": "channel crash-loop: 2/3 channels failing",
  "retry_hint": "praisonai gateway doctor"
}
FieldValue
owner_kindgateway
owner_idfleet
statestale — the fleet is still serving from last-known-good while restarts are held
reason`”channel crash-loop:

/

channels failing” (or “channel crash-loop” when total == 0) | | retry_hint | praisonai gateway doctor` |

It auto-clears on the next monitor sweep once the storm subsides — no external health() / gateway status poll is needed. Wait one breaker_cooldown_s after fixing the systemic cause and the entry disappears.

The gateway / durability:* owners

When a durable store — the SQLite webhook-idempotency store or the configured session store — genuinely cannot initialise, the boundary that owns it falls back to in-memory to keep serving and records a gateway owner with owner_id = "durability:idempotency" or owner_id = "durability:session".

json
{
  "owner_kind": "gateway",
  "owner_id": "durability:idempotency",
  "state": "cold",
  "reason": "durable idempotency store unavailable (running in-memory)",
  "retry_hint": "praisonai gateway doctor --fix"
}
  • Emitted by the praisonai-bot wrapper (praisonai_bot.bots._session) at the boundary that owns the state.
  • Recorded only when a durable store genuinely cannot initialise and the wrapper has to run in-memory to keep serving inbound deliveries.
  • retry_hint is praisonai gateway doctor --fix.
  • Auto-cleared on the next successful build of the durable store — a config hot-reload that fixes the store path clears the entry with no operator step.
  • Reasons are redacted — the raw store path stays in the log, never in health() / gateway status.

See Gateway State Durability for the full contract.


Fail-Closed Guard

Before dispatching work for an owner, call the guard so a recorded degradation short-circuits into a typed outcome instead of a silent failure.

Diagram
Provider / Channel / CapabilityDegradedCapabilityRegistryassert_owner_available()Dispatch PathProvider / Channel / CapabilityDegradedCapabilityRegistryassert_owner_available()Dispatch Pathcatch → render redacted“unavailable — next action”alt[owner healthy][owner degraded]assert_owner_available(registry, kind, id)find(kind, id)Noneno-opproceed with requestDegradedOwner(…)raise OwnerUnavailable

Pick the right call for how you hold the registry.

Diagram
graph TB
    Q1{Do you hold the<br/>registry directly?} -->|Yes, non-optional| M[reg.assert_owner_available kind, id]
    Q1 -->|Registry may be None| F[assert_owner_available reg, kind, id<br/>module-level, no-op if None]
    Q1 -->|Just want the record| P[reg.find kind, id → Optional DegradedOwner]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef call fill:#189AB4,stroke:#7C90A0,color:#fff

    class Q1 q
    class M,F,P call

Three call shapes cover every dispatch path.

CallSignatureBehaviour
reg.findfind(owner_kind: str, owner_id: str) -> Optional[DegradedOwner]Point read. Returns the record if degraded, else None. Thread-safe.
reg.assert_owner_availableassert_owner_available(owner_kind: str, owner_id: str) -> NoneRaises OwnerUnavailable if degraded. No-op if healthy.
praisonaiagents.gateway.assert_owner_availableassert_owner_available(registry: Optional[DegradedCapabilityProtocol], owner_kind: str, owner_id: str) -> NoneSame guard for a possibly-absent registry. No-op when registry is None. Falls back to list_degraded() on legacy registries that lack find/the guard.

OwnerUnavailable carries only redacted, operator-safe fields — never token or secret material.

AttributeTypeNotes
ownerDegradedOwnerFull redacted record
owner_kindstrConvenience mirror of owner.owner_kind
owner_idstrConvenience mirror of owner.owner_id
statestrcold or stale
reasonstrRedacted, operator-safe
retry_hintstrExact next action
to_dict()methodSame shape as DegradedOwner.to_dict() — safe to serialise to a JSON response

The message reads {owner_kind} {owner_id!r} unavailable ({state}): {reason} — {retry_hint}, and to_dict() returns the same redacted shape as DegradedOwner:

json
{"owner_kind": "provider", "owner_id": "openai", "state": "cold",
 "reason": "auth rejected (401)",
 "retry_hint": "re-set OPENAI_API_KEY then: praisonai gateway doctor --fix"}

Common Patterns

Read the surface once and act on it — for alerting or for CI.

Poll for degradation from an ops script — alert on any channel (matches existing on-call playbooks) plus any provider (new).

python
degraded = gateway.health().get("degraded_owners", [])
for owner in degraded:
    if owner["owner_kind"] in ("channel", "provider"):
        alert(f"{owner['owner_kind']} {owner['owner_id']} degraded: {owner['reason']}")

Fail a CI check on any degraded owner — same call, exit non-zero when the list is non-empty.

python
import sys

degraded = gateway.health().get("degraded_owners", [])
if degraded:
    for owner in degraded:
        print(owner["owner_kind"], owner["owner_id"], owner["retry_hint"])
    sys.exit(1)

Turn OwnerUnavailable into a chat reply — catch the guard’s exception and hand the operator the exact next action instead of a hang or a 500.

python
from praisonaiagents.gateway import OwnerUnavailable, assert_owner_available

def handle_chat(registry, message):
    try:
        assert_owner_available(registry, "provider", "openai")
        return agent.start(message)
    except OwnerUnavailable as err:
        return err.to_dict()["retry_hint"]

Best Practices

Use the closed vocabularies

Import OWNER_KINDS and DEGRADED_STATES from praisonaiagents.gateway and validate against them before constructing a DegradedOwner. The frozen dataclass raises ValueError on an unknown owner_kind or state, so checking first turns a runtime crash into a clean guard.

Redact secrets in reason

The registry is operator-facing — a token or secret in reason surfaces on every dashboard, log line, and status probe. Say what failed, not what the value was: "auth rejected (401)", not the key itself.

Set an actionable retry_hint

praisonai gateway doctor --fix is the sanctioned default — the command exists and actually repairs a weak/missing gateway auth token, then re-validates (see Gateway CLI › Auto-repair). Every retry_hint in this registry MUST name a command that exists. Prefer a specific one that names the exact env var, secret file, or rotation command — e.g. re-set OPENAI_API_KEY — so the operator’s next step is unambiguous.

Clear on recovery

mark() alone leaves the entry until the process restarts. Pair every mark() at the failure point with a clear() on the success path so health() reflects reality instead of a stale failure.

Guard before you dispatch, not after

Call assert_owner_available() immediately before the work targeting that owner. A guard after the fact converts a silent failure into a loud one but wastes the round-trip; a guard before it keeps the failure typed and free.


Related