use std::collections::BTreeMap;
use std::fmt;
use std::time::Duration;
use reqwest::header::CONTENT_TYPE;
use reqwest::{Response, StatusCode};
use serde::Deserialize;
use crate::response::{read_body, request_id};
use crate::retry::parse_retry_after;
const MAX_ERROR_SIZE: usize = 64 << 10;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
InvalidInput(String),
Api(Box<ApiError>),
Http(reqwest::Error),
Timeout,
Decode(serde_json::Error),
Protocol(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(message) | Self::Protocol(message) => f.write_str(message),
Self::Api(err) => err.fmt(f),
Self::Http(_) => f.write_str("http request failed"),
Self::Timeout => f.write_str("operation timed out"),
Self::Decode(_) => f.write_str("failed to decode response"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Http(err) => Some(err),
Self::Decode(err) => Some(err),
_ => None,
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Self::Http(err.without_url())
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::Decode(err)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ApiError {
pub status: StatusCode,
pub problem_type: Option<String>,
pub title: Option<String>,
pub detail: Option<String>,
pub errors: BTreeMap<String, Vec<String>>,
pub request_id: Option<String>,
pub retry_after: Option<Duration>,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "api returned {}", self.status)
}
}
impl std::error::Error for ApiError {}
#[derive(Default, Deserialize)]
pub(crate) struct Problem {
#[serde(rename = "type")]
problem_type: Option<String>,
title: Option<String>,
detail: Option<String>,
instance: Option<String>,
errors: Option<serde_json::Value>,
}
pub(crate) async fn api_error(mut response: Response) -> Error {
let status = response.status();
let headers = response.headers();
let request_id = request_id(headers);
let retry_after = parse_retry_after(headers);
let is_text = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.trim_start().starts_with("text/plain"));
let mut body = Vec::new();
let _ = read_body(&mut response, MAX_ERROR_SIZE, &mut body).await;
let problem = serde_json::from_slice(&body).unwrap_or_else(|_| Problem {
detail: is_text.then(|| String::from_utf8_lossy(&body).trim().to_owned()),
..Problem::default()
});
Error::Api(Box::new(ApiError {
status,
problem_type: problem.problem_type,
title: problem.title,
detail: problem.detail,
errors: problem
.errors
.and_then(|errors| serde_json::from_value(errors).ok())
.unwrap_or_default(),
request_id: request_id.or(problem.instance),
retry_after,
}))
}
pub(crate) async fn require_ok(response: Response) -> Result<Response> {
if response.status() != StatusCode::OK {
return Err(api_error(response).await);
}
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_omits_server_details() {
let err = Error::Api(Box::new(ApiError {
status: StatusCode::FORBIDDEN,
problem_type: Some("https://docs.synthient.com/errors/forbidden".into()),
title: Some("Forbidden".into()),
detail: Some("secret detail".into()),
errors: BTreeMap::new(),
request_id: Some("abc".into()),
retry_after: None,
}));
assert_eq!(err.to_string(), "api returned 403 Forbidden");
}
#[test]
fn decode_errors_keep_their_source() {
let source = serde_json::from_str::<u8>("x").unwrap_err();
let err = Error::from(source);
assert_eq!(err.to_string(), "failed to decode response");
assert!(std::error::Error::source(&err).is_some());
}
}