use std::fmt;
use feltdb::PreconditionFailure;
use serde_json::Value;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Api(Box<ApiError>),
Transport {
outcome_unknown: bool,
source: reqwest::Error,
},
Decode {
status: u16,
source: serde_json::Error,
},
InvalidRequest(String),
Stream(String),
}
#[derive(Debug, Clone)]
pub struct ApiError {
pub status: u16,
pub code: String,
pub message: String,
pub request_id: Option<String>,
pub failure: Option<PreconditionFailure>,
pub body: Value,
}
impl Error {
pub fn code(&self) -> Option<&str> {
match self {
Self::Api(error) => Some(&error.code),
_ => None,
}
}
pub fn status(&self) -> Option<u16> {
match self {
Self::Api(error) => Some(error.status),
Self::Decode { status, .. } => Some(*status),
_ => None,
}
}
pub fn is_outcome_unknown(&self) -> bool {
matches!(
self,
Self::Transport {
outcome_unknown: true,
..
}
)
}
pub fn precondition_failure(&self) -> Option<&PreconditionFailure> {
match self {
Self::Api(error) => error.failure.as_ref(),
_ => None,
}
}
pub fn is_conflict(&self) -> bool {
match self {
Self::Api(error) => error.status == 409 || error.failure.is_some(),
_ => false,
}
}
pub(crate) fn transport(source: reqwest::Error, mutating: bool) -> Self {
let outcome_unknown = mutating && !source.is_connect() && !source.is_builder();
Self::Transport {
outcome_unknown,
source,
}
}
}
impl ApiError {
pub(crate) fn from_body(status: u16, text: &str) -> Self {
let body = serde_json::from_str::<Value>(text)
.ok()
.filter(Value::is_object)
.unwrap_or_else(|| serde_json::json!({ "message": text }));
let field = |name: &str| body.get(name).and_then(Value::as_str).map(str::to_owned);
let code = field("code")
.or_else(|| field("error"))
.unwrap_or_else(|| fallback_code(status).to_owned());
let message = field("message")
.or_else(|| field("error"))
.unwrap_or_else(|| format!("FeltDB request failed with HTTP {status}"));
let failure = body
.get("failure")
.cloned()
.and_then(|value| serde_json::from_value(value).ok());
Self {
status,
code,
message,
request_id: field("request_id"),
failure,
body,
}
}
}
fn fallback_code(status: u16) -> &'static str {
match status {
401 => "UNAUTHORIZED",
403 => "FORBIDDEN",
404 => "NOT_FOUND",
409 => "CONFLICT",
_ if status >= 500 => "EXECUTION_FAILED",
_ => "INVALID_REQUEST",
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Api(error) => write!(f, "{error}"),
Self::Transport {
outcome_unknown: true,
source,
} => write!(f, "request outcome unknown: {source}"),
Self::Transport { source, .. } => write!(f, "request failed: {source}"),
Self::Decode { status, source } => {
write!(f, "could not decode an HTTP {status} response: {source}")
}
Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
Self::Stream(message) => write!(f, "event stream ended: {message}"),
}
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (HTTP {}): {}", self.code, self.status, self.message)?;
if let Some(request_id) = &self.request_id {
write!(f, " [{request_id}]")?;
}
Ok(())
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Transport { source, .. } => Some(source),
Self::Decode { source, .. } => Some(source),
_ => None,
}
}
}
impl std::error::Error for ApiError {}
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refused_transaction_carries_its_structured_failure() {
let error = ApiError::from_body(
409,
r#"{"committed":false,"code":"PRECONDITION_FAILED","error":"PRECONDITION_FAILED","message":"Conflict",
"failure":{"predicate":"version","collection":"orders","key":"orders:o-1","expected":1,"actual":2},
"request_id":"req_1"}"#,
);
assert_eq!(error.code, "PRECONDITION_FAILED");
assert_eq!(error.request_id.as_deref(), Some("req_1"));
assert_eq!(
error.failure,
Some(PreconditionFailure::Version {
collection: "orders".into(),
key: "orders:o-1".into(),
expected: 1,
actual: 2
})
);
assert!(Error::Api(Box::new(error)).is_conflict());
}
#[test]
fn a_body_that_is_not_json_still_yields_a_code() {
let error = ApiError::from_body(502, "bad gateway");
assert_eq!(error.code, "EXECUTION_FAILED");
assert_eq!(error.message, "bad gateway");
assert!(error.failure.is_none());
}
}