use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde::Serialize;
use serde_json::Value;
use std::fmt;
const MAX_DETAILS_BYTES: usize = 4096;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("The Commit API URL `{url}` cannot be used: {reason}")]
InvalidUrl {
url: String,
reason: &'static str,
},
#[error("{0}")]
Invalid(String),
#[error("Could not reach the Commit API at {base}: {cause}")]
Transport {
base: String,
cause: String,
#[source]
source: reqwest::Error,
},
#[error("{0}")]
Api(Box<ApiError>),
#[error(
"The Commit API answered with contract {served}, but this client speaks contract 2 (Silicon Accounts sign-in). Update silicon-commit-client or the commit CLI."
)]
UnsupportedContract {
served: String,
},
#[error("The Commit API response exceeds the 8 MiB limit, so it was not read.")]
ResponseTooLarge,
#[error("The Commit API response is not the JSON this client expects: {0}")]
Decode(#[source] serde_json::Error),
#[error("{0}")]
Accounts(#[from] silicon_accounts_client::Error),
}
impl Error {
pub fn code(&self) -> &str {
match self {
Self::InvalidUrl { .. } => "invalid_url",
Self::Invalid(_) => "invalid_input",
Self::Transport { .. } => "connection_failed",
Self::Api(api) => &api.code,
Self::UnsupportedContract { .. } => "unsupported_contract",
Self::ResponseTooLarge => "response_too_large",
Self::Decode(_) => "unexpected_response",
Self::Accounts(error) => error.code(),
}
}
pub fn status(&self) -> Option<u16> {
match self {
Self::Api(api) => Some(api.status),
Self::Accounts(error) => error.status(),
_ => None,
}
}
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api(api) => api.request_id.as_deref(),
Self::Accounts(error) => error.request_id(),
_ => None,
}
}
pub fn hint(&self) -> Option<String> {
match self {
Self::Api(api) => api.hint.clone(),
Self::Accounts(error) => error.hint(),
_ => None,
}
}
pub fn as_api(&self) -> Option<&ApiError> {
match self {
Self::Api(api) => Some(api),
_ => None,
}
}
pub fn is_unauthenticated(&self) -> bool {
matches!(self, Self::Api(api) if api.status == 401)
}
pub fn is_sign_in_refused(&self) -> bool {
matches!(self, Self::Accounts(error) if error.code() == "invalid_grant")
}
pub fn is_transient(&self) -> bool {
match self {
Self::Transport { .. } => true,
Self::Api(api) => api.status >= 500 || api.status == 429,
Self::Accounts(error) => {
error.is_transport() || error.status().is_some_and(|s| s >= 500 || s == 429)
}
_ => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub struct ApiError {
pub status: u16,
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_seconds: Option<u64>,
}
impl ApiError {
pub fn new(status: u16, code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status,
code: code.into(),
message: message.into(),
hint: None,
details: None,
request_id: None,
retry_after_seconds: None,
}
}
pub(crate) fn from_response(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> Self {
let header = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
};
let envelope = serde_json::from_slice::<Value>(body)
.ok()
.and_then(|v| v.get("error").cloned())
.filter(Value::is_object);
let text = |key: &str| {
envelope
.as_ref()
.and_then(|e| e.get(key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
};
let code = text("code")
.filter(|c| c.len() <= 128 && c.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'))
.unwrap_or_else(|| format!("http_{}", status.as_u16()));
let reason = status.canonical_reason().unwrap_or("error");
let message = text("message").unwrap_or_else(|| {
if envelope.is_some() {
format!("The Commit API answered HTTP {} {reason} ({code}).", status.as_u16())
} else {
format!(
"The Commit API answered HTTP {} {reason} without its JSON error body; a proxy answered, or the URL does not point at the Commit API.",
status.as_u16()
)
}
});
let details = envelope
.as_ref()
.and_then(|e| e.get("details"))
.filter(|d| !d.is_null())
.filter(|d| serde_json::to_vec(d).is_ok_and(|b| b.len() <= MAX_DETAILS_BYTES))
.cloned();
Self {
status: status.as_u16(),
code,
message,
hint: text("hint"),
details,
request_id: header("x-request-id").or_else(|| text("request_id")),
retry_after_seconds: header("retry-after").and_then(|v| v.parse().ok()),
}
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (HTTP {} {}", self.message, self.status, self.code)?;
if let Some(id) = &self.request_id {
write!(f, ", request ID {id}")?;
}
f.write_str(")")?;
if let Some(details) = &self.details {
write!(f, " Details: {details}")?;
}
if let Some(seconds) = self.retry_after_seconds {
write!(f, " Retry after {seconds} s.")?;
}
if let Some(hint) = &self.hint {
write!(f, " Hint: {hint}")?;
}
Ok(())
}
}
impl From<ApiError> for Error {
fn from(value: ApiError) -> Self {
Self::Api(Box::new(value))
}
}
pub(crate) fn root_cause(error: &(dyn std::error::Error + 'static)) -> String {
let mut current = error;
while let Some(next) = current.source() {
current = next;
}
current.to_string()
}