Skip to main content

rtb_chat/
error.rs

1//! Typed errors for the AI client.
2//!
3//! Payloads carrying provider, transport or (de)serialisation text run
4//! through [`rtb_redact::string`] so leaked URLs / tokens / headers in
5//! upstream provider errors never reach our telemetry.
6
7use std::time::Duration;
8
9use miette::Diagnostic;
10use thiserror::Error;
11
12/// Failures surfaced by [`crate::AiClient`].
13///
14/// Text from a provider response, a transport error or a serde/schema
15/// failure has been through [`rtb_redact::string`] before storage. Two
16/// payloads have not, because they come from the caller's own config:
17/// the `InvalidConfig` messages raised by config checks, and
18/// `RateLimited::host`, which cannot carry userinfo because
19/// [`crate::validate_base_url`] rejects it.
20#[derive(Debug, Clone, Error, Diagnostic)]
21#[non_exhaustive]
22pub enum AiError {
23    /// Bad config — invalid base URL, empty API key, unsupported
24    /// provider+model combination.
25    #[error("invalid AI client config: {0}")]
26    #[diagnostic(code(rtb::ai::config))]
27    InvalidConfig(String),
28
29    /// Provider returned an error response (4xx / 5xx that isn't a
30    /// rate-limit or auth issue).
31    #[error("provider error: {0}")]
32    #[diagnostic(code(rtb::ai::provider))]
33    Provider(String),
34
35    /// HTTP transport failure — DNS, TCP, TLS, body read interrupted.
36    #[error("HTTP transport: {0}")]
37    #[diagnostic(code(rtb::ai::transport))]
38    Transport(String),
39
40    /// `chat_structured` got a response that didn't validate against
41    /// the requested type's `JsonSchema`.
42    #[error("response did not validate against schema: {0}")]
43    #[diagnostic(code(rtb::ai::schema))]
44    SchemaValidation(String),
45
46    /// `chat_structured` got JSON that validated against the schema
47    /// but failed `serde::Deserialize` for the target type.
48    #[error("response was not valid JSON for the requested type: {0}")]
49    #[diagnostic(code(rtb::ai::deserialize))]
50    Deserialize(String),
51
52    /// Provider rejected the request as unauthenticated or expired.
53    #[error("authentication failed: {0}")]
54    #[diagnostic(code(rtb::ai::auth))]
55    Auth(String),
56
57    /// Provider rate-limited us. `retry_after` is populated when the
58    /// `Retry-After` header is parseable.
59    #[error("rate limited by {host} (retry-after: {retry_after:?})")]
60    #[diagnostic(code(rtb::ai::rate_limited))]
61    RateLimited {
62        /// Host that returned the rate-limit response.
63        host: String,
64        /// Server-suggested wait, when present.
65        retry_after: Option<Duration>,
66    },
67}
68
69/// Sanitise a free-form provider error payload before it lands in an
70/// `AiError`. Centralised so every error site goes through the same
71/// redactor.
72pub(crate) fn redact(input: &str) -> String {
73    rtb_redact::string(input).into_owned()
74}