1use std::fmt;
2
3use feltdb::PreconditionFailure;
4use serde_json::Value;
5
6#[derive(Debug)]
11#[non_exhaustive]
12pub enum Error {
13 Api(Box<ApiError>),
15 Transport {
22 outcome_unknown: bool,
23 source: reqwest::Error,
24 },
25 Decode {
27 status: u16,
28 source: serde_json::Error,
29 },
30 InvalidRequest(String),
32 Stream(String),
34}
35
36#[derive(Debug, Clone)]
38pub struct ApiError {
39 pub status: u16,
41 pub code: String,
44 pub message: String,
46 pub request_id: Option<String>,
48 pub failure: Option<PreconditionFailure>,
51 pub body: Value,
53}
54
55impl Error {
56 pub fn code(&self) -> Option<&str> {
58 match self {
59 Self::Api(error) => Some(&error.code),
60 _ => None,
61 }
62 }
63
64 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 pub fn is_outcome_unknown(&self) -> bool {
76 matches!(
77 self,
78 Self::Transport {
79 outcome_unknown: true,
80 ..
81 }
82 )
83 }
84
85 pub fn precondition_failure(&self) -> Option<&PreconditionFailure> {
87 match self {
88 Self::Api(error) => error.failure.as_ref(),
89 _ => None,
90 }
91 }
92
93 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 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}