Skip to main content

ccwire_oauth/
error.rs

1//! [`Error`]: why a call failed, never with a token (or the account's
2//! personal data) in it.
3
4use alloc::string::{String, ToString};
5use core::time::Duration;
6
7use serde_json::Value;
8
9/// Why a call failed.
10///
11/// No variant holds a token: response bodies are scrubbed (see
12/// [`Error::Decode`]) and transport errors drop the URL. Snippets also
13/// drop personal data (emails, names, phone numbers, addresses).
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// The token was refused: HTTP 401, or (from the token endpoint) an
18    /// `invalid_grant`, which means the refresh token was revoked or has
19    /// already been rotated away. The login needs a new token (a refresh,
20    /// or a new login if refreshing gives this too).
21    #[error("unauthorized (HTTP {status}{})", fmt_code(code.as_deref()))]
22    Unauthorized {
23        /// The HTTP status.
24        status: u16,
25        /// The API's error code, if the body had one (`invalid_grant`,
26        /// `authentication_error`, `permission_error`, ...).
27        code: Option<String>,
28    },
29    /// HTTP 403: the token is good but may not do this (a scope it lacks,
30    /// such as an inference-only token on a profile endpoint, or a role it
31    /// lacks in the organization). Logging in again won't change it.
32    #[error("forbidden (HTTP {status}{})", fmt_code(code.as_deref()))]
33    Forbidden {
34        /// The HTTP status (403).
35        status: u16,
36        /// The API's error code, if the body had one (`permission_error`,
37        /// ...).
38        code: Option<String>,
39    },
40    /// HTTP 429.
41    #[error("rate limited{}", fmt_retry(*retry_after))]
42    RateLimited {
43        /// The `Retry-After` header, if it was there and readable (seconds
44        /// or an HTTP date).
45        retry_after: Option<Duration>,
46    },
47    /// Any other non-success status.
48    #[error("HTTP {status}{}", fmt_code(code.as_deref()))]
49    Http {
50        /// The HTTP status.
51        status: u16,
52        /// The API's error code, if the body had one.
53        code: Option<String>,
54        /// The API's error message, if the body had one, scrubbed and cut
55        /// short.
56        message: Option<String>,
57    },
58    /// A success status whose body isn't the expected JSON.
59    #[error("unexpected response (HTTP {status}): {message}")]
60    Decode {
61        /// The HTTP status.
62        status: u16,
63        /// What didn't match, with any quoted value in it replaced.
64        message: String,
65        /// The start of the body, with the values of token-like and
66        /// personal keys replaced, and any string that looks like a token.
67        /// That last rule is broad: ids (UUIDs) and other long unbroken
68        /// strings are replaced too.
69        snippet: String,
70    },
71    /// The request didn't complete: connect, TLS, a timeout. The error
72    /// carries no URL.
73    #[error("transport: {0}")]
74    Transport(#[source] reqwest::Error),
75    /// The response body was larger than the client accepts.
76    #[error("response body over {limit} bytes")]
77    TooLarge {
78        /// The limit, in bytes.
79        limit: usize,
80    },
81    /// [`Client::refresh_and_store`](crate::Client::refresh_and_store)'s
82    /// task was cancelled before it finished (its runtime shut down).
83    #[error("the refresh task was cancelled")]
84    Cancelled,
85    /// The client was misconfigured (a bad base URL, a header value).
86    #[error("config: {0}")]
87    Config(String),
88}
89
90impl Error {
91    /// Whether the login itself is bad (refresh or re-login needed), as
92    /// opposed to a passing failure. Not true for [`Error::Forbidden`].
93    pub fn is_unauthorized(&self) -> bool {
94        matches!(self, Self::Unauthorized { .. })
95    }
96
97    /// Whether the token was good but not allowed this call
98    /// ([`Error::Forbidden`]).
99    pub fn is_forbidden(&self) -> bool {
100        matches!(self, Self::Forbidden { .. })
101    }
102
103    /// Whether a later retry may succeed: rate limiting, a 5xx, a transport
104    /// failure.
105    pub fn is_transient(&self) -> bool {
106        match self {
107            Self::RateLimited { .. } | Self::Transport(_) => true,
108            Self::Http { status, .. } => *status >= 500,
109            _ => false,
110        }
111    }
112}
113
114fn fmt_code(code: Option<&str>) -> String {
115    code.map(|c| alloc::format!(", {}", scrub_text(c, 64)))
116        .unwrap_or_default()
117}
118
119fn fmt_retry(retry_after: Option<Duration>) -> String {
120    retry_after
121        .map(|d| alloc::format!(", retry after {}s", d.as_secs()))
122        .unwrap_or_default()
123}
124
125/// The most of a body kept in [`Error::Decode`]'s snippet.
126pub(crate) const SNIPPET_LEN: usize = 256;
127const MESSAGE_LEN: usize = 200;
128const REDACTED: &str = "<redacted>";
129
130/// Object keys whose values are replaced in snippets, matched as
131/// lowercase substrings: secrets, and the personal data the profile and
132/// account endpoints carry (`email`, `email_address`, `full_name`,
133/// `display_name`, `organization_name`, ...).
134const SECRET_KEYS: &[&str] = &[
135    "token",
136    "secret",
137    "password",
138    "key",
139    "code",
140    "authorization",
141    "cookie",
142    "credential",
143    "email",
144    "name",
145    "phone",
146    "address",
147    "birth",
148];
149
150/// `{"error": "code", "error_description": "..."}` (OAuth) or
151/// `{"type": "error", "error": {"type": "...", "message": "..."}}` (the
152/// API): the code and the message, scrubbed.
153pub(crate) fn error_fields(body: &[u8]) -> (Option<String>, Option<String>) {
154    let Ok(Value::Object(o)) = serde_json::from_slice::<Value>(body) else {
155        return (None, None);
156    };
157    let s = |v: Option<&Value>, n| v.and_then(Value::as_str).map(|s| scrub_text(s, n));
158    match o.get("error") {
159        Some(Value::String(c)) => (
160            Some(scrub_text(c, 64)),
161            s(o.get("error_description"), MESSAGE_LEN),
162        ),
163        Some(Value::Object(e)) => (s(e.get("type"), 64), s(e.get("message"), MESSAGE_LEN)),
164        _ => (None, None),
165    }
166}
167
168/// A serde error's text with every `"..."` replaced: `serde_json` quotes the
169/// offending string value, which could be a token.
170pub(crate) fn scrub_serde(e: &serde_json::Error) -> String {
171    let text = e.to_string();
172    let mut out = String::with_capacity(text.len());
173    let mut parts = text.split('"');
174    if let Some(first) = parts.next() {
175        out.push_str(first);
176    }
177    for (i, part) in parts.enumerate() {
178        out.push('"');
179        out.push_str(if i % 2 == 0 { REDACTED } else { part });
180    }
181    out
182}
183
184/// The start of a body, for [`Error::Decode`]. JSON keeps its shape with
185/// the values of token-like keys (and anything that looks like a token)
186/// replaced; anything else is cut and its token-like words replaced.
187pub(crate) fn snippet(body: &[u8]) -> String {
188    match serde_json::from_slice::<Value>(body) {
189        Ok(mut v) => {
190            scrub_value(&mut v);
191            truncate(&v.to_string(), SNIPPET_LEN)
192        }
193        Err(_) => scrub_text(&String::from_utf8_lossy(body), SNIPPET_LEN),
194    }
195}
196
197fn scrub_value(v: &mut Value) {
198    match v {
199        Value::Object(o) => {
200            for (k, val) in o.iter_mut() {
201                let k = k.to_ascii_lowercase();
202                if SECRET_KEYS.iter().any(|s| k.contains(s)) && !val.is_null() {
203                    *val = Value::String(REDACTED.into());
204                } else {
205                    scrub_value(val);
206                }
207            }
208        }
209        Value::Array(a) => a.iter_mut().for_each(scrub_value),
210        Value::String(s) if looks_like_token(s) || looks_like_email(s) => *s = REDACTED.into(),
211        _ => {}
212    }
213}
214
215/// Free text, cut to `max` chars, with token-like words replaced.
216pub(crate) fn scrub_text(text: &str, max: usize) -> String {
217    let words: alloc::vec::Vec<&str> = text
218        .split_inclusive(char::is_whitespace)
219        .map(|w| {
220            if looks_like_token(w.trim()) || looks_like_email(w) {
221                REDACTED
222            } else {
223                w
224            }
225        })
226        .collect();
227    truncate(&words.concat(), max)
228}
229
230/// An Anthropic token (`sk-ant-...`), a bearer header, or a long run of
231/// token characters with no spaces.
232fn looks_like_token(s: &str) -> bool {
233    let s = s.trim_matches(|c: char| matches!(c, '"' | '\'' | ',' | ';'));
234    s.contains("sk-ant-")
235        || s.to_ascii_lowercase().starts_with("bearer")
236        || (s.len() >= 32
237            && s.chars().all(|c| {
238                c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~' | '+' | '/' | '=')
239            }))
240}
241
242/// Anything with an `@` in it: an email address, wherever it appears.
243fn looks_like_email(s: &str) -> bool {
244    s.contains('@')
245}
246
247fn truncate(s: &str, max: usize) -> String {
248    match s.char_indices().nth(max) {
249        Some((i, _)) => {
250            let mut out = s[..i].to_string();
251            out.push('…');
252            out
253        }
254        None => s.to_string(),
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn snippet_redacts_token_keys_and_values() {
264        let body = br#"{"access_token":"sk-ant-oat01-fake","refresh_token":"r","expires_in":"soon","note":"sk-ant-ort01-other","nested":{"api_key":"k"},"ok":"short"}"#;
265        let s = snippet(body);
266        assert!(!s.contains("sk-ant"), "{s}");
267        assert!(!s.contains("\"r\""), "{s}");
268        assert!(s.contains("\"expires_in\":\"soon\""), "{s}");
269        assert!(s.contains("\"ok\":\"short\""), "{s}");
270    }
271
272    #[test]
273    fn snippet_redacts_personal_data() {
274        let body = br#"{"account":{"uuid":"u1","email_address":"ada@example.com","full_name":"Ada Example","display_name":"Ada","phone_number":"+1 555 0100"},"organization":{"name":"Example Org","billing_type":"stripe"},"email":"x@example.org","roles":{"organization_name":"Example Org"},"note":"mail ada@example.com"}"#;
275        let s = snippet(body);
276        for leak in ["ada@", "Ada", "Example", "555", "x@example"] {
277            assert!(!s.contains(leak), "{leak}: {s}");
278        }
279        assert!(s.contains(r#""billing_type":"stripe""#), "{s}");
280        assert!(s.contains(r#""uuid":"u1""#), "{s}");
281    }
282
283    #[test]
284    fn snippet_of_text_is_cut_and_scrubbed() {
285        let body = alloc::format!(
286            "<html>sk-ant-oat01-fake ada@example.com {}</html>",
287            "x".repeat(400)
288        );
289        let s = snippet(body.as_bytes());
290        assert!(!s.contains("sk-ant"), "{s}");
291        assert!(!s.contains("ada@"), "{s}");
292        assert!(s.chars().count() <= SNIPPET_LEN + 1);
293    }
294
295    #[test]
296    fn serde_messages_lose_quoted_values() {
297        #[derive(Debug, serde::Deserialize)]
298        #[allow(dead_code)]
299        struct T {
300            n: u64,
301        }
302        let e = serde_json::from_str::<T>(r#"{"n":"sk-ant-oat01-fake"}"#).unwrap_err();
303        let m = scrub_serde(&e);
304        assert!(!m.contains("sk-ant"), "{m}");
305        assert!(m.contains("invalid type"), "{m}");
306    }
307
308    #[test]
309    fn error_fields_both_shapes() {
310        assert_eq!(
311            error_fields(
312                br#"{"error":"invalid_grant","error_description":"Refresh token not found"}"#
313            ),
314            (
315                Some("invalid_grant".into()),
316                Some("Refresh token not found".into())
317            )
318        );
319        assert_eq!(
320            error_fields(
321                br#"{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}"#
322            ),
323            (Some("rate_limit_error".into()), Some("slow down".into()))
324        );
325        assert_eq!(error_fields(b"nope"), (None, None));
326    }
327
328    #[test]
329    fn display() {
330        let e = Error::Unauthorized {
331            status: 400,
332            code: Some("invalid_grant".into()),
333        };
334        assert_eq!(e.to_string(), "unauthorized (HTTP 400, invalid_grant)");
335        let e = Error::RateLimited {
336            retry_after: Some(Duration::from_secs(30)),
337        };
338        assert_eq!(e.to_string(), "rate limited, retry after 30s");
339        assert!(e.is_transient());
340    }
341}