ai_usagebar/error.rs
1//! Shared error type. Vendors and renderers convert their failures into
2//! `AppError` so the widget shell can decide whether to retry, fall back to
3//! cache, show ⚠, or show "Loading…".
4
5use std::io;
6use std::path::PathBuf;
7
8pub type Result<T> = std::result::Result<T, AppError>;
9
10pub const AUTH_FAILURE_MESSAGE: &str =
11 "authentication rejected — credentials may be missing, expired, or invalid";
12
13#[derive(Debug, thiserror::Error)]
14pub enum AppError {
15 /// Local I/O failed (cache write, credentials read, theme file, etc.).
16 ///
17 /// **The path is sanitized here rather than at each print site.** A path in
18 /// this variant is not always a literal this program chose — it can carry a
19 /// component from an account label, a vendor response, or an archive
20 /// member — and `Display for Path` escapes nothing. Doing it at the
21 /// `Display` covers every site that formats an `AppError`, including the
22 /// ones written after this comment.
23 #[error(
24 "io error at {}: {source}",
25 crate::display::sanitize_untrusted_path(path)
26 )]
27 Io {
28 path: PathBuf,
29 #[source]
30 source: io::Error,
31 },
32
33 /// Generic I/O without a meaningful path (e.g. stdout writes).
34 #[error(transparent)]
35 IoBare(#[from] io::Error),
36
37 /// A vendor's credentials file is missing, unreadable, or malformed.
38 /// Distinct from `Io` because the widget treats it as "user must re-auth"
39 /// rather than a transient failure.
40 #[error("credentials error: {0}")]
41 Credentials(String),
42
43 /// The credential is valid but its account may not read the resource.
44 /// Distinct from `Http { status: 403 }` because UIs render that status as
45 /// the generic authentication failure; a permission problem needs its own
46 /// actionable text (Mistral's `/billing/budget` requires the
47 /// `see_org_inference_usage` permission).
48 #[error("{0}")]
49 Permission(String),
50
51 /// HTTP request failed at the transport layer (DNS, TLS, timeout, connect).
52 /// Maps to claudebar's "HTTP 000" — show `Loading…`, don't write
53 /// `.last_error`, retry next tick.
54 #[error("network transport error: {0}")]
55 Transport(String),
56
57 /// HTTP request reached the server but returned a non-2xx status.
58 /// Carries the code + best-effort body so the widget can populate
59 /// `.last_error` for the tooltip.
60 #[error("HTTP {status}: {body}")]
61 Http { status: u16, body: String },
62
63 /// API returned 2xx but the body did not match our expected schema.
64 /// Treated like an HTTP error for tooltip purposes, but logged separately
65 /// because it signals undocumented-endpoint drift.
66 #[error("schema mismatch: {0}")]
67 Schema(String),
68
69 /// JSON serialization/deserialization failure (config files, response bodies).
70 #[error("json error: {0}")]
71 Json(#[from] serde_json::Error),
72
73 /// TOML config parse failure.
74 #[error("toml error: {0}")]
75 Toml(#[from] toml::de::Error),
76
77 /// Catch-all for unexpected conditions (cache lock contention, etc.).
78 #[error("{0}")]
79 Other(String),
80
81 /// A fetch failed after the vendor's plan label was already known (Claude
82 /// OAuth `subscriptionType`, etc.). Display and [`Self::user_message`] are
83 /// the inner error so UIs can still name the real cause; the plan is for
84 /// surfaces that can show it without a quota snapshot.
85 #[error("{source}")]
86 WithPlan {
87 plan: String,
88 #[source]
89 source: Box<AppError>,
90 },
91}
92
93impl AppError {
94 /// Convenience for non-pathful I/O.
95 pub fn io_at(path: impl Into<PathBuf>, source: io::Error) -> Self {
96 AppError::Io {
97 path: path.into(),
98 source,
99 }
100 }
101
102 /// Attach a plan label already known from credentials. Empty labels are
103 /// dropped so a card does not render a blank plan row.
104 pub fn with_plan(self, plan: impl Into<String>) -> Self {
105 let plan = plan.into();
106 if plan.is_empty() {
107 self
108 } else {
109 AppError::WithPlan {
110 plan,
111 source: Box::new(self),
112 }
113 }
114 }
115
116 /// Plan label carried by [`Self::WithPlan`], if any.
117 pub fn plan(&self) -> Option<&str> {
118 match self {
119 AppError::WithPlan { plan, .. } => Some(plan.as_str()),
120 _ => None,
121 }
122 }
123
124 /// True for transient network errors that the widget should hide behind a
125 /// "Loading…" rather than a "⚠".
126 pub fn is_transient(&self) -> bool {
127 match self {
128 AppError::Transport(_) => true,
129 AppError::WithPlan { source, .. } => source.is_transient(),
130 _ => false,
131 }
132 }
133
134 /// Render an error for a local UI or report without exposing an upstream
135 /// authentication response body. Other errors retain their diagnostic text.
136 pub fn user_message(&self) -> String {
137 match self {
138 AppError::WithPlan { source, .. } => source.user_message(),
139 AppError::Http { status, .. } if matches!(status, 401 | 403) => {
140 format!("HTTP {status}: {AUTH_FAILURE_MESSAGE}")
141 }
142 other => other.to_string(),
143 }
144 }
145}
146
147/// Map a reqwest error into the right variant. Connection-class failures
148/// become `Transport` (transient); the rest become generic `Http`/`Other`.
149/// `Transport` carries the source chain — reqwest's own Display swallows it,
150/// and without it a TLS failure behind a corporate proxy is indistinguishable
151/// from an outage (the certificate cause, e.g. `UnknownIssuer`, is in the
152/// chain, not the message). The chain carries TLS internals, never
153/// credentials.
154impl From<reqwest::Error> for AppError {
155 fn from(err: reqwest::Error) -> Self {
156 if err.is_timeout() || err.is_connect() || err.is_request() {
157 let mut msg = err.to_string();
158 let mut source = std::error::Error::source(&err);
159 while let Some(cause) = source {
160 msg.push_str(&format!(": {cause}"));
161 source = cause.source();
162 }
163 return AppError::Transport(msg);
164 }
165 if let Some(status) = err.status() {
166 return AppError::Http {
167 status: status.as_u16(),
168 body: err.to_string(),
169 };
170 }
171 AppError::Other(err.to_string())
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 /// Asserted at the `Display`, not at a print site, because that is the
180 /// whole point: a path in this variant can come from an account label, a
181 /// vendor response, or an archive member, and there are a dozen places
182 /// that format one.
183 #[test]
184 fn an_io_path_carrying_a_terminal_escape_renders_without_it() {
185 let rendered = AppError::Io {
186 path: PathBuf::from("/tmp/\x1b[2Kspoofed\nRESTORED: 0"),
187 source: io::Error::other("disk full"),
188 }
189 .to_string();
190
191 assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
192 assert!(
193 !rendered.contains('\n'),
194 "an embedded newline forges a line: {rendered:?}"
195 );
196 assert!(rendered.contains("disk full"), "{rendered}");
197 }
198
199 /// A closed loopback port produces a connect-class reqwest error whose
200 /// Display alone is "error sending request"; the Transport message must
201 /// carry the chain so the OS cause ("Connection refused") survives.
202 #[tokio::test]
203 async fn a_transport_error_carries_its_source_chain() {
204 let client = reqwest::Client::new();
205 let err = client
206 .get("http://127.0.0.1:1/")
207 .send()
208 .await
209 .expect_err("port 1 is closed");
210 let rendered = AppError::from(err).to_string();
211 assert!(
212 rendered.contains("Connection refused") || rendered.contains("refused"),
213 "the OS cause must survive into the message: {rendered:?}"
214 );
215 }
216
217 #[test]
218 fn user_message_does_not_expose_authentication_response_bodies() {
219 for status in [401, 403] {
220 let error = AppError::Http {
221 status,
222 body: "PANCEA user@example.test <credential>&token".into(),
223 };
224 let rendered = error.user_message();
225 assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
226 assert!(!rendered.contains("PANCEA"));
227 assert!(!rendered.contains("user@example.test"));
228 assert!(!rendered.contains("&token"));
229 }
230 }
231
232 #[test]
233 fn user_message_preserves_non_authentication_diagnostics() {
234 let error = AppError::Http {
235 status: 500,
236 body: "provider unavailable".into(),
237 };
238 assert!(error.user_message().contains("provider unavailable"));
239 }
240
241 #[test]
242 fn with_plan_keeps_the_inner_message_and_the_label() {
243 let error = AppError::Http {
244 status: 401,
245 body: "invalid token".into(),
246 }
247 .with_plan("Claude Max 5x");
248 assert_eq!(error.plan(), Some("Claude Max 5x"));
249 let rendered = error.user_message();
250 assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
251 assert!(!rendered.contains("invalid token"));
252 assert!(!error.is_transient());
253 assert!(
254 AppError::Transport("timeout".into())
255 .with_plan("Pro")
256 .is_transient()
257 );
258 }
259}