use axum::Json;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use crate::http::logging;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ApiError {
Held {
eligible_at_micros: i64,
retry_after_seconds: u64,
},
Blocked {
reason: &'static str,
},
NotFound,
InvalidInput(&'static str),
MethodNotAllowed,
NotAcceptable,
PolicyUnavailable,
Overloaded,
CapacityExhausted,
StorageUnusable,
InternalFailure,
UpstreamFailure,
UpstreamInvalid,
IntegrityMismatch,
UpstreamTimeout,
}
impl ApiError {
pub fn held(eligible_at_micros: i64, now_utc_micros: i64) -> ApiError {
let remaining = eligible_at_micros.saturating_sub(now_utc_micros).max(0);
let retry_after_seconds = (remaining as u64).div_ceil(1_000_000).max(1);
ApiError::Held {
eligible_at_micros,
retry_after_seconds,
}
}
pub fn status(&self) -> StatusCode {
match self {
ApiError::Held { .. } | ApiError::Blocked { .. } => StatusCode::FORBIDDEN,
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::InvalidInput(_) => StatusCode::BAD_REQUEST,
ApiError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
ApiError::NotAcceptable => StatusCode::NOT_ACCEPTABLE,
ApiError::PolicyUnavailable
| ApiError::Overloaded
| ApiError::CapacityExhausted
| ApiError::StorageUnusable
| ApiError::InternalFailure => StatusCode::SERVICE_UNAVAILABLE,
ApiError::UpstreamFailure | ApiError::UpstreamInvalid | ApiError::IntegrityMismatch => {
StatusCode::BAD_GATEWAY
}
ApiError::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT,
}
}
pub fn error_code(&self) -> &'static str {
match self {
ApiError::Held { .. } => "HELD",
ApiError::Blocked { .. } => "BLOCKED",
ApiError::NotFound => "NOT_FOUND",
ApiError::InvalidInput(_) => "INVALID_INPUT",
ApiError::MethodNotAllowed => "METHOD_NOT_ALLOWED",
ApiError::NotAcceptable => "NOT_ACCEPTABLE",
ApiError::PolicyUnavailable => "POLICY_UNAVAILABLE",
ApiError::Overloaded => "OVERLOADED",
ApiError::CapacityExhausted => "CAPACITY_EXHAUSTED",
ApiError::StorageUnusable => "STORAGE_UNUSABLE",
ApiError::InternalFailure => "INTERNAL_FAILURE",
ApiError::UpstreamFailure => "UPSTREAM_FAILURE",
ApiError::UpstreamInvalid => "UPSTREAM_INVALID",
ApiError::IntegrityMismatch => "INTEGRITY_MISMATCH",
ApiError::UpstreamTimeout => "UPSTREAM_TIMEOUT",
}
}
pub fn reason(&self) -> String {
match self {
ApiError::Held { .. } => "the release has not completed its cooldown period".to_owned(),
ApiError::Blocked { reason } => (*reason).to_owned(),
ApiError::NotFound => "no such route or resource".to_owned(),
ApiError::InvalidInput(reason) => (*reason).to_owned(),
ApiError::MethodNotAllowed => {
"this route does not support that request method".to_owned()
}
ApiError::NotAcceptable => {
"no representation this server produces was accepted".to_owned()
}
ApiError::PolicyUnavailable => {
"no valid blocklist is loaded, so no package can be judged eligible".to_owned()
}
ApiError::Overloaded => "the service is at capacity".to_owned(),
ApiError::CapacityExhausted => "local storage could not take the artifact".to_owned(),
ApiError::StorageUnusable => {
"local storage is unusable, so no decision can be committed".to_owned()
}
ApiError::InternalFailure => {
"the request could not be completed; a later attempt may succeed".to_owned()
}
ApiError::UpstreamFailure => "the upstream registry could not be reached".to_owned(),
ApiError::UpstreamInvalid => {
"the upstream registry returned an unusable document".to_owned()
}
ApiError::IntegrityMismatch => {
"the artifact does not match its expected digests".to_owned()
}
ApiError::UpstreamTimeout => "the upstream registry did not answer in time".to_owned(),
}
}
fn eligible_at(&self) -> Option<String> {
match self {
ApiError::Held {
eligible_at_micros, ..
} => jiff::Timestamp::from_microsecond(*eligible_at_micros)
.ok()
.map(|timestamp| timestamp.to_string()),
_ => None,
}
}
}
#[derive(Serialize)]
pub struct ErrorBody {
pub error: &'static str,
pub reason: String,
pub request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub eligible_at: Option<String>,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = ErrorBody {
error: self.error_code(),
reason: self.reason(),
request_id: logging::request_id(),
eligible_at: self.eligible_at(),
};
let mut response = (self.status(), Json(body)).into_response();
if let ApiError::Held {
retry_after_seconds,
..
} = self
&& let Ok(value) = HeaderValue::from_str(&retry_after_seconds.to_string())
{
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response.extensions_mut().insert(self);
response
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hold_carries_a_deadline_and_a_whole_number_of_seconds() {
let now = 1_000_000_000i64;
let held = ApiError::held(now + 1_500_000, now);
assert_eq!(
held,
ApiError::Held {
eligible_at_micros: now + 1_500_000,
retry_after_seconds: 2,
},
"a part second rounds up, so a client that obeys it does not come back early"
);
assert_eq!(held.status(), StatusCode::FORBIDDEN);
assert!(held.eligible_at().is_some());
assert_eq!(
ApiError::held(now, now),
ApiError::Held {
eligible_at_micros: now,
retry_after_seconds: 1,
},
"never zero: a Retry-After of 0 invites a client to spin"
);
}
#[test]
fn only_a_hold_carries_eligible_at() {
assert!(
ApiError::Blocked {
reason: "the package is blocked"
}
.eligible_at()
.is_none(),
"a block has no deadline, so it must not look like one"
);
assert!(ApiError::NotFound.eligible_at().is_none());
}
}