use crate::types::GatewayErrorBody;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SolariError {
#[error("{message}")]
Auth { status: u16, message: String, code: Option<String> },
#[error("{message}")]
Plan { status: u16, message: String, code: Option<String> },
#[error("{message}")]
ConcurrencyLimit { status: u16, message: String, code: Option<String> },
#[error("{message}")]
NoCapacity { status: u16, message: String, code: Option<String> },
#[error("{message}")]
Gateway { status: u16, message: String, code: Option<String> },
#[error("{message}")]
Action { method: String, message: String, code: Option<String> },
#[error("Action \"{method}\" timed out after {timeout_ms}ms")]
Timeout { method: String, timeout_ms: u64 },
#[error("{message}")]
Connection { message: String },
#[error("{message}")]
Git { message: String },
#[error("{0}")]
Other(String),
#[error("{message}")]
Validation { message: String },
}
impl SolariError {
pub(crate) fn connection(msg: impl Into<String>) -> Self {
SolariError::Connection { message: msg.into() }
}
pub fn status(&self) -> Option<u16> {
match self {
SolariError::Auth { status, .. }
| SolariError::Plan { status, .. }
| SolariError::ConcurrencyLimit { status, .. }
| SolariError::NoCapacity { status, .. }
| SolariError::Gateway { status, .. } => Some(*status),
_ => None,
}
}
}
pub(crate) fn map_gateway_error(status: u16, body: Option<GatewayErrorBody>) -> SolariError {
let code = body.as_ref().and_then(|b| b.code.clone());
let message = body
.as_ref()
.and_then(|b| b.message.clone().or_else(|| b.error.clone()).or_else(|| b.code.clone()))
.unwrap_or_else(|| default_gateway_message(status));
match (status, code.as_deref()) {
(401, _) => SolariError::Auth { status, message, code },
(402, _) => SolariError::Plan { status, message, code },
(403, Some("NotEntitled")) => SolariError::Plan { status, message, code },
(429, None | Some("ConcurrencyLimitExceeded")) => SolariError::ConcurrencyLimit { status, message, code },
(503, None | Some("NoCapacity" | "StartTimeout")) => SolariError::NoCapacity { status, message, code },
_ => SolariError::Gateway { status, message, code },
}
}
pub(crate) fn is_route_unavailable(err: &SolariError) -> bool {
match err {
SolariError::Gateway { status: 404, message, code: None } => *message == default_gateway_message(404),
_ => matches!(err.status(), Some(405) | Some(501)),
}
}
fn default_gateway_message(status: u16) -> String {
format!("Gateway request failed with status {status}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_unavailable_is_not_a_json_404() {
let plain_404 = map_gateway_error(404, None);
let json_404 = map_gateway_error(
404,
Some(GatewayErrorBody { error: Some("Not found".into()), ..Default::default() }),
);
assert!(is_route_unavailable(&plain_404), "a plain-text 404 means no such route");
assert!(!is_route_unavailable(&json_404), "a JSON 404 means the sandbox is gone");
assert!(is_route_unavailable(&map_gateway_error(405, None)));
assert!(is_route_unavailable(&map_gateway_error(501, None)));
assert!(!is_route_unavailable(&map_gateway_error(500, None)));
assert!(!is_route_unavailable(&SolariError::Other("x".into())));
}
#[derive(Debug, PartialEq)]
enum Kind {
Auth,
Plan,
Concurrency,
NoCapacity,
Plain,
}
fn kind(e: &SolariError) -> Kind {
match e {
SolariError::Auth { .. } => Kind::Auth,
SolariError::Plan { .. } => Kind::Plan,
SolariError::ConcurrencyLimit { .. } => Kind::Concurrency,
SolariError::NoCapacity { .. } => Kind::NoCapacity,
SolariError::Gateway { .. } => Kind::Plain,
other => panic!("not a gateway error: {other:?}"),
}
}
#[test]
fn maps_by_code_first() {
let cases: &[(&str, u16, Option<&str>, Kind)] = &[
("401", 401, Some("Unauthorized"), Kind::Auth),
("402-plan", 402, Some("FeatureRequiresPlan"), Kind::Plan),
("402-credit", 402, Some("InsufficientCredit"), Kind::Plan),
("403-not-entitled", 403, Some("NotEntitled"), Kind::Plan),
("403-other", 403, Some("Forbidden"), Kind::Plain),
("404", 404, Some("NotFound"), Kind::Plain),
("409-sandbox-stopped", 409, Some("SandboxStopped"), Kind::Plain),
("410-gone", 410, Some("SandboxGone"), Kind::Plain),
("429-concurrency-cap", 429, Some("ConcurrencyLimitExceeded"), Kind::Concurrency),
("429-no-code", 429, None, Kind::Concurrency),
("429-too-many-executions", 429, Some("TooManyExecutions"), Kind::Plain),
("429-object-limit", 429, Some("ObjectLimit"), Kind::Plain),
("500-internal", 500, Some("InternalError"), Kind::Plain),
("501-not-supported", 501, Some("NotSupported"), Kind::Plain),
("503-no-capacity", 503, Some("NoCapacity"), Kind::NoCapacity),
("503-start-timeout", 503, Some("StartTimeout"), Kind::NoCapacity),
("503-no-code", 503, None, Kind::NoCapacity),
("503-sandbox-unavailable", 503, Some("SandboxUnavailable"), Kind::Plain),
("503-store-unavailable", 503, Some("StoreUnavailable"), Kind::Plain),
("503-admission-unavailable", 503, Some("AdmissionUnavailable"), Kind::Plain),
("503-dependency-unavailable", 503, Some("DependencyUnavailable"), Kind::Plain),
("507-insufficient-capacity", 507, Some("InsufficientCapacity"), Kind::Plain),
];
for (id, status, code, want) in cases {
let body = GatewayErrorBody { code: code.map(str::to_string), ..Default::default() };
let e = map_gateway_error(*status, Some(body));
assert_eq!(&kind(&e), want, "errors/{id}");
assert_eq!(e.status(), Some(*status), "errors/{id}: the status the gateway sent is kept");
let kept = match &e {
SolariError::Auth { code, .. }
| SolariError::Plan { code, .. }
| SolariError::ConcurrencyLimit { code, .. }
| SolariError::NoCapacity { code, .. }
| SolariError::Gateway { code, .. } => code.clone(),
_ => unreachable!(),
};
assert_eq!(kept.as_deref(), *code, "errors/{id}: the gateway's code is kept");
}
}
}