use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerError {
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documentation_url: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("{}", format_status(*status, url.as_deref(), body.as_ref()))]
Status {
status: u16,
url: Option<String>,
lfs_authenticate: Option<String>,
body: Option<ServerError>,
retry_after: Option<Duration>,
},
#[error("malformed response body: {0}")]
Decode(String),
#[error("url error: {0}")]
Url(#[from] url::ParseError),
#[error("Git credentials for {url} not found{}", detail.as_deref().map(|d| format!(":\n{d}")).unwrap_or_else(|| ".".into()))]
CredentialsNotFound { url: String, detail: Option<String> },
}
fn format_status(status: u16, url: Option<&str>, body: Option<&ServerError>) -> String {
if let Some(b) = body
&& !b.message.is_empty()
{
return b.message.clone();
}
if matches!(status, 401 | 403)
&& let Some(u) = url
{
return format!("Authorization error: {u}");
}
format!("server returned status {status}")
}
impl ApiError {
pub fn is_unauthorized(&self) -> bool {
matches!(self, ApiError::Status { status: 401, .. })
}
pub fn is_forbidden(&self) -> bool {
matches!(self, ApiError::Status { status: 403, .. })
}
pub fn is_not_found(&self) -> bool {
matches!(self, ApiError::Status { status: 404, .. })
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
ApiError::Transport(_)
| ApiError::Status {
status: 408 | 429 | 500..=599,
..
}
)
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
ApiError::Status { retry_after, .. } => *retry_after,
_ => None,
}
}
}
pub fn parse_retry_after(value: &str) -> Option<Duration> {
let trimmed = value.trim();
trimmed.parse::<u64>().ok().map(Duration::from_secs)
}