---
title: Errors
description: Every AiError variant in rtb-chat — what raises it, what the message looks like, what to do about it, and what gets redacted first.
tags: [reference, errors]
---
# 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
| `InvalidConfig(String)` | `AiClient::new` rejects the config, or a schema fails to serialise | fix the config; see [Configuration](01-config.md#errors-from-aiclientnew) |
| `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:
| `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`.
| 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:
```text
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@host` → `https://[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:
```rust
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.