use reqwest::StatusCode;
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
const MAX_ERROR_BODY: usize = 4 * 1024;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("invalid Nightscout URL: {0}")]
InvalidUrl(String),
#[error(
"refusing to talk to `{host}` over plain HTTP; use https:// or ClientBuilder::allow_insecure_http()"
)]
InsecureTransport {
host: String,
},
#[error(
"Nightscout redirected ({status}) to `{location}`; use that URL as the base URL instead"
)]
Redirected {
status: u16,
location: String,
},
#[error("unauthorized: {message}")]
Unauthorized {
message: String,
},
#[error("forbidden: {message}")]
Forbidden {
permission: Option<String>,
message: String,
},
#[error("not found")]
NotFound,
#[error("the document has been deleted (410 Gone)")]
Gone,
#[error("the document was modified in the meantime (412 Precondition Failed)")]
PreconditionFailed,
#[error("the document is read-only (422)")]
ReadOnly,
#[error("bad request: {message}")]
BadRequest {
message: String,
},
#[error(
"writes are disabled on this Nightscout (API_SECRET shorter than 12 characters, or the careportal feature is disabled)"
)]
ApiDisabled,
#[error("{feature} is not supported: {reason}")]
Unsupported {
feature: &'static str,
reason: String,
},
#[error("Nightscout returned HTTP {status}: {message}")]
Server {
status: u16,
message: String,
},
#[error("request timed out")]
Timeout,
#[error("network error: {0}")]
Transport(#[source] BoxError),
#[error("could not decode the response at `{path}`: {source}")]
Decode {
path: String,
#[source]
source: serde_json::Error,
},
#[error("invalid input: {0}")]
InvalidInput(String),
}
impl Error {
#[must_use]
pub const fn status(&self) -> Option<u16> {
Some(match self {
Self::Redirected { status, .. } | Self::Server { status, .. } => *status,
Self::Unauthorized { .. } => 401,
Self::Forbidden { .. } => 403,
Self::NotFound | Self::ApiDisabled => 404,
Self::Gone => 410,
Self::PreconditionFailed => 412,
Self::ReadOnly => 422,
Self::BadRequest { .. } => 400,
_ => return None,
})
}
#[must_use]
pub const fn is_transient(&self) -> bool {
match self {
Self::Timeout | Self::Transport(_) => true,
Self::Server { status, .. } => matches!(*status, 408 | 429 | 500 | 502 | 503 | 504),
_ => false,
}
}
pub(crate) fn from_reqwest(err: reqwest::Error) -> Self {
if err.is_timeout() {
Self::Timeout
} else {
Self::Transport(Box::new(err.without_url()))
}
}
pub(crate) fn from_response(status: StatusCode, body: &[u8], is_write: bool) -> Self {
let message = error_message(body);
match status.as_u16() {
400 => Self::BadRequest { message },
401 => Self::Unauthorized { message },
403 => Self::Forbidden {
permission: message
.strip_prefix("Missing permission ")
.map(|p| p.trim().to_owned()),
message,
},
404 if is_write && message.starts_with("Cannot ") => Self::ApiDisabled,
404 => Self::NotFound,
410 => Self::Gone,
412 => Self::PreconditionFailed,
422 => Self::ReadOnly,
code => Self::Server {
status: code,
message,
},
}
}
pub(crate) fn decode(err: serde_path_to_error::Error<serde_json::Error>) -> Self {
let path = err.path().to_string();
Self::Decode {
path,
source: err.into_inner(),
}
}
}
impl From<url::ParseError> for Error {
fn from(err: url::ParseError) -> Self {
Self::InvalidUrl(err.to_string())
}
}
fn error_message(body: &[u8]) -> String {
let body = &body[..body.len().min(MAX_ERROR_BODY)];
let text = String::from_utf8_lossy(body);
if let Ok(serde_json::Value::Object(map)) = serde_json::from_slice::<serde_json::Value>(body) {
let pick = |key: &str| match map.get(key) {
Some(serde_json::Value::String(s)) if !s.is_empty() => Some(s.clone()),
Some(v @ serde_json::Value::Object(_)) => Some(v.to_string()),
_ => None,
};
return match (pick("message"), pick("description")) {
(Some(m), Some(d)) if m != d => format!("{m} ({d})"),
(Some(m), _) => m,
(None, Some(d)) => d,
(None, None) => text.trim().to_owned(),
};
}
if let (Some(start), Some(end)) = (text.find("<pre>"), text.find("</pre>")) {
if start < end {
return text[start + 5..end].trim().to_owned();
}
}
text.trim().to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_error_bodies() {
let err = Error::from_response(
StatusCode::FORBIDDEN,
br#"{"status":403,"message":"Missing permission api:treatments:update"}"#,
true,
);
match err {
Error::Forbidden { permission, .. } => {
assert_eq!(permission.as_deref(), Some("api:treatments:update"));
}
other => panic!("unexpected {other:?}"),
}
let err = Error::from_response(
StatusCode::UNAUTHORIZED,
br#"{"status":401,"message":"Unauthorized","description":"Invalid/Missing"}"#,
false,
);
assert_eq!(
err.to_string(),
"unauthorized: Unauthorized (Invalid/Missing)"
);
}
#[test]
fn express_404_on_write_means_api_disabled() {
let html =
b"<!DOCTYPE html><html><body><pre>Cannot POST /api/v1/treatments</pre></body></html>";
assert!(matches!(
Error::from_response(StatusCode::NOT_FOUND, html, true),
Error::ApiDisabled
));
assert!(matches!(
Error::from_response(StatusCode::NOT_FOUND, html, false),
Error::NotFound
));
}
#[test]
fn error_bodies_are_truncated() {
let huge = vec![b'x'; 100_000];
match Error::from_response(StatusCode::INTERNAL_SERVER_ERROR, &huge, false) {
Error::Server { message, status } => {
assert_eq!(status, 500);
assert_eq!(message.len(), MAX_ERROR_BODY);
}
other => panic!("unexpected {other:?}"),
}
}
}