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 => Self::Unavailable { detail },
295 }
296 }
297
298 #[must_use]
300 pub fn from_proto_wire_error(error: ProtoWireError) -> Self {
301 match WireError::try_from(error) {
302 Ok(error) | Err(error) => Self::from_wire_error(error),
303 }
304 }
305
306 #[must_use]
314 pub fn from_status(status: &tonic::Status) -> Self {
315 if let Some(error) = decode_status_details(status) {
316 return Self::from_proto_wire_error(error);
317 }
318
319 let detail = ErrorDetail::new(status.message());
320 match status.code() {
321 Code::NotFound => Self::NotFound { detail },
322 Code::AlreadyExists => Self::AlreadyExists { detail },
323 Code::DeadlineExceeded => Self::QueryTimeout { detail },
324 Code::Cancelled => Self::Cancelled { detail },
325 Code::Unavailable | Code::ResourceExhausted => Self::Unavailable { detail },
326 Code::Unauthenticated => Self::Unauthenticated { detail },
327 Code::PermissionDenied => Self::NamespaceDenied { detail },
328 Code::InvalidArgument => Self::InvalidArgument { detail },
329 Code::FailedPrecondition => Self::NotRunning { detail },
332 _ => Self::Server { detail },
337 }
338 }
339
340 #[must_use]
343 pub fn from_transport_error(error: &tonic::transport::Error) -> Self {
344 Self::Unavailable {
345 detail: ErrorDetail::new(source_chain(error)),
346 }
347 }
348}
349
350fn source_chain(error: &(dyn std::error::Error + 'static)) -> String {
354 let mut message = error.to_string();
355 let mut source = error.source();
356 while let Some(cause) = source {
357 message.push_str(": ");
358 message.push_str(&cause.to_string());
359 source = cause.source();
360 }
361 message
362}
363
364fn decode_status_details(status: &tonic::Status) -> Option<ProtoWireError> {
365 let details = status.details();
366 if details.is_empty() {
367 return None;
368 }
369 ProtoWireError::decode(details).ok()
370}
371
372#[cfg(test)]
373mod tests {
374 use super::{ClientError, ErrorDetail};
375
376 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
377
378 #[test]
379 fn client_error_is_send_sync_static() {
380 assert_send_sync_static::<ClientError>();
381 }
382
383 fn all_variants() -> Vec<ClientError> {
386 vec![
387 ClientError::not_found("d"),
388 ClientError::already_exists("d"),
389 ClientError::query_failed("d"),
390 ClientError::query_timeout("d"),
391 ClientError::unknown_query("d"),
392 ClientError::not_running("d"),
393 ClientError::cancelled("d"),
394 ClientError::unavailable("d"),
395 ClientError::unauthenticated("d"),
396 ClientError::namespace_denied("d"),
397 ClientError::invalid_argument("d"),
398 ClientError::server("d"),
399 ]
400 }
401
402 #[test]
403 fn display_is_class_colon_detail_for_every_variant() {
404 let mut classes = Vec::new();
405 for error in all_variants() {
406 assert_eq!(
407 error.to_string(),
408 format!("{}: d", error.class()),
409 "{error:?} Display must be `<class>: <detail>`",
410 );
411 assert_eq!(error.detail().message, "d");
412 classes.push(error.class());
413 }
414 let expected = [
415 "not_found",
416 "already_exists",
417 "query_failed",
418 "query_timeout",
419 "unknown_query",
420 "not_running",
421 "cancelled",
422 "unavailable",
423 "unauthenticated",
424 "namespace_denied",
425 "invalid_input",
426 "backend",
427 ];
428 assert_eq!(classes, expected, "class strings are a pinned contract");
429 }
430
431 #[test]
432 fn detail_display_appends_the_typed_discriminator() {
433 assert_eq!(ErrorDetail::new("plain").to_string(), "plain");
434 assert_eq!(
435 ErrorDetail::with_type("store unavailable", "Durability").to_string(),
436 "store unavailable [Durability]"
437 );
438 assert_eq!(
439 ClientError::not_found(ErrorDetail::with_type(
440 "workflow was not found",
441 "WorkflowNotFound"
442 ))
443 .to_string(),
444 "not_found: workflow was not found [WorkflowNotFound]"
445 );
446 }
447}