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