Skip to content

Reliability & Errors

Networks fail, rate limits bite, and models occasionally return nonsense. Kova is built to fail predictably: transient problems are retried automatically, provider failures are classified so you can react precisely, and everything that can go wrong maps to one flat error type you match on.

Transient provider failures — connection errors, timeouts, and HTTP 408, 429, and 500/502/503/504/529 — are retried automatically with exponential backoff. This applies to both blocking calls and establishing a stream.

The default is 2 retries with backoff capped at 10 seconds. Configure or disable it:

use kova_sdk::provider::RetryConfig;
// Custom policy
let agent = AgentBuilder::new()
.provider(provider)
.retry_config(RetryConfig { max_retries: 4, ..RetryConfig::default() })
.build()?;
// Turn retries off entirely
let agent = AgentBuilder::new()
.provider(provider)
.retry_config(RetryConfig::disabled())
.build()?;

Only errors where KovaError::is_retryable() is true are retried — a 429 is retried, a 400 Bad Request (your fault, retrying won’t help) is not. Retries are silent from your perspective: you either get a successful result or the final error after the last attempt.

// Inspect an error yourself
if err.is_retryable() {
// transient — a retry might succeed
}
if let Some(code) = err.status_code() {
eprintln!("HTTP {code}");
}

Not all provider failures should be handled the same way — a bad API key needs a human, a rate limit needs a back-off, an overloaded server just needs a retry. KovaError::Provider carries a class: ProviderErrorClass (in the prelude) so you can branch on the kind of failure without sniffing status codes or messages:

use kova_sdk::prelude::ProviderErrorClass;
match err.provider_class() {
Some(ProviderErrorClass::AuthInvalid) => { /* 401 — prompt for a new key */ }
Some(ProviderErrorClass::AuthForbidden) => { /* 403 — key lacks access */ }
Some(ProviderErrorClass::RateLimited { retry_after }) => { /* 429 — back off (honours Retry-After) */ }
Some(ProviderErrorClass::Overloaded) => { /* 408/5xx/529 — transient, retryable */ }
Some(ProviderErrorClass::InvalidRequest) => { /* 400/413/422 — incl. context-length */ }
Some(ProviderErrorClass::NotFound) => { /* 404 — unknown model/endpoint */ }
Some(ProviderErrorClass::Other) | None => { /* everything else / not a provider error */ }
}

err.is_retryable() derives from the class: RateLimited and Overloaded are retryable, everything else is not. The classification is uniform across every provider — Bedrock exception types (ThrottlingException, AccessDeniedException, …) and Anthropic’s 529 Overloaded are normalized to the same classes as the OpenAI-compatible and Gemini providers.

Every fallible operation in Kova returns Result<_, KovaError> — a single flat enum. Library code never panics; if something can fail, it’s in the Result. Match on the variant you care about:

use kova_sdk::error::KovaError;
match agent.run(&history).await {
Ok(response) => println!("{}", response.text),
Err(KovaError::Provider { message, status_code, class }) => {
// the model API returned an error (bad key, model not found, 429, …)
eprintln!("provider error {status_code:?} [{class:?}]: {message}");
}
Err(KovaError::Connection(msg)) => eprintln!("network unreachable: {msg}"),
Err(KovaError::Timeout(dur)) => eprintln!("timed out after {dur:?}"),
Err(KovaError::MaxIterations(n)) => eprintln!("tool loop hit its {n}-round cap"),
Err(KovaError::ToolNotFound(name)) => eprintln!("model called unknown tool {name}"),
Err(e) => eprintln!("other error: {e}"),
}
VariantRaised by
Provider { message, status_code, class }The model API returned an error response
Connection(String)Network unreachable / DNS / TLS (and MCP transport-level I/O failures)
Timeout(Duration)A request exceeded its timeout
ToolExecution { tool_name, message }A tool returned Err (not an in-band error)
ToolNotFound(String)The model called a tool that isn’t registered
Mcp(String)A server-reported MCP (JSON-RPC) protocol error
Stream(String)A streaming error
MaxIterations(usize)The agentic loop exceeded max_iterations
CancelledThe turn was cancelled via its CancellationToken
ContextBudgetExceeded { measured, budget }The assembled prompt exceeded context_budget
Build(String)AgentBuilder::build() misconfiguration
Serialization(_)JSON (de)serialization failure
Io(_)Underlying I/O failure

Build is special: it’s the only variant that comes from AgentBuilder::build(). Everything else is a runtime error. So a program that builds its agent successfully at startup won’t surprise you with a config error mid-request.

See the Error Types reference for the full per-variant handling guide.

Sometimes you need to stop a turn in flight — a user hit “stop”, a request deadline passed, an upstream connection dropped. The cancellable variants take a CancellationToken (a prelude re-export of tokio_util’s):

use kova_sdk::prelude::CancellationToken;
let cancel = CancellationToken::new();
// Spawn or hold the token elsewhere; call cancel.cancel() to abort.
let result = agent.run_cancellable(&history, cancel.clone()).await;
match result {
Err(KovaError::Cancelled) => println!("stopped"),
other => { /* … */ }
}

Cancellation races the in-flight provider call and any running tools — dropped tool futures kill spawned child processes via kill_on_drop. A cancelled turn returns KovaError::Cancelled and produces no messages, so your history is left exactly as it was. run_stream_cancellable is the streaming counterpart; plain run / run_stream delegate to these with a token that never fires.

A prompt that’s too long is a wasted (and sometimes expensive) round-trip. AgentBuilder::context_budget(max_prompt_tokens) caps the assembled prompt for every provider call in a turn:

let agent = AgentBuilder::new()
.provider(provider)
.context_budget(100_000) // max prompt tokens per call
.build()?;

Before each call the agent measures the prompt with a cheap offline heuristic (~4 chars/token). If it exceeds the budget, the turn fails immediately with KovaError::ContextBudgetExceeded { measured, budget } instead of sending a doomed request. Kova imposes no default budget — you supply the number based on the model’s context window (leaving headroom for the response). Pair it with history windowing (see Conversations & History).

This distinction (introduced in Tools) is the key reliability decision in your own code:

  • In-band errorOk(ToolResult { is_error: true, content }). Goes back to the model, which can read it and recover. Use for anything the model could reasonably work around: “city not found”, “invalid date format”.
  • Fatal errorErr(KovaError::…). Aborts the turn and bubbles up to your caller. Reserve for genuine faults you can’t recover from inside the tool.

Prefer in-band errors. A resilient agent is one whose tools tell the model what went wrong instead of crashing the turn.

max_iterations (default 10) caps how many tool-calling rounds a single turn can run. If a model gets stuck calling tools forever, you get a clean KovaError::MaxIterations instead of an infinite loop and an unbounded bill. Set it based on how deep your tasks legitimately go.

Each provider has its own request timeout (configurable in its config builder — e.g. .with_timeout(Duration::from_secs(60))). MCP calls are bounded too (connect_with_timeout, default 30s, plus a per-call tools_call_with_timeout). A blown timeout surfaces as KovaError::Timeout, which is retryable.

  • Transient failures (connection, timeout, 408/429/5xx) retry automatically with backoff — default 2 retries, configurable or disable-able.
  • KovaError::Provider carries a ProviderErrorClass so you can branch on auth / rate-limit / overload / invalid-request precisely; is_retryable() derives from it.
  • Everything maps to one KovaError enum; library code never panics. Build errors only come from build(); everything else is runtime.
  • Cancel a turn with a CancellationToken (KovaError::Cancelled, no messages produced); cap prompt size with context_budget (ContextBudgetExceeded).
  • Prefer in-band tool errors (ToolResult { is_error: true }) so the model can recover; max_iterations stops runaway loops.