LLM Error Classification
Typed error categories, failover decisions, and idle-timeout protection for LLM calls
PraisonAI classifies every LLM failure into one of 11 typed categories so retries, failovers, and circuit breakers can react correctly without parsing error strings.
from praisonaiagents import Agent
agent = Agent(
name="Smart Agent",
instructions="Process user requests with automatic error handling",
)
result = agent.start("Generate a summary")The user sends a prompt; transient LLM errors trigger retries or profile rotation instead of opaque failures.
Quick Start
- Simple Usage
Error classification happens automatically when using agents:
python from praisonaiagents import Agent agent = Agent( name="Smart Agent", instructions="Process user requests with automatic error handling" ) # Classification happens transparently result = agent.start("Generate a summary") - Read Error Category
Access the typed error category in error hooks:
python from praisonaiagents import Agent from praisonaiagents.errors import AgentErrorKind def handle_error(error): # Access typed error category instead of parsing strings if error.error_category == "billing": print("💳 Quota exceeded - contact billing") elif error.error_category == "rate_limit": print("⏸️ Rate limited - will auto-retry") elif error.error_category == "auth_permanent": print("🔑 Invalid API key - check configuration") agent = Agent( name="Smart Agent", instructions="Handle requests with error monitoring", on_error=handle_error ) - Custom Idle-Timeout Protection
Tune the idle-timeout circuit breaker for different scenarios:
python from praisonaiagents import Agent from praisonaiagents.errors import IdleTimeoutBreaker from praisonaiagents.llm import LLM # Custom breaker for slow self-hosted models breaker = IdleTimeoutBreaker(max_consecutive=5) llm = LLM( model="self-hosted/slow-model", idle_timeout_breaker=breaker, max_iter=15 ) agent = Agent( name="Patient Agent", instructions="Work with slow models", llm=llm )
How It Works
The classification system converts every LLM exception into a structured FailoverDecision that tells retry logic exactly what to do.
Classification (is_retryable, should_fallback_model) is consulted from every turn shape — non-streaming, streaming, tool-iteration, reflection, sync and async — so an LLMError category maps to the same retry/failover decision regardless of how the turn is being executed.
Error Categories
All LLM failures are classified into these 11 typed categories:
| Kind | Triggers (examples) | Default Action | Retryable |
|---|---|---|---|
auth | unauthorized, api key, authentication failed, any HTTP 401 / 403 (even without the word “unauthorized”) | rotate_profile (if failover enabled) | Yes |
auth_permanent | invalid api key, incorrect api key | surface_error | No |
rate_limit | rate limit, 429, resource_exhausted, 429 "Rate limit reached for API key …" | retry (with parsed/exponential backoff, max 60s) | Yes |
overloaded | 503, 502, 500, service unavailable, HTTP 503 with an empty body | retry (2s→4s→8s, capped at 30s) | Yes |
context_overflow | maximum context length, context window is too long | surface_error | No |
idle_timeout | timeout, timed out, deadline exceeded | retry until breaker hits 3, then surface_error | Yes (until breaker) |
billing | insufficient quota, quota exceeded, payment required, credit balance, insufficient credit | surface_error | No |
model_not_found | model not found, unknown model, HTTP 404 from a completion endpoint, exception named NotFoundError | surface_error | No |
empty_response | empty response, json decode error | retry (limited) | Limited |
format_error | validation error, invalid json, schema error, a 400 whose body happens to contain digits like 500 / 8500 | surface_error | No |
unknown | anything else | retry for attempt ≤ 2, then surface_error | Limited |
As of PR #1898, billing errors are explicitly added to the non-retryable list and are classified before rate-limit (so quota exceeded no longer double-matches as a rate-limit error). This means a quota-exhausted call now surfaces immediately instead of retrying with backoff.
Classification Ordering
The order of checks in classify_error_kind matters: billing is checked before rate-limit so phrases like quota exceeded route to billing (non-retryable) instead of rate_limit (retryable). Auth is checked before billing so auth_permanent takes precedence on invalid API keys.
An auth error with no failover manager configured now surfaces immediately (surface_error) instead of falling through the generic retry ladder. Introduced in PR #4958 and confirmed on both the sync and async paths by PR #5359, this stops such an error from burning the whole retry budget on a credential that would never recover; with a failover manager registered, auth still triggers rotate_profile as before.
Status-code-first classification
As of PraisonAI PR #5841, classify_error_kind decides in two steps:
- Category from the status code or exception type. The HTTP status code (
401,404,429,503, …) or the exception class picks the category — this is authoritative. - Kind refined by message text. The message text can only pick a kind that is allowed for that category. A
400can becomeformat_error,context_overflow,billingand so on, but neveroverloaded.
If the error has no status code and no known exception type, the message-text matching described above runs as before. Text matching is now the fallback, not the main path.
If you upgraded, these errors now route differently:
| Error | Before | Now | Effect |
|---|---|---|---|
400 whose body contains 8500 or 5003 | overloaded | format_error | A permanent bad request is surfaced, not retried |
401 / 403 without the word “unauthorized” | unknown | auth | Profile rotation can now trigger |
404 (e.g. the model does not exist) | unknown | model_not_found | Surfaced as a config error |
503 with no body text | unknown | overloaded | Retried as an outage |
429 Rate limit reached for API key … | auth | rate_limit | Retried with rate-limit backoff |
400 mentioning credit card (e.g. image content) | billing | format_error | No longer read as a quota problem |
400 with credit balance / insufficient credit | billing | billing | Unchanged |
The billing keyword was narrowed from the bare word credit to credit balance / insufficient credit, so text such as credit card no longer counts as a billing error.
Retry-After Header
When a provider sends a Retry-After response header, the retry loop waits that long instead of the 1 s default. resolve_failover_decision passes the exception itself to the delay parser, so the header is read straight from the response.
For example, a 429 with Retry-After: 37 gives FailoverDecision.backoff_ms == 37000 — the agent waits 37 s, not 1 s. The wait is still capped (300 s by default, or the rate limiter’s max_retry_delay).
from praisonaiagents import Agent
agent = Agent(name="Polite Agent", instructions="Summarise the report")
agent.start("Summarise this quarter's sales") # waits as long as the provider asks on a 429FailoverDecision Structure
Every classification produces a FailoverDecision with these fields:
| Field | Type | Description | ||
|---|---|---|---|---|
action | "retry" \ | "rotate_profile" \ | "surface_error" | What action to take |
reason | AgentErrorKind | The classified error type | ||
backoff_ms | int | Milliseconds to wait before retry (0 = immediate) | ||
is_retryable | bool | Whether this error is worth retrying |
Idle-Timeout Circuit Breaker
The idle-timeout circuit breaker is separate from the per-tool circuit breaker. It protects against LLM provider stalls:
- Default: Stops after 3 consecutive
idle_timeoutfailures - Auto-resets: On any successful LLM call
- Only triggered by:
idle_timeouterror kind (not other timeouts)
Choosing Between Options
Common Patterns
Log Every Classified Failure
from praisonaiagents import Agent
from praisonaiagents import LLMError
def error_logger(error: LLMError):
print(f"⚠️ LLM {error.error_category}: {error.message}")
# Route specific error types
if error.error_category == "billing":
# Alert ops team
send_alert("billing", error.message)
elif error.error_category == "auth_permanent":
# Alert dev team
send_alert("config", error.message)
agent = Agent(
name="Monitored Agent",
instructions="Track all LLM failures",
on_error=error_logger
)Custom Breaker for Slow Models
from praisonaiagents import Agent
from praisonaiagents.errors import IdleTimeoutBreaker
from praisonaiagents.llm import LLM
# More patient with self-hosted models
slow_model_breaker = IdleTimeoutBreaker(max_consecutive=8)
agent = Agent(
name="Self-Hosted Agent",
llm=LLM(
model="ollama/custom-model",
idle_timeout_breaker=slow_model_breaker,
timeout=120 # 2 minute timeout
)
)Gate Alerts by Error Type
from praisonaiagents import Agent
def smart_alerting(error):
# Only alert on errors that need human intervention
alert_worthy = [
"auth_permanent", "model_not_found",
"context_overflow", "billing"
]
if error.error_category in alert_worthy:
send_slack_alert(f"🚨 {error.error_category}: {error.message}")
else:
# Just log retryable errors
logger.info(f"Retryable {error.error_category} - auto-handling")
agent = Agent(
name="Smart Alerting Agent",
instructions="Only alert on actionable errors",
on_error=smart_alerting
)Legacy Migration
Old error_category | New AgentErrorKind | Migration |
|---|---|---|
"tool" | "unknown" | Update error classification logic |
"llm" | "unknown" | More specific classification available |
"budget" | "billing" | Direct replacement |
"validation" | "format_error" | Direct replacement |
"network" | "unknown" | Use specific network error kinds |
"handoff" | "unknown" | Agent handoff errors are separate |
Example migration:
# OLD (deprecated - emits warning)
if error.error_category == "budget":
handle_budget_error()
# NEW (recommended)
if error.error_category == "billing":
handle_billing_error()Migrating from _classify_error_and_should_retry
If you previously subclassed LLM to customise retry classification via _classify_error_and_should_retry, override resolve_failover_decision instead. It returns a typed FailoverDecision in place of the old (category, retry, delay) tuple.
# OLD — no longer works after praisonaiagents 1.6.161
from praisonaiagents import LLM
class MyLLM(LLM):
def _classify_error_and_should_retry(self, error, attempt=1):
# Silently ignored — nothing calls this method any more
return "rate_limit", True, 5.0
# NEW — override the live decision point
from praisonaiagents import LLM
from praisonaiagents.errors import FailoverDecision
class MyLLM(LLM):
def resolve_failover_decision(self, error, attempt_state):
return FailoverDecision(
action="retry", reason="rate_limit",
backoff_ms=5000, is_retryable=True,
)If you called llm._classify_error_and_should_retry(...) directly (rare — the underscore prefix marked it as private), the equivalent public path is llm.resolve_failover_decision(error, {"attempt": n, "max_retries": llm._max_retries}).
Best Practices
Treat Permanent Errors as Config Issues
Errors classified as auth_permanent, model_not_found, and context_overflow indicate configuration problems, not transient failures. Set up monitoring to catch these during development.
permanent_errors = ["auth_permanent", "model_not_found", "format_error"]
if error.error_category in permanent_errors:
# Log as config issue, not service degradation
config_logger.error(f"Config issue: {error.error_category}")Tune Circuit Breaker by Model Speed
Fast cloud models can use the default max_consecutive=3. Slow self-hosted models should increase this to avoid premature circuit breaking.
# Fast cloud model (default)
fast_breaker = IdleTimeoutBreaker() # max_consecutive=3
# Slow self-hosted model
slow_breaker = IdleTimeoutBreaker(max_consecutive=8)Use Typed Categories Over String Matching
Instead of parsing error messages, use the typed error_category field for reliable error handling.
# AVOID: String parsing
if "quota" in str(exception):
handle_quota()
# PREFER: Typed classification
if error.error_category == "billing":
handle_billing_issue()Pair with Model Failover for Cross-Provider Resilience
Combine error classification with Model Failover to automatically switch providers on auth errors.
from praisonaiagents import FailoverManager
failover = FailoverManager([
AuthProfile(provider="openai", api_key="..."),
AuthProfile(provider="anthropic", api_key="...")
])
agent = Agent(
name="Resilient Agent",
llm={"model": "gpt-4o", "failover_manager": failover}
)
# Auth errors will automatically rotate to Anthropic