feltdb-client 0.6.1

Native async Rust client for a FeltDB server: records, atomic and idempotent transactions, compare-and-set, bounded queries, read-basis fences, events, and the canonical application API
Documentation
use std::fmt;

use feltdb::PreconditionFailure;
use serde_json::Value;

/// Everything a request can end in other than success.
///
/// Match on the variant and on [`ApiError::code`], never on message text. The enum is
/// `#[non_exhaustive]`, so keep a catch-all arm.
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
    /// The server answered and refused the request.
    Api(Box<ApiError>),
    /// The request did not complete: connection refused, reset, timed out, or the response could
    /// not be read.
    ///
    /// For a write, [`Error::is_outcome_unknown`] says whether the server may still have applied it.
    /// A transaction with an identity is safe to resubmit with the same id: the authority
    /// recognizes the retry as a duplicate instead of applying it twice.
    Transport {
        outcome_unknown: bool,
        source: reqwest::Error,
    },
    /// The server answered successfully with a body this client could not decode.
    Decode {
        status: u16,
        source: serde_json::Error,
    },
    /// Refused before anything was sent: the request is not one the server could accept.
    InvalidRequest(String),
    /// An event stream ended with a reported authority error.
    Stream(String),
}

/// A refusal from the server, as its JSON error body describes it.
#[derive(Debug, Clone)]
pub struct ApiError {
    /// The HTTP status.
    pub status: u16,
    /// The stable machine-readable code, e.g. `PRECONDITION_FAILED`, `VERSION_CONFLICT`,
    /// `IDEMPOTENCY_CONFLICT`, `FORBIDDEN`, `NOT_FOUND`.
    pub code: String,
    /// Human-readable detail. Not a contract: do not branch on it.
    pub message: String,
    /// The server's request id, when it reported one. Quote it when reporting a problem.
    pub request_id: Option<String>,
    /// Which predicate failed and what the authority holds, when a transaction was refused for a
    /// failed precondition or fence. Nothing was written.
    pub failure: Option<PreconditionFailure>,
    /// The complete error body, for fields this type does not name.
    pub body: Value,
}

impl Error {
    /// The stable error code, when the server reported one.
    pub fn code(&self) -> Option<&str> {
        match self {
            Self::Api(error) => Some(&error.code),
            _ => None,
        }
    }

    /// The HTTP status, when the server answered.
    pub fn status(&self) -> Option<u16> {
        match self {
            Self::Api(error) => Some(error.status),
            Self::Decode { status, .. } => Some(*status),
            _ => None,
        }
    }

    /// True when a write may or may not have been applied: it was sent, and no answer arrived.
    /// Reconcile by resubmitting a transaction with the same id, or by reading the state back.
    pub fn is_outcome_unknown(&self) -> bool {
        matches!(
            self,
            Self::Transport {
                outcome_unknown: true,
                ..
            }
        )
    }

    /// The structured precondition failure of a refused transaction.
    pub fn precondition_failure(&self) -> Option<&PreconditionFailure> {
        match self {
            Self::Api(error) => error.failure.as_ref(),
            _ => None,
        }
    }

    /// True for a refusal caused by state: a failed precondition or fence, a version, epoch or
    /// lease conflict, a record already present. Retry against fresh state, not unchanged.
    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 {
        // A connection that was never established carried no request, so nothing can have been
        // applied. Every other failure of a write leaves its outcome open.
        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());
    }
}