Send Error Taxonomy

Structured classification of why an outbound send failed so the retry loop stops burning budget on permanent failures

Structured classification of why an outbound send failed, so the retry loop stops burning budget on permanent failures.

Diagram

Send Error Taxonomy

transient / rate_limited / unknown

forbidden / target_not_found / auth_fatal / invalid_request

âš ī¸ Native Exception

🔎 classify_error

đŸˇī¸ SendErrorKind

â†Šī¸ retryable?

🔄 Retry w/ backoff

⛔ Short-circuit

Every gateway/bot adapter now tags a failed send with a SendErrorKind. Permanent failures — a blocked user, a deleted chat, a revoked token — short-circuit instead of exhausting the retry budget against a dead target.

Quick Start

  1. Benefit automatically

    Any adapter that inherits BasePlatformAdapter gains status-code-aware short-circuiting with no code change. A permanent failure returns immediately instead of retrying max_retries times.

    python
    from praisonaiagents.bots import BasePlatformAdapter, SendResult
    
    class MyPlatformAdapter(BasePlatformAdapter):
        async def connect(self, *, is_reconnect=False):
            return True
    
        async def disconnect(self):
            ...
    
        async def send(self, chat_id, content, *, reply_to=None, metadata=None) -> SendResult:
            message_id = await my_api.send(chat_id, content)
            return SendResult(ok=True, message_id=message_id, chat_id=chat_id)
    
        async def get_chat_info(self, chat_id):
            return {"id": chat_id}

    praisonai-bot also treats a SendResult with status == "failed" as a failed send, so the default BasePlatformAdapter.send_message — which returns status="failed" when deliver() exhausts retries — reaches the outbound retry router with no extra code.

  2. Inspect the result

    After a delivery, read error_kind and retryable to react — rotate credentials on auth_fatal, mark a target dead on forbidden.

    python
    from praisonaiagents.bots import SendErrorKind
    
    result = await adapter.deliver(chat_id="123", content="Hi!")
    if not result.ok:
        if result.error_kind == SendErrorKind.AUTH_FATAL:
            print("Rotate the bot token — retrying won't help.")
        elif result.error_kind == SendErrorKind.FORBIDDEN:
            print(f"Bot was blocked/kicked from chat {result.chat_id}; marking dead.")
        elif result.retryable:
            print(f"Transient issue ({result.error_kind.value}); the retry loop handles it.")

How It Works

Failures are classified once at the boundary via classify_error(exc). Only retryable kinds are retried; provably-permanent kinds short-circuit immediately, and the router records the dead target by kind.

Diagram
sequenceDiagram
    participant Loop as 🔁 Delivery Loop
    participant Adapter as 📡 Adapter
    participant Router as đŸ—‚ī¸ DeliveryRouter

    Loop->>Adapter: send(chat_id, content)
    Adapter-->>Loop: raise native exception
    Loop->>Adapter: classify_error(exc)
    Adapter-->>Loop: SendResult(error_kind, retryable)
    alt retryable
        Note over Loop: back off, retry
    else permanent
        Note over Loop: short-circuit, return result
        Loop->>Router: mark_dead(reason="forbidden: â€Ļ")
    end

The retry loop (BasePlatformAdapter._send_with_retry) stamps chat_id onto the classified SendResult, so the caller can act on result.error_kind without re-parsing the error string.


Which Kind Gets Returned?

The classifier keys off the HTTP/platform status code first, then falls back to a small set of cross-platform substrings when no status is present.

Diagram

429

401

403

404 / 410

400

5xx / 408

other

none

timeout / connection reset

rate limit / flood

chat not found / peer_id_invalid

forbidden / kicked / blocked

unauthorized / invalid token

no match

âš ī¸ Exception

Status code?

rate_limited

auth_fatal

forbidden

target_not_found

invalid_request

transient

unknown

Match text?

asyncio.TimeoutError, TimeoutError, ConnectionError, and OSError are treated as transient before the text fallback runs.


SendErrorKind Reference

Seven members, spelled exactly as in praisonaiagents/bots/base.py.

KindHTTP triggersText triggers (fallback)Retryable
rate_limited429too many requests, rate limit, rate_limited, flood✅ (with retry_after)
target_not_found404, 410chat not found, channel not found, peer_id_invalid, user is deactivated, group chat was deleted❌ (mark dead)
forbidden403forbidden, blocked, kicked, not enough rights, no rights to send❌ (mark dead)
auth_fatal401unauthorized, invalid token, invalid_auth, token_revoked, not_authed, authentication failed❌ (surface degraded)
invalid_request400_(none — status only)_❌ (fix payload)
transient5xx, 408, asyncio.TimeoutError, ConnectionError, OSErrortimeout, timed out, temporarily unavailable, service unavailable, connection reset, connection refused, bad gateway, gateway timeout✅
unknownanything elseanything unmatched✅ (safety default)

The enum is a str-Enum: SendErrorKind.FORBIDDEN.value == "forbidden".


Writing a Custom Adapter

Override classify_error to map your platform SDK’s native exceptions into the taxonomy with full fidelity. Fall through to classify_send_error for the shared status/text-aware default.

python
from praisonaiagents.bots import (
    BasePlatformAdapter,
    SendErrorKind,
    SendResult,
    classify_send_error,
)

class MyPlatformAdapter(BasePlatformAdapter):
    def classify_error(self, exc):
        # Full-fidelity mapping from your platform SDK's native exceptions
        if isinstance(exc, MyPlatformSDK.BotBlocked):
            return SendResult(ok=False, error=str(exc),
                              error_kind=SendErrorKind.FORBIDDEN, retryable=False)
        if isinstance(exc, MyPlatformSDK.RateLimited):
            return SendResult(ok=False, error=str(exc),
                              error_kind=SendErrorKind.RATE_LIMITED,
                              retryable=True, retry_after=exc.retry_after)
        # Fall through to the shared status/text-aware default
        return classify_send_error(exc)

The default classify_error delegates to classify_send_error, so an adapter that skips this override still short-circuits standard HTTP failures.


Serialization & Observability

SendResult.to_dict() exposes error_kind as the enum value (a plain string) plus the derived retryable flag — ready for log pipelines and dashboards.

python
from praisonaiagents.bots import classify_send_error

class Blocked(Exception):
    status = 403

result = classify_send_error(Blocked("bot was kicked"))
print(result.to_dict())
# {
#   'ok': False,
#   'message_id': None,
#   'chat_id': None,
#   'message_ids': [],
#   'error': 'bot was kicked',
#   'error_kind': 'forbidden',
#   'retryable': False,
#   'retry_after': None,
#   'metadata': {},
# }

error_kind is None on success. Record dead targets by kind so dashboards read forbidden: bot was kicked instead of an opaque str(exc).


Best Practices

Override classify_error in your adapter

The default’s text fallback is deliberately narrow so it stays cross-platform. Your SDK’s typed exceptions give perfect fidelity — map them directly and fall through to classify_send_error for the rest.

Never downgrade a permanent failure to retryable

A blocked user or deleted chat cannot be recovered by retry. Short-circuiting is a feature, not a bug — keep retryable=False for forbidden, target_not_found, auth_fatal, and invalid_request.

Persist dead targets by error_kind, not str(exc)

Record the failure by kind (e.g. reason="forbidden: bot was kicked"), matching what DeliveryRouter now does. Machine-readable kinds make dashboards and alerts readable.

Treat UNKNOWN as retryable-by-default

unknown preserves the historical retry-everything behaviour and prevents silent drops of failures the classifier can’t name. Do not special-case it into a permanent failure.


Related