1#[derive(Debug, thiserror::Error)]
9#[non_exhaustive]
10pub enum Error {
11 #[error("transport error")]
13 Transport(#[source] Box<tonic::transport::Error>),
14
15 #[error("connection error: {0}")]
18 Connection(String),
19
20 #[error("grpc status {}: {}", .0.code(), .0.message())]
23 Status(#[source] Box<tonic::Status>),
24
25 #[error("http {status}: {body}")]
28 Http {
29 status: u16,
31 body: String,
33 },
34
35 #[error("json error: {0}")]
37 Json(#[source] Box<serde_json::Error>),
38
39 #[error("command rejected ({code}): {message}")]
42 CommandRejected {
43 code: String,
45 message: String,
47 },
48
49 #[error("authentication failed: {0}")]
53 Auth(String),
54
55 #[error("invalid request: {0}")]
57 InvalidRequest(String),
58
59 #[error("unexpected response: {0}")]
63 UnexpectedResponse(String),
64
65 #[error("operation timed out")]
67 Timeout,
68}
69
70impl Error {
71 #[must_use]
73 pub fn code(&self) -> Option<tonic::Code> {
74 match self {
75 Error::Status(status) => Some(status.code()),
76 _ => None,
77 }
78 }
79
80 #[must_use]
88 pub fn is_retriable(&self) -> bool {
89 use tonic::Code::{Aborted, DeadlineExceeded, ResourceExhausted, Unavailable};
90 match self {
91 Error::Timeout | Error::Transport(_) | Error::Connection(_) => true,
92 Error::Status(status) => matches!(
93 status.code(),
94 Unavailable | DeadlineExceeded | ResourceExhausted | Aborted
95 ),
96 Error::Http { status, .. } => matches!(status, 408 | 429 | 500 | 502 | 503 | 504),
97 _ => false,
98 }
99 }
100
101 #[must_use]
107 pub fn error_info(&self) -> Option<ErrorInfo> {
108 match self {
109 Error::Status(status) => {
110 use tonic_types::StatusExt as _;
111 status
112 .get_error_details()
113 .error_info()
114 .map(|info| ErrorInfo {
115 reason: info.reason.clone(),
116 domain: info.domain.clone(),
117 metadata: info.metadata.clone(),
118 })
119 }
120 _ => None,
121 }
122 }
123}
124
125#[derive(Clone, Debug, Default, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct ErrorInfo {
130 pub reason: String,
132 pub domain: String,
134 pub metadata: std::collections::HashMap<String, String>,
136}
137
138impl From<tonic::Status> for Error {
139 fn from(status: tonic::Status) -> Self {
140 Error::Status(Box::new(status))
141 }
142}
143
144impl From<tonic::transport::Error> for Error {
145 fn from(err: tonic::transport::Error) -> Self {
146 Error::Transport(Box::new(err))
147 }
148}
149
150impl From<serde_json::Error> for Error {
151 fn from(err: serde_json::Error) -> Self {
152 Error::Json(Box::new(err))
153 }
154}
155
156pub type Result<T, E = Error> = std::result::Result<T, E>;
158
159#[cfg(test)]
160#[allow(clippy::unwrap_used, clippy::expect_used)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
166 use tonic_types::{ErrorDetails, StatusExt as _};
167
168 let mut metadata = std::collections::HashMap::new();
169 metadata.insert("resource".to_string(), "contract-1".to_string());
170 let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
171 let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
172
173 let info = Error::from(status)
174 .error_info()
175 .expect("error info present");
176 assert_eq!(info.reason, "DUPLICATE_COMMAND");
177 assert_eq!(info.domain, "canton");
178 assert_eq!(
179 info.metadata.get("resource").map(String::as_str),
180 Some("contract-1")
181 );
182
183 assert!(
185 Error::from(tonic::Status::not_found("x"))
186 .error_info()
187 .is_none()
188 );
189 assert!(Error::Timeout.error_info().is_none());
190 }
191
192 #[test]
193 fn transient_conditions_are_retriable() {
194 assert!(Error::Timeout.is_retriable());
195 assert!(Error::Connection("reset".to_string()).is_retriable());
196 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
197 assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
198 assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
199 assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
200 }
201
202 #[test]
203 fn transient_http_codes_are_retriable_but_client_codes_are_not() {
204 for status in [408, 429, 500, 502, 503, 504] {
205 assert!(
206 Error::Http {
207 status,
208 body: String::new()
209 }
210 .is_retriable(),
211 "http {status} should be retriable"
212 );
213 }
214 for status in [400, 401, 403, 404, 409] {
215 assert!(
216 !Error::Http {
217 status,
218 body: String::new()
219 }
220 .is_retriable(),
221 "http {status} should not be retriable"
222 );
223 }
224 }
225
226 #[test]
227 fn definite_failures_are_not_retriable() {
228 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
229 assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
230 assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
231 assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
232 assert!(!Error::Auth("x".to_string()).is_retriable());
233 assert!(
234 !Error::CommandRejected {
235 code: "GrpcStatus".to_string(),
236 message: "boom".to_string()
237 }
238 .is_retriable()
239 );
240 assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
241 }
242
243 #[test]
244 fn code_is_exposed_only_for_status_errors() {
245 assert_eq!(
246 Error::from(tonic::Status::not_found("x")).code(),
247 Some(tonic::Code::NotFound)
248 );
249 assert_eq!(Error::Timeout.code(), None);
250 assert_eq!(Error::Connection("x".to_string()).code(), None);
251 assert_eq!(
252 Error::Http {
253 status: 503,
254 body: String::new()
255 }
256 .code(),
257 None
258 );
259 }
260
261 #[test]
262 fn display_messages_are_lowercase_and_informative() {
263 assert_eq!(Error::Timeout.to_string(), "operation timed out");
264 assert_eq!(
265 Error::InvalidRequest("bad uri".to_string()).to_string(),
266 "invalid request: bad uri"
267 );
268 assert_eq!(
269 Error::Auth("token expired".to_string()).to_string(),
270 "authentication failed: token expired"
271 );
272 assert_eq!(
273 Error::Http {
274 status: 503,
275 body: "down".to_string()
276 }
277 .to_string(),
278 "http 503: down"
279 );
280 assert_eq!(
281 Error::CommandRejected {
282 code: "INVALID_ARGUMENT".to_string(),
283 message: "nope".to_string()
284 }
285 .to_string(),
286 "command rejected (INVALID_ARGUMENT): nope"
287 );
288 }
289}