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
54/// The words of a refusal a person can act on, out of a body that may wrap them in JSON.
55///
56/// Providers put the sentence that says what happened in different places: `error.message` in
57/// the Anthropic shape, a bare `message` or `error` string in others, and some keep the useful
58/// part under `metadata.raw`. The most specific one is taken when the body is one of these
59/// shapes, and the body itself otherwise.
60pub(crate) fn complaint(body: &str) -> String {
61    let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else { return shorten(body) };
62    let error = value.get("error");
63    let said = [
64        value.get("metadata").and_then(|metadata| metadata.get("raw")),
65        error.and_then(|error| error.get("metadata")).and_then(|metadata| metadata.get("raw")),
66        error.and_then(|error| error.get("message")),
67        error.filter(|error| error.is_string()),
68        value.get("message"),
69    ]
70    .into_iter()
71    .flatten()
72    .find_map(|said| said.as_str().filter(|said| !said.trim().is_empty()))
73    .unwrap_or(body);
74    shorten(said)
75}
76
77/// The beginning of `said`, so that the screen is never filled with an error page.
78fn shorten(said: &str) -> String {
79    let trimmed = said.trim();
80    match trimmed.char_indices().nth(QUOTED) {
81        Some((end, _)) => format!("{}…", &trimmed[..end]),
82        None => trimmed.to_owned(),
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn the_sentence_is_taken_out_of_the_shapes_providers_use() {
92        let anthropic = r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#;
93        assert_eq!(complaint(anthropic), "invalid x-api-key");
94        assert_eq!(complaint(r#"{"error":"model not found"}"#), "model not found");
95        assert_eq!(complaint(r#"{"message":"rate limited"}"#), "rate limited");
96        assert_eq!(complaint("  Bad Gateway \n"), "Bad Gateway");
97    }
98
99    #[test]
100    fn a_long_page_is_cut_with_an_ellipsis() {
101        let page = "x".repeat(1000);
102        let said = complaint(&page);
103        assert_eq!(said.chars().count(), QUOTED + 1);
104        assert!(said.ends_with('…'));
105    }
106}