use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Credential {
None,
Basic,
Token,
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("{}", unauthorized_message(*.presented))]
Unauthorized { presented: Credential },
#[error("{message}")]
Forbidden { message: String },
#[error("no {kind} {name:?}")]
NotFound { kind: &'static str, name: String },
#[error("{message}")]
Conflict { message: String },
#[error("deployment {deployment:?} has no running VM (retry with wake)")]
NoRunningVm { deployment: String },
#[error("deployment {deployment:?} had no VM ready within its cold-start timeout")]
ColdStartTimeout { deployment: String },
#[error("{message}")]
Upstream { message: String },
#[error("{message}")]
Api { status: u16, message: String },
#[error("unexpected HTTP {status}{}", if .body.is_empty() { String::new() } else { format!(": {}", .body) })]
Malformed { status: u16, body: String },
#[error("could not reach {0}")]
Transport(#[source] reqwest::Error),
#[error("could not read the response: {0}")]
Decode(#[source] serde_json::Error),
#[error("shell connection failed: {0}")]
Shell(String),
#[error("{what} did not finish within {}s", .after.as_secs())]
Timeout {
what: String,
after: std::time::Duration,
},
#[error("{0}")]
Invalid(String),
}
fn unauthorized_message(presented: Credential) -> String {
match presented {
Credential::None => {
"authentication required, and no credential was sent — supply a username and \
password, or an app-token"
}
Credential::Basic => "the username or password was not accepted",
Credential::Token => {
"the app-token was not accepted — it may be wrong, revoked or expired \
(app-lb does not say which)"
}
}
.to_string()
}
#[derive(serde::Deserialize)]
struct Envelope {
error: String,
}
impl Error {
pub fn from_response(
status: u16,
body: &str,
kind: &'static str,
name: &str,
presented: Credential,
) -> Self {
let message = serde_json::from_str::<Envelope>(body)
.map(|e| e.error)
.ok()
.filter(|m| !m.trim().is_empty())
.or_else(|| Some(body.trim().to_string()).filter(|b| !b.is_empty()));
match (status, message) {
(401, _) => Self::Unauthorized { presented },
(403, m) => Self::Forbidden {
message: m.unwrap_or_else(|| "forbidden".into()),
},
(404, Some(m)) => {
if m.starts_with("no ") {
Self::NotFound {
kind,
name: name.to_string(),
}
} else {
Self::Malformed { status, body: m }
}
}
(404, None) => Self::NotFound {
kind,
name: name.to_string(),
},
(409, Some(m)) if m.contains("no running VM") => Self::NoRunningVm {
deployment: name.to_string(),
},
(409, m) => Self::Conflict {
message: m.unwrap_or_else(|| "conflict".into()),
},
(503, _) => Self::ColdStartTimeout {
deployment: name.to_string(),
},
(502, m) => Self::Upstream {
message: m.unwrap_or_else(|| "the daemon did not answer".into()),
},
(400 | 415 | 422, m) if serde_json::from_str::<Envelope>(body).is_err() => {
Self::Malformed {
status,
body: m.unwrap_or_default(),
}
}
(s, Some(m)) => Self::Api { status: s, message: m },
(s, None) => Self::Malformed {
status: s,
body: String::new(),
},
}
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::ColdStartTimeout { .. } | Self::Upstream { .. } | Self::Transport(_)
)
}
pub fn is_auth(&self) -> bool {
matches!(self, Self::Unauthorized { .. } | Self::Forbidden { .. })
}
pub fn status(&self) -> Option<u16> {
Some(match self {
Self::Unauthorized { .. } => 401,
Self::Forbidden { .. } => 403,
Self::NotFound { .. } => 404,
Self::Conflict { .. } | Self::NoRunningVm { .. } => 409,
Self::ColdStartTimeout { .. } => 503,
Self::Upstream { .. } => 502,
Self::Api { status, .. } | Self::Malformed { status, .. } => *status,
_ => return None,
})
}
}
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
Self::Transport(e)
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::Decode(e)
}
}
impl fmt::Display for Credential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::None => "no credential",
Self::Basic => "a username and password",
Self::Token => "an app-token",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn from(status: u16, body: &str) -> Error {
Error::from_response(status, body, "deployment", "demo", Credential::Token)
}
#[test]
fn the_json_envelope_is_unwrapped() {
let e = from(400, r#"{"error":"command must not be empty"}"#);
assert!(matches!(e, Error::Api { status: 400, .. }));
assert_eq!(e.to_string(), "command must not be empty");
}
#[test]
fn a_plain_text_body_is_not_mistaken_for_json() {
let e = from(401, "authentication required\n");
assert!(matches!(e, Error::Unauthorized { .. }));
assert!(e.to_string().contains("app-token"), "{e}");
let e = from(415, "Expected request with `Content-Type: application/json`");
assert!(matches!(e, Error::Malformed { status: 415, .. }));
assert!(e.to_string().contains("Content-Type"), "{e}");
let e = from(422, "Failed to deserialize the JSON body: missing field `command`");
assert!(matches!(e, Error::Malformed { status: 422, .. }));
assert!(e.to_string().contains("missing field"), "{e}");
}
#[test]
fn an_empty_body_still_produces_something_sayable() {
let e = from(405, "");
assert!(matches!(e, Error::Malformed { status: 405, .. }));
assert_eq!(e.to_string(), "unexpected HTTP 405");
}
#[test]
fn a_missing_deployment_names_itself() {
let e = from(404, r#"{"error":"no deployment \"demo\""}"#);
assert!(matches!(&e, Error::NotFound { kind: "deployment", name } if name == "demo"));
}
#[test]
fn an_unrouted_path_is_not_a_missing_object() {
let e = from(404, "");
assert!(matches!(e, Error::NotFound { .. }), "empty body: assume the object");
let e = from(404, "<html>404 not found</html>");
assert!(matches!(e, Error::Malformed { status: 404, .. }), "{e:?}");
}
#[test]
fn a_sleeping_vm_is_distinguishable_from_a_running_job() {
let e = from(
409,
r#"{"error":"deployment \"demo\" has no running VM (pass wake=true to start one)"}"#,
);
assert!(matches!(&e, Error::NoRunningVm { deployment } if deployment == "demo"));
let e = from(409, r#"{"error":"a build is already running"}"#);
assert!(matches!(e, Error::Conflict { .. }));
}
#[test]
fn the_two_timeouts_are_told_apart() {
let e = from(503, r#"{"error":"…none became available…"}"#);
assert!(matches!(e, Error::ColdStartTimeout { .. }));
assert!(e.is_retryable());
let e = from(502, r#"{"error":"could not run the command in sb-1: timeout"}"#);
assert!(matches!(e, Error::Upstream { .. }));
assert!(e.is_retryable());
}
#[test]
fn retryability_is_conservative() {
assert!(!from(409, r#"{"error":"a build is already running"}"#).is_retryable());
assert!(!from(400, r#"{"error":"bad"}"#).is_retryable());
assert!(!from(401, "authentication required\n").is_retryable());
assert!(!from(403, r#"{"error":"out of scope"}"#).is_retryable());
assert!(!from(404, "").is_retryable());
}
#[test]
fn scope_failures_are_distinguishable_from_credential_failures() {
let bad_creds = from(401, "authentication required\n");
let bad_scope = from(403, r#"{"error":"this token is not scoped to deployment \"x\""}"#);
assert!(bad_creds.is_auth() && bad_scope.is_auth());
assert!(matches!(bad_creds, Error::Unauthorized { .. }));
assert!(matches!(bad_scope, Error::Forbidden { .. }));
assert!(bad_scope.to_string().contains("scoped to"), "{bad_scope}");
}
#[test]
fn the_401_message_reflects_what_was_sent() {
let says = |c| {
Error::from_response(401, "authentication required\n", "deployment", "d", c).to_string()
};
assert!(says(Credential::None).contains("no credential was sent"));
assert!(says(Credential::Basic).contains("username or password"));
assert!(says(Credential::Token).contains("revoked or expired"));
}
#[test]
fn statuses_round_trip() {
for (status, body) in [
(400u16, r#"{"error":"x"}"#),
(403, r#"{"error":"x"}"#),
(404, r#"{"error":"no deployment \"demo\""}"#),
(409, r#"{"error":"x"}"#),
(415, ""),
(502, r#"{"error":"x"}"#),
(503, r#"{"error":"x"}"#),
] {
assert_eq!(from(status, body).status(), Some(status), "status {status}");
}
assert_eq!(
Error::Invalid("nope".into()).status(),
None,
"an error raised before sending has no status"
);
}
}