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