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.
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
- Benefit automatically
Any adapter that inherits
BasePlatformAdaptergains status-code-aware short-circuiting with no code change. A permanent failure returns immediately instead of retryingmax_retriestimes.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-botalso treats aSendResultwithstatus == "failed"as a failed send, so the defaultBasePlatformAdapter.send_messageâ which returnsstatus="failed"whendeliver()exhausts retries â reaches the outbound retry router with no extra code. - Inspect the result
After a delivery, read
error_kindandretryableto react â rotate credentials onauth_fatal, mark a target dead onforbidden.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.
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: âĻ")
endThe 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.
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.
| Kind | HTTP triggers | Text triggers (fallback) | Retryable |
|---|---|---|---|
rate_limited | 429 | too many requests, rate limit, rate_limited, flood | â
(with retry_after) |
target_not_found | 404, 410 | chat not found, channel not found, peer_id_invalid, user is deactivated, group chat was deleted | â (mark dead) |
forbidden | 403 | forbidden, blocked, kicked, not enough rights, no rights to send | â (mark dead) |
auth_fatal | 401 | unauthorized, invalid token, invalid_auth, token_revoked, not_authed, authentication failed | â (surface degraded) |
invalid_request | 400 | _(none â status only)_ | â (fix payload) |
transient | 5xx, 408, asyncio.TimeoutError, ConnectionError, OSError | timeout, timed out, temporarily unavailable, service unavailable, connection reset, connection refused, bad gateway, gateway timeout | â |
unknown | anything else | anything 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.
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.
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.