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("invalid_state: {detail}")]
168 InvalidState {
169 detail: ErrorDetail,
171 },
172 #[error("backend: {detail}")]
174 Server {
175 detail: ErrorDetail,
177 },
178}
179
180macro_rules! detail_constructors {
181 ($(($constructor:ident, $variant:ident, $doc:literal)),+ $(,)?) => {
182 $(
183 #[doc = $doc]
184 #[must_use]
185 pub fn $constructor(detail: impl Into<ErrorDetail>) -> Self {
186 Self::$variant {
187 detail: detail.into(),
188 }
189 }
190 )+
191 };
192}
193
194impl ClientError {
195 detail_constructors!(
196 (not_found, NotFound, "Creates a not-found error."),
197 (
198 already_exists,
199 AlreadyExists,
200 "Creates an idempotency-conflict error."
201 ),
202 (
203 query_failed,
204 QueryFailed,
205 "Creates a query-handler failure."
206 ),
207 (query_timeout, QueryTimeout, "Creates a query timeout."),
208 (
209 unknown_query,
210 UnknownQuery,
211 "Creates an unknown-query error."
212 ),
213 (not_running, NotRunning, "Creates a not-running error."),
214 (cancelled, Cancelled, "Creates a cancellation error."),
215 (
216 unavailable,
217 Unavailable,
218 "Creates a transport-unavailable error."
219 ),
220 (
221 unauthenticated,
222 Unauthenticated,
223 "Creates a credential-rejection error."
224 ),
225 (
226 namespace_denied,
227 NamespaceDenied,
228 "Creates a namespace-grant denial."
229 ),
230 (
231 invalid_argument,
232 InvalidArgument,
233 "Creates an [`ClientError::InvalidArgument`] carrying a precise message."
234 ),
235 (
236 invalid_state,
237 InvalidState,
238 "Creates an [`ClientError::InvalidState`] precondition failure."
239 ),
240 (
241 server,
242 Server,
243 "Creates an unexpected-server-failure error from a local conversion or server detail."
244 ),
245 );
246
247 #[must_use]
249 pub const fn class(&self) -> &'static str {
250 match self {
251 Self::NotFound { .. } => "not_found",
252 Self::AlreadyExists { .. } => "already_exists",
253 Self::QueryFailed { .. } => "query_failed",
254 Self::QueryTimeout { .. } => "query_timeout",
255 Self::UnknownQuery { .. } => "unknown_query",
256 Self::NotRunning { .. } => "not_running",
257 Self::Cancelled { .. } => "cancelled",
258 Self::Unavailable { .. } => "unavailable",
259 Self::Unauthenticated { .. } => "unauthenticated",
260 Self::NamespaceDenied { .. } => "namespace_denied",
261 Self::InvalidArgument { .. } => "invalid_input",
262 Self::InvalidState { .. } => "invalid_state",
263 Self::Server { .. } => "backend",
264 }
265 }
266
267 #[must_use]
269 pub const fn detail(&self) -> &ErrorDetail {
270 match self {
271 Self::NotFound { detail }
272 | Self::AlreadyExists { detail }
273 | Self::QueryFailed { detail }
274 | Self::QueryTimeout { detail }
275 | Self::UnknownQuery { detail }
276 | Self::NotRunning { detail }
277 | Self::Cancelled { detail }
278 | Self::Unavailable { detail }
279 | Self::Unauthenticated { detail }
280 | Self::NamespaceDenied { detail }
281 | Self::InvalidArgument { detail }
282 | Self::InvalidState { detail }
283 | Self::Server { detail } => detail,
284 }
285 }
286
287 #[must_use]
290 pub fn from_wire_error(error: WireError) -> Self {
291 let code = error.code;
292 let detail = ErrorDetail::from(error);
293 match code {
294 WireErrorCode::NotFound => Self::NotFound { detail },
295 WireErrorCode::NamespaceDenied => Self::NamespaceDenied { detail },
296 WireErrorCode::UnknownQuery => Self::UnknownQuery { detail },
297 WireErrorCode::NotRunning => Self::NotRunning { detail },
298 WireErrorCode::InvalidInput => Self::InvalidArgument { detail },
299 WireErrorCode::InvalidState => Self::InvalidState { detail },
300 WireErrorCode::SequenceConflict
310 | WireErrorCode::Backend
311 | WireErrorCode::DeployDenied
312 | WireErrorCode::VersionPinned => Self::Server { detail },
313 WireErrorCode::QueryFailed => Self::QueryFailed { detail },
314 WireErrorCode::QueryTimeout => Self::QueryTimeout { detail },
315 WireErrorCode::Lagged | WireErrorCode::NotOwner => Self::Unavailable { detail },
320 }
321 }
322
323 #[must_use]
325 pub fn from_proto_wire_error(error: ProtoWireError) -> Self {
326 match WireError::try_from(error) {
327 Ok(error) | Err(error) => Self::from_wire_error(error),
328 }
329 }
330
331 #[must_use]
339 pub fn from_status(status: &tonic::Status) -> Self {
340 if let Some(error) = decode_status_details(status) {
341 return Self::from_proto_wire_error(error);
342 }
343
344 let detail = ErrorDetail::new(status.message());
345 match status.code() {
346 Code::NotFound => Self::NotFound { detail },
347 Code::AlreadyExists => Self::AlreadyExists { detail },
348 Code::DeadlineExceeded => Self::QueryTimeout { detail },
349 Code::Cancelled => Self::Cancelled { detail },
350 Code::Unavailable | Code::ResourceExhausted => Self::Unavailable { detail },
351 Code::Unauthenticated => Self::Unauthenticated { detail },
352 Code::PermissionDenied => Self::NamespaceDenied { detail },
353 Code::InvalidArgument => Self::InvalidArgument { detail },
354 Code::FailedPrecondition => Self::NotRunning { detail },
360 _ => Self::Server { detail },
365 }
366 }
367
368 #[must_use]
371 pub fn from_transport_error(error: &tonic::transport::Error) -> Self {
372 Self::Unavailable {
373 detail: ErrorDetail::new(source_chain(error)),
374 }
375 }
376}
377
378fn source_chain(error: &(dyn std::error::Error + 'static)) -> String {
382 let mut message = error.to_string();
383 let mut source = error.source();
384 while let Some(cause) = source {
385 message.push_str(": ");
386 message.push_str(&cause.to_string());
387 source = cause.source();
388 }
389 message
390}
391
392fn decode_status_details(status: &tonic::Status) -> Option<ProtoWireError> {
393 let details = status.details();
394 if details.is_empty() {
395 return None;
396 }
397 ProtoWireError::decode(details).ok()
398}
399
400#[cfg(test)]
401mod tests {
402 use super::{ClientError, ErrorDetail};
403
404 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
405
406 #[test]
407 fn client_error_is_send_sync_static() {
408 assert_send_sync_static::<ClientError>();
409 }
410
411 fn all_variants() -> Vec<ClientError> {
414 vec![
415 ClientError::not_found("d"),
416 ClientError::already_exists("d"),
417 ClientError::query_failed("d"),
418 ClientError::query_timeout("d"),
419 ClientError::unknown_query("d"),
420 ClientError::not_running("d"),
421 ClientError::cancelled("d"),
422 ClientError::unavailable("d"),
423 ClientError::unauthenticated("d"),
424 ClientError::namespace_denied("d"),
425 ClientError::invalid_argument("d"),
426 ClientError::invalid_state("d"),
427 ClientError::server("d"),
428 ]
429 }
430
431 #[test]
432 fn display_is_class_colon_detail_for_every_variant() {
433 let mut classes = Vec::new();
434 for error in all_variants() {
435 assert_eq!(
436 error.to_string(),
437 format!("{}: d", error.class()),
438 "{error:?} Display must be `<class>: <detail>`",
439 );
440 assert_eq!(error.detail().message, "d");
441 classes.push(error.class());
442 }
443 let expected = [
444 "not_found",
445 "already_exists",
446 "query_failed",
447 "query_timeout",
448 "unknown_query",
449 "not_running",
450 "cancelled",
451 "unavailable",
452 "unauthenticated",
453 "namespace_denied",
454 "invalid_input",
455 "invalid_state",
456 "backend",
457 ];
458 assert_eq!(classes, expected, "class strings are a pinned contract");
459 }
460
461 #[test]
462 fn detail_display_appends_the_typed_discriminator() {
463 assert_eq!(ErrorDetail::new("plain").to_string(), "plain");
464 assert_eq!(
465 ErrorDetail::with_type("store unavailable", "Durability").to_string(),
466 "store unavailable [Durability]"
467 );
468 assert_eq!(
469 ClientError::not_found(ErrorDetail::with_type(
470 "workflow was not found",
471 "WorkflowNotFound"
472 ))
473 .to_string(),
474 "not_found: workflow was not found [WorkflowNotFound]"
475 );
476 }
477}