Skip to main content

qcli/provider/
error.rs

1//! Why a reply did not come, told in words a person can act on.
2
3use std::fmt;
4
5/// How much of a server's answer is ever repeated back. Enough to recognise the complaint, short
6/// enough that the screen is not filled with someone's HTML error page.
7const QUOTED: usize = 240;
8
9/// Why a request to a provider failed.
10///
11/// None of these carries a key: a key is only ever a header value, never part of an address,
12/// and what is quoted back is the server's own words, not the request.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ProviderError {
15    /// The address could not be reached at all.
16    Unreachable {
17        /// Where the request was going.
18        url: String,
19        /// What the network said.
20        reason: String,
21    },
22    /// The server answered, and refused.
23    Refused {
24        /// Where the request went.
25        url: String,
26        /// The status it refused with.
27        status: u16,
28        /// The beginning of what it said.
29        said: String,
30    },
31    /// The server stopped the reply part way with an error of its own.
32    Stopped {
33        /// The beginning of what it said.
34        said: String,
35    },
36    /// What came back is not the shape the Messages API promises.
37    Unreadable {
38        /// What was wrong with it.
39        what: String,
40    },
41}
42
43impl fmt::Display for ProviderError {
44    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::Unreachable { url, reason } => write!(formatter, "{url}: {reason}"),
47            Self::Refused { url, status, said } => write!(formatter, "{url}: {status} {said}"),
48            Self::Stopped { said } => formatter.write_str(said),
49            Self::Unreadable { what } => formatter.write_str(what),
50        }
51    }
52}
53
54impl ProviderError {
55    /// The same error with every place `secret` appears in it hidden. Some servers repeat the key
56    /// they were sent in their refusal; what they say is shown and kept in the conversation, and
57    /// the key must reach neither.
58    #[must_use]
59    pub fn without(self, secret: &str) -> Self {
60        let hide = |text: String| if secret.is_empty() { text } else { text.replace(secret, "…") };
61        match self {
62            Self::Unreachable { url, reason } => Self::Unreachable { url: hide(url), reason: hide(reason) },
63            Self::Refused { url, status, said } => Self::Refused { url: hide(url), status, said: hide(said) },
64            Self::Stopped { said } => Self::Stopped { said: hide(said) },
65            Self::Unreadable { what } => Self::Unreadable { what: hide(what) },
66        }
67    }
68}
69
70/// The words of a refusal a person can act on, out of a body that may wrap them in JSON.
71///
72/// Providers put the sentence that says what happened in different places: `error.message` in
73/// the Anthropic shape, a bare `message` or `error` string in others, and some keep the useful
74/// part under `metadata.raw`. The most specific one is taken when the body is one of these
75/// shapes, and the body itself otherwise.
76pub(crate) fn complaint(body: &str) -> String {
77    let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else { return shorten(body) };
78    let error = value.get("error");
79    let said = [
80        value.get("metadata").and_then(|metadata| metadata.get("raw")),
81        error.and_then(|error| error.get("metadata")).and_then(|metadata| metadata.get("raw")),
82        error.and_then(|error| error.get("message")),
83        error.filter(|error| error.is_string()),
84        value.get("message"),
85    ]
86    .into_iter()
87    .flatten()
88    .find_map(|said| said.as_str().filter(|said| !said.trim().is_empty()))
89    .unwrap_or(body);
90    shorten(said)
91}
92
93/// The beginning of `said`, so that the screen is never filled with an error page.
94fn shorten(said: &str) -> String {
95    let trimmed = said.trim();
96    match trimmed.char_indices().nth(QUOTED) {
97        Some((end, _)) => format!("{}…", &trimmed[..end]),
98        None => trimmed.to_owned(),
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn the_sentence_is_taken_out_of_the_shapes_providers_use() {
108        let anthropic = r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#;
109        assert_eq!(complaint(anthropic), "invalid x-api-key");
110        assert_eq!(complaint(r#"{"error":"model not found"}"#), "model not found");
111        assert_eq!(complaint(r#"{"message":"rate limited"}"#), "rate limited");
112        assert_eq!(complaint("  Bad Gateway \n"), "Bad Gateway");
113    }
114
115    #[test]
116    fn a_long_page_is_cut_with_an_ellipsis() {
117        let page = "x".repeat(1000);
118        let said = complaint(&page);
119        assert_eq!(said.chars().count(), QUOTED + 1);
120        assert!(said.ends_with('…'));
121    }
122}