Errors and retries

Things fail: providers rate-limit, tools time out, processes die. CriogenAI sorts failures into three groups, and handles the first two without your code seeing an exception:

GroupExamplesWho handles it
The model can fix itWrong tool arguments, an unknown tool, a ToolError, a tool exception (by default)The engine sends the problem back to the model as the tool's result, and the run goes on
Waiting may fix itRate limits, overload, 5xx, timeouts, network errorsThe engine retries with backoff, honours Retry-After, then tries fallback models
Your code must decideA bad key, a limit reached, a busy thread, a crash, a cancelled runThe call raises (Python) or rejects (TypeScript) with an error that has a stable code

Every error has a code#

Every exception CriogenAI raises is a CriogenError with:

  • code: a stable snake_case string, the same in Python, TypeScript and Rust (model_failed, limit_exceeded, thread_busy, ...). Write your checks against it. Messages are for people and may change.
  • details: JSON with more about the failure, such as details["limit"] for limit_exceeded, or details["model_error"]["kind"] for model_failed.
  • a subclass per family, for except / instanceof.
Class (Python and TypeScript)Codes
ModelErrormodel_failed
ToolExecutionErrortool_failed (with on_tool_exception="fail"; the cause is your exception)
LimitExceededErrorlimit_exceeded, step_limit_exceeded, deadline_exceeded
ThreadBusyErrorthread_busy
ThreadSuspendedErrorthread_suspended
CancelledErrorcancelled
StorageErrorstorage_*, encryption_*, checkpoint_not_found, audit_failed
ValidationErrorinvalid_*, resume_invalid, workflow_mismatch
NativeUnavailableError (TypeScript)native_unavailable
CriogenError (the base)everything else, e.g. thread_interrupted, output_invalid, hook_failed, node_failed

The full list, with what each code means and whether a retry can help, is on Error codes.

from criogenai import (
    CancelledError,
    CriogenError,
    LimitExceededError,
    ModelError,
    ThreadBusyError,
    ThreadSuspendedError,
)

chat = agent.thread("ticket-7")
try:
    reply = await chat.send("Where is my order?")
except ThreadSuspendedError:
    ...  # an approval is waiting: answer it first (approve, reject or decide)
except ThreadBusyError:
    ...  # another run holds the thread (on_busy="reject", or the wait timed out)
except LimitExceededError as error:
    print(error.details["limit"])  # "model_calls", "tool_calls", "tokens", ...
except ModelError as error:
    kind = error.details["model_error"]["kind"]  # "rate_limited", "auth", "context_overflow", ...
    if kind in ("rate_limited", "overloaded", "server", "timeout", "network"):
        reply = await chat.retry()  # transient: continue from the failed step
except CancelledError:
    ...  # criogenai.CancelledError (not asyncio's): the run was cancelled, retry() continues it
except CriogenError as error:
    # Every other failure: decide by the stable code, never by the message.
    if error.code == "thread_interrupted":
        reply = await chat.retry()  # the process died mid-run: continue it
    else:
        raise
import {
  CancelledError,
  CriogenError,
  LimitExceededError,
  ModelError,
  ThreadBusyError,
  ThreadSuspendedError,
} from "criogenai";

const chat = agent.thread("ticket-7");
try {
  const reply = await chat.send("Where is my order?", { signal: AbortSignal.timeout(60_000) });
} catch (error) {
  if (error instanceof ThreadSuspendedError) {
    // an approval is waiting: answer it first (approve, reject or decide)
  } else if (error instanceof ThreadBusyError) {
    // another run holds the thread (onBusy "reject", or the wait timed out)
  } else if (error instanceof LimitExceededError) {
    console.log(error.details.limit); // "model_calls", "tool_calls", "tokens", ...
  } else if (error instanceof ModelError) {
    const kind = (error.details.model_error as { kind: string }).kind; // "rate_limited", "auth", ...
    if (["rate_limited", "overloaded", "server", "timeout", "network"].includes(kind)) {
      await chat.retry(); // transient: continue from the failed step
    }
  } else if (error instanceof CancelledError) {
    // the signal fired: the run is saved, and retry() continues it
  } else if (error instanceof CriogenError && error.code === "thread_interrupted") {
    await chat.retry(); // the process died mid-run: continue it
  } else {
    throw error; // decide by the stable code, never by the message
  }
}

Model errors: retries, then fallbacks#

When a model call fails, the provider's error is first classified into a kind: rate_limited, overloaded, server, timeout, network (transient), or auth, permission, not_found, invalid_request, context_overflow, content_filter, quota_exhausted, invalid_response, unknown. Then:

  1. Retry. Transient kinds are retried: 3 attempts in total by default, waiting 0.5 s, then 1 s, ... up to 20 s, with jitter. A Retry-After from the provider is honoured (up to 60 s by default). A request that never left your machine is retried at once.
  2. Fall back. If the attempts run out, the next model in fallbacks is tried, with its own retries. By default only the transient kinds move to the next model; fallback_on (fallbackOn) adds others: quota_exhausted (another provider may still answer), or context_overflow to a model with a longer context.
  3. Fail. If every model fails, the call raises ModelError (code model_failed). details["model_error"] has the kind, the provider and the model of the last failure. Keys never appear in it.
from criogenai import Agent, Retry

agent = Agent(
    "openai:gpt-5-mini",
    # Transient model errors (rate limits, overload, 5xx, timeouts, network)
    # are retried with backoff; Retry-After from the provider is honoured.
    model_retry=Retry(max_attempts=4, initial_backoff=1.0, max_backoff=30.0),
    max_retry_after=60,  # never wait longer than this for one Retry-After
    # Then the next model is tried, in order.
    fallbacks=["anthropic:claude-haiku-5-5", "ollama:llama3.2"],
    # Also move on for errors that another model may not have.
    fallback_on=["context_overflow", "content_filter"],
)
const resilient = new Agent({
  model: "openai:gpt-5-mini",
  // Transient model errors (rate limits, overload, 5xx, timeouts, network)
  // are retried with backoff; Retry-After from the provider is honoured.
  modelRetry: { maxAttempts: 4, initialBackoffSecs: 1, maxBackoffSecs: 30 },
  maxRetryAfterSecs: 60, // never wait longer than this for one Retry-After
  // Then the next model is tried, in order.
  fallbacks: ["anthropic:claude-haiku-5-5", "ollama:llama3.2"],
  // Also move on for errors that another model may not have.
  fallbackOn: ["context_overflow", "content_filter"],
});

result.answered_by (answeredBy) says which provider and model wrote the final answer, so you can see when a fallback answered.

Tool errors: back to the model#

A tool can fail four ways, and only one stops the run by default:

The tool...The model seesThe run
raises ToolError("...")your messagegoes on
raises ToolError(..., retryable=True)your message, after the engine retried a read_only or idempotent toolgoes on
raises any other exceptionits type and message (return_to_model), or only "the tool failed" (return_generic)goes on
raises with on_tool_exception="fail"nothingfails with tool_failed; the exception is the error's cause
times outa timeoutgoes on (a read_only / idempotent tool is retried first)

Arguments that fail the schema never reach your function; the model gets the problems and usually corrects itself. See Tools.

Thread retry: continue after a failure#

On a thread, a failed run is saved with its state. retry() continues it from the failed step:

  • Tool calls that had finished are not run again: their results were saved. A refund or an email is never repeated by a retry.
  • The model call that failed is made again.
  • If the process died mid-run (no error reached your code), the next send fails with thread_interrupted; retry() continues that run too.
  • retry() on a thread with nothing to retry fails with resume_invalid.

A failed head does not block the thread: send starts a new turn instead. Choose: retry() to finish what failed, send() to move on.

Limits are errors on purpose#

limit_exceeded (model calls, tool calls, tokens), step_limit_exceeded and deadline_exceeded (workflows) stop work that went on too long. Retrying the same run usually hits the same limit; raise the limit, change the instructions, or tell the user. See Agents and the loop.

What never goes in an error#

CriogenAI keeps secrets and content out of errors: API keys are removed from provider messages, and the gateway and telemetry send no content or error text unless you ask. When you return errors to a browser, return the code, not the message: a message can quote what a tool or the model saw.

Next#