1use aion_proto::{ProtoWireError, WireError, WireErrorCode};
10use prost::Message;
11use tonic::Code;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ErrorDetail {
16 pub message: String,
19 pub error_type: Option<String>,
22}
23
24impl ErrorDetail {
25 #[must_use]
27 pub fn new(message: impl Into<String>) -> Self {
28 Self {
29 message: message.into(),
30 error_type: None,
31 }
32 }
33
34 #[must_use]
36 pub fn with_type(message: impl Into<String>, error_type: impl Into<String>) -> Self {
37 Self {
38 message: message.into(),
39 error_type: Some(error_type.into()),
40 }
41 }
42}
43
44impl std::fmt::Display for ErrorDetail {
45 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match &self.error_type {
47 Some(error_type) => write!(formatter, "{} [{error_type}]", self.message),
48 None => formatter.write_str(&self.message),
49 }
50 }
51}
52
53impl From<String> for ErrorDetail {
54 fn from(message: String) -> Self {
55 Self::new(message)
56 }
57}
58
59impl From<&str> for ErrorDetail {
60 fn from(message: &str) -> Self {
61 Self::new(message)
62 }
63}
64
65impl From<WireError> for ErrorDetail {
66 fn from(error: WireError) -> Self {
67 Self {
68 message: error.message,
69 error_type: error.error_type,
70 }
71 }
72}
73
74#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
80pub enum ClientError {
81 #[error("not_found: {detail}")]
83 NotFound {
84 detail: ErrorDetail,
86 },
87 #[error("already_exists: {detail}")]
89 AlreadyExists {
90 detail: ErrorDetail,
92 },
93 #[error("query_failed: {detail}")]
95 QueryFailed {
96 detail: ErrorDetail,
98 },
99 #[error("query_timeout: {detail}")]
101 QueryTimeout {
102 detail: ErrorDetail,
104 },
105 #[error("unknown_query: {detail}")]
107 UnknownQuery {
108 detail: ErrorDetail,
110 },
111 #[error("not_running: {detail}")]
113 NotRunning {
114 detail: ErrorDetail,
116 },
117 #[error("cancelled: {detail}")]
119 Cancelled {
120 detail: ErrorDetail,
122 },
123 #[error("unavailable: {detail}")]
125 Unavailable {
126 detail: ErrorDetail,
128 },
129 #[error("unauthenticated: {detail}")]
131 Unauthenticated {
132 detail: ErrorDetail,
134 },
135 #[error("namespace_denied: {detail}")]
149 NamespaceDenied {
150 detail: ErrorDetail,
152 },
153 #[error("invalid_input: {detail}")]
155 InvalidArgument {
156 detail: ErrorDetail,
158 },
159 #[error("backend: {detail}")]
161 Server {
162 detail: ErrorDetail,
164 },
165}
166
167macro_rules! detail_constructors {
168 ($(($constructor:ident, $variant:ident, $doc:literal)),+ $(,)?) => {
169 $(
170 #[doc = $doc]
171 #[must_use]
172 pub fn $constructor(detail: impl Into<ErrorDetail>) -> Self {
173 Self::$variant {
174 detail: detail.into(),
175 }
176 }
177 )+
178 };
179}
180
181impl ClientError {
182 detail_constructors!(
183 (not_found, NotFound, "Creates a not-found error."),
184 (
185 already_exists,
186 AlreadyExists,
187 "Creates an idempotency-conflict error."
188 ),
189 (
190 query_failed,
191 QueryFailed,
192 "Creates a query-handler failure."
193 ),
194 (query_timeout, QueryTimeout, "Creates a query timeout."),
195 (
196 unknown_query,
197 UnknownQuery,
198 "Creates an unknown-query error."
199 ),
200 (not_running, NotRunning, "Creates a not-running error."),
201 (cancelled, Cancelled, "Creates a cancellation error."),
202 (
203 unavailable,
204 Unavailable,
205 "Creates a transport-unavailable error."
206 ),
207 (
208 unauthenticated,
209 Unauthenticated,
210 "Creates a credential-rejection error."
211 ),
212 (
213 namespace_denied,
214 NamespaceDenied,
215 "Creates a namespace-grant denial."
216 ),
217 (
218 invalid_argument,
219 InvalidArgument,
220 "Creates an [`ClientError::InvalidArgument`] carrying a precise message."
221 ),
222 (
223 server,
224 Server,
225 "Creates an unexpected-server-failure error from a local conversion or server detail."
226 ),
227 );
228
229 #[must_use]
231 pub const fn class(&self) -> &'static str {
232 match self {
233 Self::NotFound { .. } => "not_found",
234 Self::AlreadyExists { .. } => "already_exists",
235 Self::QueryFailed { .. } => "query_failed",
236 Self::QueryTimeout { .. } => "query_timeout",
237 Self::UnknownQuery { .. } => "unknown_query",
238 Self::NotRunning { .. } => "not_running",
239 Self::Cancelled { .. } => "cancelled",
240 Self::Unavailable { .. } => "unavailable",
241 Self::Unauthenticated { .. } => "unauthenticated",
242 Self::NamespaceDenied { .. } => "namespace_denied",
243 Self::InvalidArgument { .. } => "invalid_input",
244 Self::Server { .. } => "backend",
245 }
246 }
247
248 #[must_use]
250 pub const fn detail(&self) -> &ErrorDetail {
251 match self {
252 Self::NotFound { detail }
253 | Self::AlreadyExists { detail }
254 | Self::QueryFailed { detail }
255 | Self::QueryTimeout { detail }
256 | Self::UnknownQuery { detail }
257 | Self::NotRunning { detail }
258 | Self::Cancelled { detail }
259 | Self::Unavailable { detail }
260 | Self::Unauthenticated { detail }
261 | Self::NamespaceDenied { detail }
262 | Self::InvalidArgument { detail }
263 | Self::Server { detail } => detail,
264 }
265 }
266
267 #[must_use]
270 pub fn from_wire_error(error: WireError) -> Self {
271 let code = error.code;
272 let detail = ErrorDetail::from(error);
273 match code {
274 WireErrorCode::NotFound => Self::NotFound { detail },
275 WireErrorCode::NamespaceDenied => Self::NamespaceDenied { detail },
276 WireErrorCode::UnknownQuery => Self::UnknownQuery { detail },
277 WireErrorCode::NotRunning => Self::NotRunning { detail },
278 WireErrorCode::InvalidInput => Self::InvalidArgument { detail },
279 WireErrorCode::SequenceConflict
289 | WireErrorCode::Backend
290 | WireErrorCode::DeployDenied
291 | WireErrorCode::VersionPinned => Self::Server { detail },
292 WireErrorCode::QueryFailed => Self::QueryFailed { detail },
293 WireErrorCode::QueryTimeout => Self::QueryTimeout { detail },
294 WireErrorCode::Lagged | WireErrorCode::NotOwner => Self::Unavailable { detail },
299 }
300 }
301
302 #[must_use]
304 pub fn from_proto_wire_error(error: ProtoWireError) -> Self {
305 match WireError::try_from(error) {
306 Ok(error) | Err(error) => Self::from_wire_error(error),
307 }
308 }
309
310 #[must_use]
318 pub fn from_status(status: &tonic::Status) -> Self {
319 if let Some(error) = decode_status_details(status) {
320 return Self::from_proto_wire_error(error);
321 }
322
323 let detail = ErrorDetail::new(status.message());
324 match status.code() {
325 Code::NotFound => Self::NotFound { detail },
326 Code::AlreadyExists => Self::AlreadyExists { detail },
327 Code::DeadlineExceeded => Self::QueryTimeout { detail },
328 Code::Cancelled => Self::Cancelled { detail },
329 Code::Unavailable | Code::ResourceExhausted => Self::Unavailable { detail },
330 Code::Unauthenticated => Self::Unauthenticated { detail },
331 Code::PermissionDenied => Self::NamespaceDenied { detail },
332 Code::InvalidArgument => Self::InvalidArgument { detail },
333 Code::FailedPrecondition => Self::NotRunning { detail },
336 _ => Self::Server { detail },
341 }
342 }
343
344 #[must_use]
347 pub fn from_transport_error(error: &tonic::transport::Error) -> Self {
348 Self::Unavailable {
349 detail: ErrorDetail::new(source_chain(error)),
350 }
351 }
352}
353
354fn source_chain(error: &(dyn std::error::Error + 'static)) -> String {
358 let mut message = error.to_string();
359 let mut source = error.source();
360 while let Some(cause) = source {
361 message.push_str(": ");
362 message.push_str(&cause.to_string());
363 source = cause.source();
364 }
365 message
366}
367
368fn decode_status_details(status: &tonic::Status) -> Option<ProtoWireError> {
369 let details = status.details();
370 if details.is_empty() {
371 return None;
372 }
373 ProtoWireError::decode(details).ok()
374}
375
376#[cfg(test)]
377mod tests {
378 use super::{ClientError, ErrorDetail};
379
380 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
381
382 #[test]
383 fn client_error_is_send_sync_static() {
384 assert_send_sync_static::<ClientError>();
385 }
386
387 fn all_variants() -> Vec<ClientError> {
390 vec![
391 ClientError::not_found("d"),
392 ClientError::already_exists("d"),
393 ClientError::query_failed("d"),
394 ClientError::query_timeout("d"),
395 ClientError::unknown_query("d"),
396 ClientError::not_running("d"),
397 ClientError::cancelled("d"),
398 ClientError::unavailable("d"),
399 ClientError::unauthenticated("d"),
400 ClientError::namespace_denied("d"),
401 ClientError::invalid_argument("d"),
402 ClientError::server("d"),
403 ]
404 }
405
406 #[test]
407 fn display_is_class_colon_detail_for_every_variant() {
408 let mut classes = Vec::new();
409 for error in all_variants() {
410 assert_eq!(
411 error.to_string(),
412 format!("{}: d", error.class()),
413 "{error:?} Display must be `<class>: <detail>`",
414 );
415 assert_eq!(error.detail().message, "d");
416 classes.push(error.class());
417 }
418 let expected = [
419 "not_found",
420 "already_exists",
421 "query_failed",
422 "query_timeout",
423 "unknown_query",
424 "not_running",
425 "cancelled",
426 "unavailable",
427 "unauthenticated",
428 "namespace_denied",
429 "invalid_input",
430 "backend",
431 ];
432 assert_eq!(classes, expected, "class strings are a pinned contract");
433 }
434
435 #[test]
436 fn detail_display_appends_the_typed_discriminator() {
437 assert_eq!(ErrorDetail::new("plain").to_string(), "plain");
438 assert_eq!(
439 ErrorDetail::with_type("store unavailable", "Durability").to_string(),
440 "store unavailable [Durability]"
441 );
442 assert_eq!(
443 ClientError::not_found(ErrorDetail::with_type(
444 "workflow was not found",
445 "WorkflowNotFound"
446 ))
447 .to_string(),
448 "not_found: workflow was not found [WorkflowNotFound]"
449 );
450 }
451}