error.rsannotatederror.rssource229 lines · 8.3 KB · raw
1use std::fmt;
2
3use serde_json::Value;

A request that cannot be built, or a response that does not answer it.

6#[derive(Clone, Debug, PartialEq)]
7#[non_exhaustive]
8pub enum ProtocolError {

The request would break an API rule; nothing was sent.

10    Invalid(String),

The response does not answer the questions asked.

12    Response(String),

A pinned model was answered by another.

14    ModelMismatch { pinned: String, answered: String },
15}
17impl fmt::Display for ProtocolError {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        match self {
20            ProtocolError::Invalid(e) => write!(f, "invalid Jev request: {e}"),
21            ProtocolError::Response(e) => write!(f, "unexpected Jev response: {e}"),
22            ProtocolError::ModelMismatch { pinned, answered } => {
23                write!(f, "the pinned model is {pinned} but {answered} answered")
24            }
25        }
26    }
27}
28
29impl std::error::Error for ProtocolError {}

A non-200 response. Bodies come as {"detail": {"error_type", "message"}} (measured 2026-09-22), as a list of field errors on validation failures, or as anything else; all three are read.

34#[derive(Clone, Debug, PartialEq)]
35pub struct ApiError {
36    pub status: u16,
37    pub kind: ApiErrorKind,
38    pub error_type: Option<String>,
39    pub message: String,
40    pub field_errors: Vec<FieldError>,

The only handle for a billing dispute; sent even on auth errors.

42    pub request_id: Option<String>,
43}
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum ApiErrorKind {
48    BadRequest,

A missing key gets 403 (measured 2026-09-22); an invalid one 401 authentication_error (measured 2026-09-23).

51    Authentication,

The account has no credit left. Inferred, not documented: the vendor names no out-of-credit response, so a 402, or a 403 whose message speaks of credit, balance or quota, is read as this (as jevsnes' Failure::of did; digest §10). Never retried: waiting does not top up an account.

57    OutOfCredit,
58    NotFound,
59    RequestTimeout,

A 400 whose error_type is max_tokens_exceeded: the request is over the context limit and must be split, not retried.

62    ContextOverflow,
63    Unprocessable,
64    RateLimited,
65    Overloaded,
66    Server,
67    Other,
68}
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct FieldError {
72    pub location: Vec<String>,
73    pub message: String,
74}
75
76const EXCERPT_CHARS: usize = 500;
77
78impl ApiError {
79    pub fn from_response(status: u16, headers: &http::HeaderMap, body: &[u8]) -> Self {
80        let request_id = crate::request_id(headers);
81        let json: Option<Value> = serde_json::from_slice(body).ok();
82        let detail = json.as_ref().and_then(|j| j.get("detail"));
83        let mut error_type = None;
84        let mut field_errors = Vec::new();
85        let message = match detail {
86            Some(Value::Object(d)) => {
87                error_type = d.get("error_type").and_then(Value::as_str).map(str::to_owned);
88                d.get("message").and_then(Value::as_str).map(str::to_owned)
89            }
90            Some(Value::Array(items)) => {
91                field_errors = items.iter().filter_map(field_error).collect();
92                (!field_errors.is_empty()).then(|| {
93                    field_errors.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
94                })
95            }
96            Some(Value::String(s)) => Some(s.clone()),
97            _ => None,
98        }
99        .unwrap_or_else(|| excerpt(body));
100        let kind = match (status, error_type.as_deref()) {
101            (400, Some("max_tokens_exceeded")) => ApiErrorKind::ContextOverflow,
102            (400, _) => ApiErrorKind::BadRequest,
103            (402, _) => ApiErrorKind::OutOfCredit,
104            (403, _) if speaks_of_credit(&message) => ApiErrorKind::OutOfCredit,
105            (401 | 403, _) => ApiErrorKind::Authentication,
106            (404, _) => ApiErrorKind::NotFound,
107            (408, _) => ApiErrorKind::RequestTimeout,
108            (422, _) => ApiErrorKind::Unprocessable,
109            (429, _) => ApiErrorKind::RateLimited,
110            (529, _) => ApiErrorKind::Overloaded,
111            (500..=599, _) => ApiErrorKind::Server,
112            _ => ApiErrorKind::Other,
113        };
114        ApiError { status, kind, error_type, message, field_errors, request_id }
115    }
116}
117
118fn speaks_of_credit(message: &str) -> bool {
119    let message = message.to_ascii_lowercase();
120    ["credit", "balance", "insufficient funds", "quota exceeded"].iter().any(|w| message.contains(w))
121}
122
123fn field_error(item: &Value) -> Option<FieldError> {
124    let message = item.get("msg").or_else(|| item.get("message"))?.as_str()?.to_owned();
125    let location = item
126        .get("loc")
127        .and_then(Value::as_array)
128        .map(|loc| loc.iter().map(|p| p.as_str().map_or_else(|| p.to_string(), str::to_owned)).collect())
129        .unwrap_or_default();
130    Some(FieldError { location, message })
131}
132
133fn excerpt(body: &[u8]) -> String {
134    let text = String::from_utf8_lossy(body);
135    let text = text.trim();
136    if text.is_empty() {
137        return "(empty body)".into();
138    }
139    match text.char_indices().nth(EXCERPT_CHARS) {
140        Some((cut, _)) => format!("{}…", &text[..cut]),
141        None => text.to_owned(),
142    }
143}
144
145impl fmt::Display for FieldError {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        if self.location.is_empty() {
148            f.write_str(&self.message)
149        } else {
150            write!(f, "{}: {}", self.location.join("."), self.message)
151        }
152    }
153}
154
155impl fmt::Display for ApiError {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(f, "Jev answered HTTP {}", self.status)?;
158        if let Some(t) = &self.error_type {
159            write!(f, " ({t})")?;
160        }
161        write!(f, ": {}", self.message)?;
162        if let Some(id) = &self.request_id {
163            write!(f, " [request {id}]")?;
164        }
165        Ok(())
166    }
167}
168
169impl std::error::Error for ApiError {}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn none() -> http::HeaderMap {
176        http::HeaderMap::new()
177    }
178
179    fn id(id: &str) -> http::HeaderMap {
180        let mut h = http::HeaderMap::new();
181        h.insert(crate::REQUEST_ID_HEADER, http::HeaderValue::from_str(id).unwrap());
182        h
183    }
184
185    #[test]
186    fn documented_body() {
187        let e = ApiError::from_response(403, &id("req-1"), br#"{"detail":{"error_type":"forbidden","message":"no key"}}"#);
188        assert_eq!(e.kind, ApiErrorKind::Authentication);
189        assert_eq!(e.to_string(), "Jev answered HTTP 403 (forbidden): no key [request req-1]");
190    }
191
192    #[test]
193    fn out_of_credit_is_told_apart_from_a_bad_key() {
194        // No vendor-documented status for this at all (digest §10): 402 is a
195        // guess, and a 403 whose body talks about credit is the other guess.
196        let paid = ApiError::from_response(402, &none(), b"no credit remaining");
197        assert_eq!(paid.kind, ApiErrorKind::OutOfCredit);
198        let spent = ApiError::from_response(403, &none(), b"insufficient balance for this request");
199        assert_eq!(spent.kind, ApiErrorKind::OutOfCredit);
200        let key = ApiError::from_response(403, &none(), b"invalid api key");
201        assert_eq!(key.kind, ApiErrorKind::Authentication);
202        assert!(!crate::retry::retryable(402));
203    }
204
205    #[test]
206    fn context_overflow_is_its_own_kind() {
207        let e = ApiError::from_response(400, &none(), br#"{"detail":{"error_type":"max_tokens_exceeded","message":"too long"}}"#);
208        assert_eq!(e.kind, ApiErrorKind::ContextOverflow);
209    }
210
211    #[test]
212    fn validation_list() {
213        let e = ApiError::from_response(
214            422,
215            &none(),
216            br#"{"detail":[{"loc":["body","questions","q","criteria"],"msg":"too many options","type":"value_error"}]}"#,
217        );
218        assert_eq!(e.field_errors.len(), 1);
219        assert_eq!(e.message, "body.questions.q.criteria: too many options");
220    }
221
222    #[test]
223    fn anything_else_is_quoted() {
224        assert_eq!(ApiError::from_response(502, &none(), b"bad gateway").to_string(), "Jev answered HTTP 502: bad gateway");
225        assert_eq!(ApiError::from_response(502, &none(), b"").message, "(empty body)");
226        let long = "x".repeat(600);
227        assert_eq!(ApiError::from_response(500, &none(), long.as_bytes()).message.chars().count(), 501);
228    }
229}