Skip to main content

feltdb_client/
error.rs

1use std::fmt;
2
3use feltdb::PreconditionFailure;
4use serde_json::Value;
5
6/// Everything a request can end in other than success.
7///
8/// Match on the variant and on [`ApiError::code`], never on message text. The enum is
9/// `#[non_exhaustive]`, so keep a catch-all arm.
10#[derive(Debug)]
11#[non_exhaustive]
12pub enum Error {
13    /// The server answered and refused the request.
14    Api(Box<ApiError>),
15    /// The request did not complete: connection refused, reset, timed out, or the response could
16    /// not be read.
17    ///
18    /// For a write, [`Error::is_outcome_unknown`] says whether the server may still have applied it.
19    /// A transaction with an identity is safe to resubmit with the same id: the authority
20    /// recognizes the retry as a duplicate instead of applying it twice.
21    Transport {
22        outcome_unknown: bool,
23        source: reqwest::Error,
24    },
25    /// The server answered successfully with a body this client could not decode.
26    Decode {
27        status: u16,
28        source: serde_json::Error,
29    },
30    /// Refused before anything was sent: the request is not one the server could accept.
31    InvalidRequest(String),
32    /// An event stream ended with a reported authority error.
33    Stream(String),
34}
35
36/// A refusal from the server, as its JSON error body describes it.
37#[derive(Debug, Clone)]
38pub struct ApiError {
39    /// The HTTP status.
40    pub status: u16,
41    /// The stable machine-readable code, e.g. `PRECONDITION_FAILED`, `VERSION_CONFLICT`,
42    /// `IDEMPOTENCY_CONFLICT`, `FORBIDDEN`, `NOT_FOUND`.
43    pub code: String,
44    /// Human-readable detail. Not a contract: do not branch on it.
45    pub message: String,
46    /// The server's request id, when it reported one. Quote it when reporting a problem.
47    pub request_id: Option<String>,
48    /// Which predicate failed and what the authority holds, when a transaction was refused for a
49    /// failed precondition or fence. Nothing was written.
50    pub failure: Option<PreconditionFailure>,
51    /// The complete error body, for fields this type does not name.
52    pub body: Value,
53}
54
55impl Error {
56    /// The stable error code, when the server reported one.
57    pub fn code(&self) -> Option<&str> {
58        match self {
59            Self::Api(error) => Some(&error.code),
60            _ => None,
61        }
62    }
63
64    /// The HTTP status, when the server answered.
65    pub fn status(&self) -> Option<u16> {
66        match self {
67            Self::Api(error) => Some(error.status),
68            Self::Decode { status, .. } => Some(*status),
69            _ => None,
70        }
71    }
72
73    /// True when a write may or may not have been applied: it was sent, and no answer arrived.
74    /// Reconcile by resubmitting a transaction with the same id, or by reading the state back.
75    pub fn is_outcome_unknown(&self) -> bool {
76        matches!(
77            self,
78            Self::Transport {
79                outcome_unknown: true,
80                ..
81            }
82        )
83    }
84
85    /// The structured precondition failure of a refused transaction.
86    pub fn precondition_failure(&self) -> Option<&PreconditionFailure> {
87        match self {
88            Self::Api(error) => error.failure.as_ref(),
89            _ => None,
90        }
91    }
92
93    /// True for a refusal caused by state: a failed precondition or fence, a version, epoch or
94    /// lease conflict, a record already present. Retry against fresh state, not unchanged.
95    pub fn is_conflict(&self) -> bool {
96        match self {
97            Self::Api(error) => error.status == 409 || error.failure.is_some(),
98            _ => false,
99        }
100    }
101
102    pub(crate) fn transport(source: reqwest::Error, mutating: bool) -> Self {
103        // A connection that was never established carried no request, so nothing can have been
104        // applied. Every other failure of a write leaves its outcome open.
105        let outcome_unknown = mutating && !source.is_connect() && !source.is_builder();
106        Self::Transport {
107            outcome_unknown,
108            source,
109        }
110    }
111}
112
113impl ApiError {
114    pub(crate) fn from_body(status: u16, text: &str) -> Self {
115        let body = serde_json::from_str::<Value>(text)
116            .ok()
117            .filter(Value::is_object)
118            .unwrap_or_else(|| serde_json::json!({ "message": text }));
119        let field = |name: &str| body.get(name).and_then(Value::as_str).map(str::to_owned);
120        let code = field("code")
121            .or_else(|| field("error"))
122            .unwrap_or_else(|| fallback_code(status).to_owned());
123        let message = field("message")
124            .or_else(|| field("error"))
125            .unwrap_or_else(|| format!("FeltDB request failed with HTTP {status}"));
126        let failure = body
127            .get("failure")
128            .cloned()
129            .and_then(|value| serde_json::from_value(value).ok());
130        Self {
131            status,
132            code,
133            message,
134            request_id: field("request_id"),
135            failure,
136            body,
137        }
138    }
139}
140
141fn fallback_code(status: u16) -> &'static str {
142    match status {
143        401 => "UNAUTHORIZED",
144        403 => "FORBIDDEN",
145        404 => "NOT_FOUND",
146        409 => "CONFLICT",
147        _ if status >= 500 => "EXECUTION_FAILED",
148        _ => "INVALID_REQUEST",
149    }
150}
151
152impl fmt::Display for Error {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        match self {
155            Self::Api(error) => write!(f, "{error}"),
156            Self::Transport {
157                outcome_unknown: true,
158                source,
159            } => write!(f, "request outcome unknown: {source}"),
160            Self::Transport { source, .. } => write!(f, "request failed: {source}"),
161            Self::Decode { status, source } => {
162                write!(f, "could not decode an HTTP {status} response: {source}")
163            }
164            Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
165            Self::Stream(message) => write!(f, "event stream ended: {message}"),
166        }
167    }
168}
169
170impl fmt::Display for ApiError {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "{} (HTTP {}): {}", self.code, self.status, self.message)?;
173        if let Some(request_id) = &self.request_id {
174            write!(f, " [{request_id}]")?;
175        }
176        Ok(())
177    }
178}
179
180impl std::error::Error for Error {
181    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
182        match self {
183            Self::Transport { source, .. } => Some(source),
184            Self::Decode { source, .. } => Some(source),
185            _ => None,
186        }
187    }
188}
189
190impl std::error::Error for ApiError {}
191
192pub type Result<T, E = Error> = std::result::Result<T, E>;
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn a_refused_transaction_carries_its_structured_failure() {
200        let error = ApiError::from_body(
201            409,
202            r#"{"committed":false,"code":"PRECONDITION_FAILED","error":"PRECONDITION_FAILED","message":"Conflict",
203                "failure":{"predicate":"version","collection":"orders","key":"orders:o-1","expected":1,"actual":2},
204                "request_id":"req_1"}"#,
205        );
206        assert_eq!(error.code, "PRECONDITION_FAILED");
207        assert_eq!(error.request_id.as_deref(), Some("req_1"));
208        assert_eq!(
209            error.failure,
210            Some(PreconditionFailure::Version {
211                collection: "orders".into(),
212                key: "orders:o-1".into(),
213                expected: 1,
214                actual: 2
215            })
216        );
217        assert!(Error::Api(Box::new(error)).is_conflict());
218    }
219
220    #[test]
221    fn a_body_that_is_not_json_still_yields_a_code() {
222        let error = ApiError::from_body(502, "bad gateway");
223        assert_eq!(error.code, "EXECUTION_FAILED");
224        assert_eq!(error.message, "bad gateway");
225        assert!(error.failure.is_none());
226    }
227}