1use std::fmt; 2 3use serde_json::Value; 4 5/// 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 { 9 /// The request would break an API rule; nothing was sent. 10 Invalid(String), 11 /// The response does not answer the questions asked. 12 Response(String), 13 /// A pinned model was answered by another. 14 ModelMismatch { pinned: String, answered: String }, 15} 16 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 {} 30 31/// A non-200 response. Bodies come as `{"detail": {"error_type", 32/// "message"}}` (measured 2026-09-22), as a list of field errors on 33/// 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>, 41 /// The only handle for a billing dispute; sent even on auth errors. 42 pub request_id: Option<String>, 43} 44 45#[derive(Clone, Copy, Debug, PartialEq, Eq)] 46#[non_exhaustive] 47pub enum ApiErrorKind { 48 BadRequest, 49 /// A missing key gets 403 (measured 2026-09-22); an invalid one 401 50 /// `authentication_error` (measured 2026-09-23). 51 Authentication, 52 /// The account has no credit left. Inferred, not documented: the 53 /// vendor names no out-of-credit response, so a 402, or a 403 whose 54 /// message speaks of credit, balance or quota, is read as this (as 55 /// jevsnes' `Failure::of` did; digest §10). Never retried: waiting does 56 /// not top up an account. 57 OutOfCredit, 58 NotFound, 59 RequestTimeout, 60 /// A 400 whose `error_type` is `max_tokens_exceeded`: the request 61 /// is over the context limit and must be split, not retried. 62 ContextOverflow, 63 Unprocessable, 64 RateLimited, 65 Overloaded, 66 Server, 67 Other, 68} 69 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}