Skip to content

Error handling

Every fallible Rig call returns a typed error, so you can react to why something failed instead of parsing strings. Two types cover almost everything:

  • PromptError: an agent run failed (agent.prompt(..).await, or the last item of .stream()).
  • ProviderError: a call to a provider failed: completions, embeddings, transcription, image generation, model listing. One type for every operation; the variant says what went wrong.

Typed prompts and extractors wrap PromptError in a StructuredOutputError (see Structured Output); vector stores return VectorStoreError.

agent.prompt(..).await returns Result<PromptResponse, PromptError>. PromptError is #[non_exhaustive], so a match needs a catch-all arm:

match agent.prompt("What is 2 + 2?").max_turns(3).await {
Ok(response) => println!("{}", response.output()),
Err(PromptError::Provider(error)) => {
eprintln!("the model call failed ({:?}): {error}", error.kind());
}
Err(PromptError::MaxTurns { max_turns, .. }) => {
eprintln!("used all {max_turns} model calls without a final answer");
}
Err(PromptError::UnknownToolCall { tool_name, .. }) => {
eprintln!("the model called `{tool_name}`, which this agent doesn't have");
}
Err(PromptError::Cancelled { reason, .. }) => eprintln!("cancelled: {reason}"),
Err(error) => eprintln!("run failed: {error}"),
}
VariantMeaning
Provider(ProviderError)A model call failed: network, rate limit, bad request, unparseable reply, or a turn the provider ended with an error.
Report(ErrorReport)A failure delivered as a serialized report, for example from a hook, a handler on the effect bus, or a relayed stream. Same fields as below.
Memory(MemoryError)Conversation memory failed to load or save history.
MaxTurns { max_turns, chat_history, prompt }The run used its whole model-call budget. Raise it with .max_turns(n) or .default_max_turns(n).
Cancelled { reason, chat_history }A hook stopped the run.
UnknownToolCall { tool_name, available_tools, allowed_tools, .. }The model called a tool the agent doesn’t offer this turn, and no hook recovered it.

The variants that end mid-run carry chat_history, the conversation up to the failure, so you can log it or resume from it.

A tool that returns an error does not produce a PromptError: the error goes back to the model as the tool’s result and the run continues. See When a tool fails. A hook can still end the run on a specific tool failure; that shows up as Cancelled.

ProviderError is what a direct model call returns (model.call(..), model.stream(..), embedding_model.embed_text(..)), and what PromptError::Provider wraps. Its main variants:

VariantMeaning
Http(..)Transport failure with no reply: connection reset, timeout, unreadable response.
ProviderResponse(ProviderResponseError)The provider replied with an error: a non-2xx status, or an error body with a 2xx status. The reply is preserved.
InvalidAuthentication(ProviderResponseError)The provider rejected the credentials (401 or 403).
Json(..)A request or reply couldn’t be (de)serialized.
Request(..)The request couldn’t be built.
UnsupportedOption(..)The request set an option (reasoning, caching, …) the model or API can’t honour. This is the default OnUnsupported::Error policy; OnUnsupported::Ignore skips the option instead.
Response(String)The reply decoded but doesn’t answer the request, or the provider ended the turn with an error.
TruncatedA reply stopped before the provider finished it, for example a stream cut off mid-response.

ProviderError is also #[non_exhaustive]. Rather than matching every variant, use the helpers:

  • error.is_retryable(): whether the same request may succeed if sent again. Transport failures, timeouts, HTTP 408/429/5xx and truncated replies are retryable; bad requests, rejected credentials and malformed replies are not.
  • error.kind(): an ErrorKind, the same classification for every operation.
  • error.provider_response(): the preserved reply as a ProviderResponseError, with its status, raw body, headers and provider_request_id. There are shortcuts for each: provider_response_status(), provider_response_body(), provider_response_json(), provider_response_headers() and provider_request_id().
use rig::ProviderError;
fn log_failure(error: &ProviderError) {
eprintln!("{:?} (retryable: {}): {error}", error.kind(), error.is_retryable());
if let Some(status) = error.provider_response_status() {
eprintln!("provider returned HTTP {status}");
}
if let Some(request_id) = error.provider_request_id() {
eprintln!("quote this request id to the provider: {request_id}");
}
match error.provider_response_json() {
Ok(Some(body)) => eprintln!("error payload: {body}"),
Ok(None) => {}
Err(_) => eprintln!("non-JSON body: {:?}", error.provider_response_body()),
}
}

PromptError forwards the same provider_response_* accessors, so you can read the provider’s reply without unwrapping the variant first.

ProviderError holds live values (sources, header maps) and is not Serialize. To store or send an error, convert it with error.report() into an ErrorReport: a plain struct with kind, retryable, message, code, http_status, request_id, the source_chain as text, and the preserved provider_response. PromptError::Report carries one of these, so the same checks work on both:

use rig::completion::PromptError;
fn is_retryable(error: &PromptError) -> bool {
match error {
PromptError::Provider(error) => error.is_retryable(),
PromptError::Report(report) => report.is_retryable(),
_ => false,
}
}

Every model reply ends with a finish reason. Rig accepts the normal ones (stop, tool calls, token limit). A reply that ends with a reason outside that set fails the turn with PromptError::Provider(ProviderError::Response(..)), and its tool calls don’t run, because such reasons often mean the reply is broken (Gemini’s MALFORMED_FUNCTION_CALL, Bedrock’s malformed_tool_use). A content-filter stop also fails the turn.

If a provider uses a finish reason Rig doesn’t know for ordinary replies, accept unknown reasons as a normal stop, for every run or for one:

let agent = AgentBuilder::new(model)
.accept_unknown_finish_reasons(true)
.build();
let reply = agent
.prompt("Hello")
.accept_unknown_finish_reasons(true)
.await?;

A turn that ran out of output tokens before producing any answer also fails, with a message saying so; raise max_tokens if you see it.

Rig doesn’t retry failed model calls for you. Wrap the call in a backoff loop and retry only errors that are worth retrying:

use rig::agent::{Agent, PromptResponse};
use rig::completion::PromptError;
use std::time::Duration;
/// Prompt an agent, retrying transient failures with exponential backoff.
async fn prompt_with_retry(
agent: &Agent,
input: &str,
max_attempts: u32,
) -> Result<PromptResponse, PromptError> {
let mut attempt = 0;
loop {
attempt += 1;
let error = match agent.prompt(input).await {
Ok(response) => return Ok(response),
Err(error) => error,
};
let retryable = match &error {
PromptError::Provider(e) => e.is_retryable(),
PromptError::Report(report) => report.is_retryable(),
_ => false,
};
if !retryable || attempt >= max_attempts {
return Err(error);
}
// Prefer the provider's Retry-After header, else back off exponentially.
let backoff = error
.provider_response_headers()
.and_then(|headers| headers.get("retry-after")?.to_str().ok()?.parse().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_millis(200 * 2u64.pow(attempt - 1)));
eprintln!("attempt {attempt} failed ({error}); retrying in {backoff:?}");
tokio::time::sleep(backoff).await;
}
}
attempt 1 failed (provider returned 429 Too Many Requests: ...); retrying in 20s

A retried prompt starts the run over, including any tool calls it already made. For heavy workloads, also cap concurrency in front of your agents (for example a tokio::sync::Semaphore) so you throttle before the provider does.

Retrying a model turn because of its content (the answer is missing a required marker, or failed validation) is a different job: a hook can reject a finished turn and ask for another attempt within the run’s turn budget. See Hooks and the agent_with_retry_hook example.