use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("{request} failed with {status}: {message}")]
Status {
request: String,
status: u16,
message: String,
retry_after: Option<Duration>,
},
#[error("{request} could not be sent: {source}")]
Transport {
request: String,
#[source]
source: reqwest::Error,
},
#[error("{request} did not finish within {}s", budget.as_secs())]
Timeout { request: String, budget: Duration },
#[error("{request} answered with more than {cap} bytes")]
TooLarge { request: String, cap: usize },
#[error("{request} answered with a body this server could not read: {source}")]
Malformed {
request: String,
#[source]
source: serde_json::Error,
},
#[error("the control-plane credential could not be exchanged for a token: {0}")]
Token(String),
#[error("the federated identity file {} could not be read: {source}", path.display())]
JwtFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the control-plane client is misconfigured: {0}")]
Config(String),
}
impl ApiError {
pub const fn status(&self) -> Option<u16> {
match self {
Self::Status { status, .. } => Some(*status),
_ => None,
}
}
pub const fn is_transient(&self) -> bool {
match self {
Self::Status { status, .. } => matches!(status, 429 | 500 | 502 | 503 | 504),
Self::Transport { .. } => true,
_ => false,
}
}
}
pub(crate) fn describe(status: reqwest::StatusCode, body: &str) -> String {
let body = body.trim();
if let Ok(serde_json::Value::Object(fields)) = serde_json::from_str::<serde_json::Value>(body)
&& let Some(serde_json::Value::String(message)) = fields.get("message")
&& !message.trim().is_empty()
{
return message.trim().to_owned();
}
if body.is_empty() {
return status
.canonical_reason()
.unwrap_or("no reason given")
.to_owned();
}
body.to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Idempotence {
Repeatable,
Once,
}
#[cfg(test)]
mod tests {
use super::*;
fn status(status: u16) -> ApiError {
ApiError::Status {
request: "GET /api/v2/tailnet/-/devices".to_owned(),
status,
message: "nope".to_owned(),
retry_after: None,
}
}
#[test]
fn the_statuses_worth_asking_again_about_are_the_ones_that_mean_later() {
for code in [429, 500, 502, 503, 504] {
assert!(status(code).is_transient(), "{code} should be transient");
}
for code in [400, 401, 403, 404, 409, 412, 501] {
assert!(!status(code).is_transient(), "{code} should be permanent");
}
}
#[test]
fn a_failure_is_described_from_the_field_the_api_uses() {
let reason = |body| describe(reqwest::StatusCode::BAD_REQUEST, body);
assert_eq!(
reason(r#"{"message": "invalid tailnet"}"#),
"invalid tailnet"
);
assert_eq!(reason("plain trouble"), "plain trouble");
assert_eq!(reason(r#"{"error": "nope"}"#), r#"{"error": "nope"}"#);
assert_eq!(reason(" "), "Bad Request");
}
#[test]
fn only_a_status_carries_a_status() {
assert_eq!(status(429).status(), Some(429));
assert_eq!(ApiError::Token("no".to_owned()).status(), None);
}
}