use std::fmt;
use reqwest::StatusCode;
use serde::Deserialize;
#[derive(Debug)]
pub enum Error {
Http(reqwest::Error),
Api(ApiError),
RateLimit(RateLimitError),
Serialization(serde_json::Error),
}
#[derive(Debug, Clone)]
pub struct ApiError {
pub status_code: StatusCode,
pub message: String,
pub documentation_url: String,
pub request_id: String,
}
#[derive(Debug, Clone)]
pub struct RateLimitError {
pub api_error: ApiError,
pub limit: i64,
pub remaining: i64,
pub reset: i64,
pub retry_after: i64,
}
#[derive(Deserialize)]
struct ApiErrorResponse {
#[serde(default)]
message: String,
#[serde(default)]
documentation_url: String,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Http(e) => write!(f, "HTTP error: {e}"),
Error::Api(e) => write!(f, "{e}"),
Error::RateLimit(e) => write!(f, "{e}"),
Error::Serialization(e) => write!(f, "serialization error: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Http(e) => Some(e),
Error::Serialization(e) => Some(e),
_ => None,
}
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.message.is_empty() {
write!(f, "GitVerse API error (status {})", self.status_code)
} else {
write!(
f,
"GitVerse API error (status {}): {}",
self.status_code, self.message
)
}
}
}
impl fmt::Display for RateLimitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"rate limit exceeded: retry after {} seconds",
self.retry_after
)
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Error::Http(err)
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Error::Serialization(err)
}
}
pub fn is_not_found(err: &Error) -> bool {
matches!(err, Error::Api(e) if e.status_code == StatusCode::NOT_FOUND)
}
pub fn is_rate_limit_error(err: &Error) -> bool {
matches!(err, Error::RateLimit(_))
}
pub fn is_unauthorized(err: &Error) -> bool {
matches!(err, Error::Api(e) if e.status_code == StatusCode::UNAUTHORIZED)
}
pub fn is_forbidden(err: &Error) -> bool {
matches!(err, Error::Api(e) if e.status_code == StatusCode::FORBIDDEN)
}
pub(crate) async fn parse_api_error(resp: reqwest::Response) -> Error {
let status_code = resp.status();
let headers = resp.headers().clone();
let request_id = headers
.get("X-Request-Id")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = match resp.text().await {
Ok(text) => text,
Err(e) => format!("(failed to read response body: {e})"),
};
let (message, documentation_url) = match serde_json::from_str::<ApiErrorResponse>(&body) {
Ok(err_resp) => (err_resp.message, err_resp.documentation_url),
Err(_) => (body, String::new()),
};
let api_error = ApiError {
status_code,
message,
documentation_url,
request_id,
};
if status_code == StatusCode::TOO_MANY_REQUESTS {
let limit = headers
.get("GitVerse-RateLimit-Limit")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let remaining = headers
.get("GitVerse-RateLimit-User-Remaining")
.or_else(|| headers.get("GitVerse-RateLimit-Remaining"))
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let reset = headers
.get("Gitverse-Ratelimit-Reset")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let retry_after = headers
.get("GitVerse-RateLimit-Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(60);
return Error::RateLimit(RateLimitError {
api_error,
limit,
remaining,
reset,
retry_after,
});
}
Error::Api(api_error)
}