use std::fmt;
use std::time::Duration;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde_json::Value;
use crate::retry::parse_retry_after;
pub const REQUEST_ID_HEADER: &str = "x-typesafe-request-id";
const MAX_RAW_BODY_IN_MESSAGE: usize = 200;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("{0}")]
Config(String),
#[error("{0}")]
InvalidRequest(String),
#[error(transparent)]
Api(Box<ApiError>),
#[error("Connection error: {source}")]
Connection {
#[source]
source: reqwest::Error,
},
#[error("Request timed out after {}ms.", .timeout.as_millis())]
Timeout {
timeout: Duration,
},
#[error("{message}")]
Decode {
message: String,
request_id: Option<String>,
#[source]
source: Option<serde_json::Error>,
},
#[error("Could not get credentials: {source}")]
Credentials {
#[source]
source: crate::credentials::BoxError,
},
#[error("Unexpected answer \"{name}\": {reason}.")]
UnexpectedAnswer {
name: String,
reason: String,
},
}
impl Error {
pub fn status(&self) -> Option<StatusCode> {
self.api_error().map(ApiError::status)
}
pub fn request_id(&self) -> Option<&str> {
match self {
Error::Api(err) => err.request_id(),
Error::Decode { request_id, .. } => request_id.as_deref(),
_ => None,
}
}
pub fn api_error(&self) -> Option<&ApiError> {
match self {
Error::Api(err) => Some(err),
_ => None,
}
}
pub fn is_timeout(&self) -> bool {
matches!(self, Error::Timeout { .. })
}
pub fn is_connection(&self) -> bool {
matches!(self, Error::Connection { .. } | Error::Timeout { .. })
}
}
impl From<ApiError> for Error {
fn from(err: ApiError) -> Self {
Error::Api(Box::new(err))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ApiErrorKind {
BadRequest,
Authentication,
PermissionDenied,
NotFound,
UnprocessableEntity,
RateLimit,
Overloaded,
InternalServer,
Other,
}
impl ApiErrorKind {
pub fn from_status(status: StatusCode) -> Self {
match status.as_u16() {
400 => Self::BadRequest,
401 => Self::Authentication,
403 => Self::PermissionDenied,
404 => Self::NotFound,
422 => Self::UnprocessableEntity,
429 => Self::RateLimit,
529 => Self::Overloaded,
500.. => Self::InternalServer,
_ => Self::Other,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ErrorBody {
Empty,
Json(Value),
Text(String),
}
impl ErrorBody {
pub fn parse(bytes: &[u8]) -> Self {
if bytes.is_empty() {
return Self::Empty;
}
match serde_json::from_slice(bytes) {
Ok(value) => Self::Json(value),
Err(_) => Self::Text(String::from_utf8_lossy(bytes).into_owned()),
}
}
}
#[derive(Debug, Clone)]
pub struct ApiError {
kind: ApiErrorKind,
status: StatusCode,
headers: HeaderMap,
body: ErrorBody,
request_id: Option<String>,
message: String,
}
impl ApiError {
pub fn from_response(status: StatusCode, headers: HeaderMap, body: &[u8]) -> Self {
let body = ErrorBody::parse(body);
let message = describe(status, &body);
Self {
kind: ApiErrorKind::from_status(status),
request_id: request_id_from(&headers),
status,
headers,
body,
message,
}
}
pub fn kind(&self) -> ApiErrorKind {
self.kind
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn body(&self) -> &ErrorBody {
&self.body
}
pub fn request_id(&self) -> Option<&str> {
self.request_id.as_deref()
}
pub fn retry_after(&self) -> Option<Duration> {
parse_retry_after(&self.headers, std::time::SystemTime::now())
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ApiError {}
pub(crate) fn request_id_from(headers: &HeaderMap) -> Option<String> {
[REQUEST_ID_HEADER, "cf-ray", "x-request-id", "x-vercel-id"]
.into_iter()
.find_map(|name| headers.get(name))
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
fn describe(status: StatusCode, body: &ErrorBody) -> String {
let status = status.as_u16();
if let Some(detail) = extract_message(body).filter(|detail| !detail.is_empty()) {
return format!("{status} {detail}");
}
let raw = match body {
ErrorBody::Empty => return format!("{status} status code (no body)"),
ErrorBody::Text(text) | ErrorBody::Json(Value::String(text)) => text.clone(),
ErrorBody::Json(value) => value.to_string(),
};
match raw.char_indices().nth(MAX_RAW_BODY_IN_MESSAGE) {
Some((cut, _)) => format!("{status} {}…", &raw[..cut]),
None => format!("{status} {raw}"),
}
}
fn extract_message(body: &ErrorBody) -> Option<String> {
let value = match body {
ErrorBody::Empty => return None,
ErrorBody::Text(text) => return non_empty(text),
ErrorBody::Json(value) => value,
};
if let Value::String(text) = value {
return non_empty(text);
}
let object = value.as_object()?;
let error = object.get("error");
let detail = object.get("detail");
if let Some(Value::String(text)) = error {
return Some(text.clone());
}
if let Some(text) = error.and_then(Value::as_object).and_then(nested_message) {
return Some(text);
}
if let Some(Value::String(text)) = object.get("message") {
return Some(text.clone());
}
match detail {
Some(Value::String(text)) => Some(text.clone()),
Some(Value::Object(inner)) => nested_message(inner),
Some(Value::Array(errors)) => describe_validation_errors(errors),
_ => None,
}
}
fn nested_message(object: &serde_json::Map<String, Value>) -> Option<String> {
["message", "error_type"]
.into_iter()
.find_map(|key| object.get(key).and_then(Value::as_str))
.and_then(non_empty)
}
fn non_empty(text: &str) -> Option<String> {
(!text.is_empty()).then(|| text.to_owned())
}
fn describe_validation_errors(errors: &[Value]) -> Option<String> {
let parts: Vec<String> = errors
.iter()
.filter_map(|error| {
let msg = error.get("msg")?.as_str()?;
let loc = error
.get("loc")
.and_then(Value::as_array)
.map(|segments| {
segments
.iter()
.filter(|segment| segment.as_str() != Some("body"))
.map(loc_segment)
.collect::<Vec<_>>()
.join(".")
})
.unwrap_or_default();
Some(if loc.is_empty() {
msg.to_owned()
} else {
format!("{loc}: {msg}")
})
})
.collect();
(!parts.is_empty()).then(|| parts.join("; "))
}
fn loc_segment(segment: &Value) -> String {
match segment {
Value::String(text) => text.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderValue;
use serde_json::json;
fn message(status: u16, body: &[u8]) -> String {
ApiError::from_response(
StatusCode::from_u16(status).unwrap(),
HeaderMap::new(),
body,
)
.to_string()
}
#[test]
fn classifies_statuses() {
let kind = |s| ApiErrorKind::from_status(StatusCode::from_u16(s).unwrap());
assert_eq!(kind(400), ApiErrorKind::BadRequest);
assert_eq!(kind(401), ApiErrorKind::Authentication);
assert_eq!(kind(403), ApiErrorKind::PermissionDenied);
assert_eq!(kind(404), ApiErrorKind::NotFound);
assert_eq!(kind(422), ApiErrorKind::UnprocessableEntity);
assert_eq!(kind(429), ApiErrorKind::RateLimit);
assert_eq!(kind(500), ApiErrorKind::InternalServer);
assert_eq!(kind(529), ApiErrorKind::Overloaded);
assert_eq!(kind(599), ApiErrorKind::InternalServer);
assert_eq!(kind(409), ApiErrorKind::Other);
assert_eq!(kind(302), ApiErrorKind::Other);
}
#[test]
fn extracts_messages_from_common_shapes() {
assert_eq!(message(400, br#"{"error":"bad"}"#), "400 bad");
assert_eq!(
message(400, br#"{"error":{"message":"nested"}}"#),
"400 nested"
);
assert_eq!(message(400, br#"{"message":"plain"}"#), "400 plain");
assert_eq!(message(400, br#"{"detail":"why"}"#), "400 why");
assert_eq!(
message(400, br#"{"detail":{"message":"deep"}}"#),
"400 deep"
);
assert_eq!(message(400, br#""quoted""#), "400 quoted");
assert_eq!(message(502, b"Bad Gateway"), "502 Bad Gateway");
}
#[test]
fn error_type_is_used_when_there_is_no_message() {
assert_eq!(
message(400, br#"{"detail":{"error_type":"max_tokens_exceeded"}}"#),
"400 max_tokens_exceeded"
);
assert_eq!(
message(400, br#"{"error":{"error_type":"rate_limited"}}"#),
"400 rate_limited"
);
assert_eq!(
message(
400,
br#"{"detail":{"error_type":"bad","message":"be specific"}}"#
),
"400 be specific"
);
}
#[test]
fn prefers_error_over_message_and_detail() {
assert_eq!(
message(400, br#"{"detail":"d","message":"m","error":"e"}"#),
"400 e"
);
assert_eq!(message(400, br#"{"detail":"d","message":"m"}"#), "400 m");
}
#[test]
fn formats_validation_errors_without_the_body_prefix() {
let body = json!({"detail": [
{"loc": ["body", "questions", "x", 0], "msg": "too short"},
{"loc": [], "msg": "bad state"},
{"msg": 3},
]});
assert_eq!(
message(422, body.to_string().as_bytes()),
"422 questions.x.0: too short; bad state"
);
}
#[test]
fn falls_back_to_raw_body_or_no_body() {
assert_eq!(message(500, b""), "500 status code (no body)");
assert_eq!(message(500, br#"{"other":1}"#), r#"500 {"other":1}"#);
assert_eq!(message(500, br#"{"detail":[]}"#), r#"500 {"detail":[]}"#);
}
#[test]
fn empty_extracted_messages_fall_back_to_the_raw_body() {
assert_eq!(
message(400, br#"{"error":"","message":"m"}"#),
r#"400 {"error":"","message":"m"}"#
);
assert_eq!(message(400, br#"{"message":""}"#), r#"400 {"message":""}"#);
assert_eq!(
message(400, br#"{"error":{"message":""}}"#),
r#"400 {"error":{"message":""}}"#
);
assert_eq!(message(400, br#"{"detail":""}"#), r#"400 {"detail":""}"#);
}
#[test]
fn json_string_bodies_are_not_quoted() {
assert_eq!(message(400, br#""""#), "400 ");
}
#[test]
fn truncates_long_json_bodies_on_char_boundaries() {
let body = json!({ "x": "é".repeat(250) }).to_string();
let expected: String = body.chars().take(200).collect();
assert_eq!(message(500, body.as_bytes()), format!("500 {expected}…"));
let exact = json!({ "x": "a".repeat(192) }).to_string();
assert_eq!(exact.chars().count(), 200);
assert_eq!(message(500, exact.as_bytes()), format!("500 {exact}"));
}
#[test]
fn text_bodies_are_the_message_in_full() {
let long = "é".repeat(250);
assert_eq!(message(502, long.as_bytes()), format!("502 {long}"));
}
#[test]
fn exposes_request_id_and_retry_after() {
let mut headers = HeaderMap::new();
headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req_1"));
headers.insert("retry-after", HeaderValue::from_static("7"));
let err = ApiError::from_response(StatusCode::TOO_MANY_REQUESTS, headers, b"{}");
assert_eq!(err.request_id(), Some("req_1"));
assert_eq!(err.retry_after(), Some(Duration::from_secs(7)));
assert_eq!(err.kind(), ApiErrorKind::RateLimit);
let wrapped = Error::from(err);
assert_eq!(wrapped.request_id(), Some("req_1"));
assert_eq!(wrapped.status(), Some(StatusCode::TOO_MANY_REQUESTS));
assert!(!wrapped.is_connection());
}
#[test]
fn timeouts_are_connection_errors() {
let err = Error::Timeout {
timeout: Duration::from_millis(1000),
};
assert!(err.is_timeout());
assert!(err.is_connection());
assert_eq!(err.to_string(), "Request timed out after 1000ms.");
}
}