Skip to content

Errors

Every fallible call returns Result<_, AiError>. AiError is #[non_exhaustive], derives Clone, and implements both std::error::Error (via thiserror) and miette::Diagnostic.

AiError variants

Variant Raised when Typical fix
InvalidConfig(String) AiClient::new rejects the config, or a schema fails to serialise fix the config; see Configuration
Provider(String) the provider returned a non-2xx that is not 401/403/429, or a malformed body read the message — it carries the provider's own explanation
Transport(String) DNS, TCP, TLS, timeout, or an interrupted body read retry; check the endpoint and the network
Auth(String) the provider returned 401 or 403 check the API key and its permissions
RateLimited { host, retry_after } the provider returned 429 back off for retry_after, or longer
SchemaValidation(String) chat_structured got JSON that does not match T's schema loosen the type, or make the prompt more specific
Deserialize(String) chat_structured got something that is not JSON, or JSON serde rejects usually a fenced or truncated reply — raise max_tokens

Diagnostic codes still say rtb::ai

The miette diagnostic codes were assigned before the crate was renamed from rtb-ai, and they were not renamed with it:

Variant Code
InvalidConfig rtb::ai::config
Provider rtb::ai::provider
Transport rtb::ai::transport
SchemaValidation rtb::ai::schema
Deserialize rtb::ai::deserialize
Auth rtb::ai::auth
RateLimited rtb::ai::rate_limited

If you match on codes, or grep logs for them, use rtb::ai::*. They are a public contract, so renaming them would be a breaking change.

How HTTP status codes map

Applies to the Anthropic-direct backend, on both chat and chat_stream.

Status Variant
2xx success
401, 403 Auth
429 RateLimited
any other non-2xx Provider

Provider messages are built as status {status} from {host}: {detail}, where detail is pulled out of the body in this order:

  1. error.message — the Messages API's own error shape;
  2. a top-level message field;
  3. the raw body, truncated to 500 characters on a character boundary and suffixed with .

An empty body becomes (empty response body). A body that cannot be read at all does not mask the status — the detail is simply empty.

So a real credit-balance failure reads:

provider error: status 400 Bad Request from api.anthropic.com: Your credit balance is too low to access the Anthropic API.

RateLimited is the exception: it is returned before the body is read, so it carries no detail. retry_after is parsed only from a Retry-After header holding an integer number of seconds. The HTTP-date form of the header parses as None, which means "no hint", not "retry now".

genai providers never return Auth or RateLimited

The status mapping above is implemented in rtb-chat's own Anthropic path. The genai backend has no equivalent: every failure genai reports — bad key, rate limit, model not found, connection refused — is wrapped as AiError::Provider.

Matching on AiError::Auth or AiError::RateLimited to drive a retry or a re-authentication prompt therefore works on Provider::Anthropic and Provider::AnthropicLocal and silently never fires on the other four. Match on AiError::Provider too, and inspect the message, if you need that behaviour everywhere.

What redaction does to the message

Every String payload built from provider output goes through rtb_redact::string before it is stored in the error. That scrubber removes:

  • userinfo in a URL (https://user:token@hosthttps://[redacted]@host);
  • Authorization-style values — Bearer, Basic and Token followed by a credential;
  • sensitive query-string parameters;
  • PEM private-key blocks;
  • well-known credential prefixes, JWT-shaped strings, and long opaque runs of token characters.

So an error message can arrive with [redacted] in the middle of it. That is the redactor working, not a truncated response. The marker is lower-case — the upper-case [REDACTED] on Config::api_key comes from secrecy's own Debug, a different mechanism.

Messages that rtb-chat writes itself — api_key must not be empty, model must not be empty, the validate_base_url rejections — are literals and are not run through the redactor. RateLimited::host is not redacted either; it is a hostname, which is what makes the error useful.

Displaying an error

AiError implements Display, so {e} gives the human-readable form. Under miette with the fancy feature — which this crate enables — the diagnostic code is shown alongside it:

match client.chat(req).await {
    Ok(resp) => println!("{}", resp.message.content[0].as_text().unwrap_or_default()),
    Err(e) => eprintln!("{e}"),
}

Errors are Clone, so one can be stashed for a retry decision and reported later without borrowing games.