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}")]
46 CommandRejected {
47 code: String,
49 message: String,
51 },
52
53 #[error("authentication failed: {0}")]
57 Auth(String),
58
59 #[error("invalid request: {0}")]
61 InvalidRequest(String),
62
63 #[error("unexpected response: {0}")]
67 UnexpectedResponse(String),
68
69 #[error("operation timed out")]
71 Timeout,
72}
73
74impl Error {
75 #[must_use]
77 pub fn code(&self) -> Option<tonic::Code> {
78 match self {
79 Error::Status(status) => Some(status.code()),
80 _ => None,
81 }
82 }
83
84 #[must_use]
101 pub fn is_retriable(&self) -> bool {
102 use tonic::Code::{Aborted, DeadlineExceeded, ResourceExhausted, Unavailable};
103 match self {
104 Error::Timeout | Error::Transport(_) | Error::Connection(_) => true,
105 Error::Status(status) => match status_category(status) {
106 Some(category) => category.is_retriable(),
107 None => {
111 status_retry_delay(status).is_some()
112 || matches!(
113 status.code(),
114 Unavailable | DeadlineExceeded | ResourceExhausted | Aborted
115 )
116 }
117 },
118 Error::Http { status, body } => match http_category(body) {
122 Some(category) => category.is_retriable(),
123 None => http_retry_delay(body).is_some() || matches!(status, 408 | 429 | 500..=599),
124 },
125 _ => false,
126 }
127 }
128
129 #[must_use]
137 pub fn category(&self) -> Option<ErrorCategory> {
138 match self {
139 Error::Status(status) => status_category(status),
140 Error::Http { body, .. } => http_category(body),
141 _ => None,
142 }
143 }
144
145 #[must_use]
151 pub fn retry_delay(&self) -> Option<std::time::Duration> {
152 match self {
153 Error::Status(status) => status_retry_delay(status),
154 Error::Http { body, .. } => http_retry_delay(body),
155 _ => None,
156 }
157 }
158
159 #[must_use]
165 pub fn correlation_id(&self) -> Option<String> {
166 match self {
167 Error::Status(status) => {
168 use tonic_types::StatusExt as _;
169 status
170 .get_details_request_info()
171 .map(|info| info.request_id)
172 }
173 Error::Http { body, .. } => http_correlation_id(body),
174 _ => None,
175 }
176 }
177
178 #[must_use]
184 pub fn error_info(&self) -> Option<ErrorInfo> {
185 match self {
186 Error::Status(status) => {
187 use tonic_types::StatusExt as _;
188 status
189 .get_error_details()
190 .error_info()
191 .map(|info| ErrorInfo {
192 reason: info.reason.clone(),
193 domain: info.domain.clone(),
194 metadata: info.metadata.clone(),
195 })
196 }
197 _ => None,
198 }
199 }
200}
201
202fn status_category(status: &tonic::Status) -> Option<ErrorCategory> {
204 use tonic_types::StatusExt as _;
205 let info = status.get_details_error_info()?;
206 ErrorCategory::from_i32(info.metadata.get("category")?.parse().ok()?)
207}
208
209fn status_retry_delay(status: &tonic::Status) -> Option<std::time::Duration> {
211 use tonic_types::StatusExt as _;
212 status.get_details_retry_info()?.retry_delay
213}
214
215fn http_category(body: &str) -> Option<ErrorCategory> {
219 let body: serde_json::Value = serde_json::from_str(body).ok()?;
220 let id = body.get("errorCategory")?.as_i64()?;
221 ErrorCategory::from_i32(i32::try_from(id).ok()?)
222}
223
224fn http_retry_delay(body: &str) -> Option<std::time::Duration> {
227 let body: serde_json::Value = serde_json::from_str(body).ok()?;
228 parse_spelled_duration(body.get("retryInfo")?.as_str()?)
229}
230
231fn http_correlation_id(body: &str) -> Option<String> {
234 let body: serde_json::Value = serde_json::from_str(body).ok()?;
235 ["correlationId", "traceId"]
236 .iter()
237 .find_map(|key| Some(body.get(key)?.as_str()?.to_string()))
238}
239
240fn parse_spelled_duration(text: &str) -> Option<std::time::Duration> {
243 let mut words = text.split_whitespace();
244 let amount: f64 = words.next()?.parse().ok()?;
245 let unit = words.next()?;
246 if words.next().is_some() {
247 return None;
248 }
249 let seconds = match unit.strip_suffix('s').unwrap_or(unit) {
250 "day" => amount * 86_400.0,
251 "hour" => amount * 3_600.0,
252 "minute" => amount * 60.0,
253 "second" => amount,
254 "millisecond" => amount / 1e3,
255 "microsecond" => amount / 1e6,
256 "nanosecond" => amount / 1e9,
257 _ => return None,
258 };
259 (seconds.is_finite() && seconds >= 0.0).then(|| std::time::Duration::from_secs_f64(seconds))
260}
261
262#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
272#[non_exhaustive]
273pub enum ErrorCategory {
274 TransientServerFailure,
277 ContentionOnSharedResources,
280 DeadlineExceededRequestStateUnknown,
283 SystemInternalAssumptionViolated,
286 SecurityAlert,
289 AuthInterceptorInvalidAuthenticationCredentials,
292 InsufficientPermission,
295 InvalidIndependentOfSystemState,
298 InvalidGivenCurrentSystemStateOther,
302 InvalidGivenCurrentSystemStateResourceExists,
305 InvalidGivenCurrentSystemStateResourceMissing,
308 InvalidGivenCurrentSystemStateSeekAfterEnd,
311 InternalUnsupportedOperation,
314}
315
316impl ErrorCategory {
317 #[must_use]
319 pub const fn from_i32(id: i32) -> Option<Self> {
320 Some(match id {
321 1 => Self::TransientServerFailure,
322 2 => Self::ContentionOnSharedResources,
323 3 => Self::DeadlineExceededRequestStateUnknown,
324 4 => Self::SystemInternalAssumptionViolated,
325 5 => Self::SecurityAlert,
326 6 => Self::AuthInterceptorInvalidAuthenticationCredentials,
327 7 => Self::InsufficientPermission,
328 8 => Self::InvalidIndependentOfSystemState,
329 9 => Self::InvalidGivenCurrentSystemStateOther,
330 10 => Self::InvalidGivenCurrentSystemStateResourceExists,
331 11 => Self::InvalidGivenCurrentSystemStateResourceMissing,
332 12 => Self::InvalidGivenCurrentSystemStateSeekAfterEnd,
333 14 => Self::InternalUnsupportedOperation,
334 _ => return None,
335 })
336 }
337
338 #[must_use]
340 pub const fn as_i32(self) -> i32 {
341 match self {
342 Self::TransientServerFailure => 1,
343 Self::ContentionOnSharedResources => 2,
344 Self::DeadlineExceededRequestStateUnknown => 3,
345 Self::SystemInternalAssumptionViolated => 4,
346 Self::SecurityAlert => 5,
347 Self::AuthInterceptorInvalidAuthenticationCredentials => 6,
348 Self::InsufficientPermission => 7,
349 Self::InvalidIndependentOfSystemState => 8,
350 Self::InvalidGivenCurrentSystemStateOther => 9,
351 Self::InvalidGivenCurrentSystemStateResourceExists => 10,
352 Self::InvalidGivenCurrentSystemStateResourceMissing => 11,
353 Self::InvalidGivenCurrentSystemStateSeekAfterEnd => 12,
354 Self::InternalUnsupportedOperation => 14,
355 }
356 }
357
358 #[must_use]
362 pub const fn is_retriable(self) -> bool {
363 matches!(
364 self,
365 Self::TransientServerFailure
366 | Self::ContentionOnSharedResources
367 | Self::DeadlineExceededRequestStateUnknown
368 | Self::InvalidGivenCurrentSystemStateSeekAfterEnd
369 )
370 }
371}
372
373#[derive(Clone, Debug, Default, PartialEq, Eq)]
376#[non_exhaustive]
377pub struct ErrorInfo {
378 pub reason: String,
380 pub domain: String,
382 pub metadata: std::collections::HashMap<String, String>,
384}
385
386impl From<tonic::Status> for Error {
387 fn from(status: tonic::Status) -> Self {
388 Error::Status(Box::new(status))
389 }
390}
391
392impl From<tonic::transport::Error> for Error {
393 fn from(err: tonic::transport::Error) -> Self {
394 Error::Transport(Box::new(err))
395 }
396}
397
398impl From<serde_json::Error> for Error {
399 fn from(err: serde_json::Error) -> Self {
400 Error::Json(Box::new(err))
401 }
402}
403
404pub type Result<T, E = Error> = std::result::Result<T, E>;
406
407#[cfg(test)]
408#[allow(clippy::unwrap_used, clippy::expect_used)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
414 use tonic_types::{ErrorDetails, StatusExt as _};
415
416 let mut metadata = std::collections::HashMap::new();
417 metadata.insert("resource".to_string(), "contract-1".to_string());
418 let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
419 let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
420
421 let info = Error::from(status)
422 .error_info()
423 .expect("error info present");
424 assert_eq!(info.reason, "DUPLICATE_COMMAND");
425 assert_eq!(info.domain, "canton");
426 assert_eq!(
427 info.metadata.get("resource").map(String::as_str),
428 Some("contract-1")
429 );
430
431 assert!(
433 Error::from(tonic::Status::not_found("x"))
434 .error_info()
435 .is_none()
436 );
437 assert!(Error::Timeout.error_info().is_none());
438 }
439
440 fn canton_status(
444 code: tonic::Code,
445 category: i32,
446 delay: Option<std::time::Duration>,
447 ) -> tonic::Status {
448 use tonic_types::{ErrorDetails, StatusExt as _};
449
450 let mut metadata = std::collections::HashMap::new();
451 metadata.insert("category".to_string(), category.to_string());
452 let mut details = ErrorDetails::with_error_info("SOME_ERROR_CODE", "participant", metadata);
453 details.set_request_info("corr-1234", "");
454 if let Some(delay) = delay {
455 details.set_retry_info(Some(delay));
456 }
457 tonic::Status::with_error_details(code, "boom", details)
458 }
459
460 #[test]
461 fn the_canton_category_decides_retryability_over_the_grpc_code() {
462 use std::time::Duration;
463
464 let err = Error::from(canton_status(tonic::Code::Aborted, 10, None));
467 assert_eq!(
468 err.category(),
469 Some(ErrorCategory::InvalidGivenCurrentSystemStateResourceExists)
470 );
471 assert!(!err.is_retriable());
472
473 let err = Error::from(canton_status(
476 tonic::Code::OutOfRange,
477 12,
478 Some(Duration::from_secs(1)),
479 ));
480 assert_eq!(
481 err.category(),
482 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
483 );
484 assert!(err.is_retriable());
485 assert_eq!(err.retry_delay(), Some(Duration::from_secs(1)));
486 }
487
488 #[test]
489 fn correlation_id_and_retry_delay_are_extracted() {
490 use std::time::Duration;
491
492 let err = Error::from(canton_status(
493 tonic::Code::Unavailable,
494 1,
495 Some(Duration::from_millis(250)),
496 ));
497 assert_eq!(err.category(), Some(ErrorCategory::TransientServerFailure));
498 assert_eq!(err.correlation_id().as_deref(), Some("corr-1234"));
499 assert_eq!(err.retry_delay(), Some(Duration::from_millis(250)));
500
501 let plain = Error::from(tonic::Status::unavailable("x"));
503 assert_eq!(plain.category(), None);
504 assert_eq!(plain.correlation_id(), None);
505 assert_eq!(plain.retry_delay(), None);
506 assert_eq!(Error::Timeout.category(), None);
507 }
508
509 #[test]
510 fn statuses_without_a_category_fall_back_to_code_classification() {
511 use tonic_types::{ErrorDetails, StatusExt as _};
512
513 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
515 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
516
517 let err = Error::from(canton_status(tonic::Code::Unavailable, 99, None));
520 assert_eq!(err.category(), None);
521 assert!(err.is_retriable());
522
523 let mut details = ErrorDetails::new();
526 details.set_retry_info(Some(std::time::Duration::from_secs(2)));
527 let status =
528 tonic::Status::with_error_details(tonic::Code::FailedPrecondition, "wait", details);
529 assert!(Error::from(status).is_retriable());
530 }
531
532 #[test]
533 fn json_api_error_bodies_classify_by_category() {
534 let body = r#"{
538 "code": "OFFSET_AFTER_LEDGER_END",
539 "cause": "Begin offset (999999999) is after ledger end (23577)",
540 "correlationId": null,
541 "traceId": "36a33702b2fa7908a7349be166ccfa38",
542 "context": {"participant": "'app-provider'", "category": "12"},
543 "resources": [],
544 "errorCategory": 12,
545 "grpcCodeValue": 11,
546 "retryInfo": "1 second",
547 "definiteAnswer": null
548 }"#;
549 let err = Error::Http {
550 status: 400,
551 body: body.to_string(),
552 };
553 assert_eq!(
554 err.category(),
555 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
556 );
557 assert!(err.is_retriable());
558 assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
559 assert_eq!(
561 err.correlation_id().as_deref(),
562 Some("36a33702b2fa7908a7349be166ccfa38")
563 );
564
565 let err = Error::Http {
568 status: 503,
569 body: r#"{"errorCategory": 8}"#.to_string(),
570 };
571 assert_eq!(
572 err.category(),
573 Some(ErrorCategory::InvalidIndependentOfSystemState)
574 );
575 assert!(!err.is_retriable());
576 }
577
578 #[test]
579 fn non_json_http_bodies_fall_back_to_status_code_classification() {
580 let retriable = Error::Http {
581 status: 503,
582 body: "<html>Service Unavailable</html>".to_string(),
583 };
584 assert!(retriable.is_retriable());
585 assert_eq!(retriable.category(), None);
586 assert_eq!(retriable.retry_delay(), None);
587
588 let terminal = Error::Http {
589 status: 404,
590 body: String::new(),
591 };
592 assert!(!terminal.is_retriable());
593 assert_eq!(terminal.correlation_id(), None);
594 }
595
596 #[test]
597 fn spelled_durations_parse_and_garbage_is_refused() {
598 use std::time::Duration;
599 for (text, expected) in [
600 ("1 second", Duration::from_secs(1)),
601 ("5 seconds", Duration::from_secs(5)),
602 ("250 milliseconds", Duration::from_millis(250)),
603 ("2 minutes", Duration::from_secs(120)),
604 ("1 hour", Duration::from_secs(3600)),
605 ("0.5 seconds", Duration::from_millis(500)),
606 ] {
607 assert_eq!(parse_spelled_duration(text), Some(expected), "{text}");
608 }
609 for bad in ["", "soon", "1", "1 fortnight", "-1 second", "1 second ago"] {
610 assert_eq!(parse_spelled_duration(bad), None, "{bad}");
611 }
612 }
613
614 #[test]
615 fn category_ids_round_trip_and_follow_the_docs_retryability() {
616 for id in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14] {
617 let category = ErrorCategory::from_i32(id).expect("known id");
618 assert_eq!(category.as_i32(), id);
619 assert_eq!(category.is_retriable(), matches!(id, 1 | 2 | 3 | 12));
621 }
622 assert_eq!(ErrorCategory::from_i32(0), None);
623 assert_eq!(ErrorCategory::from_i32(13), None);
625 assert_eq!(ErrorCategory::from_i32(15), None);
626 }
627
628 #[test]
629 fn transient_conditions_are_retriable() {
630 assert!(Error::Timeout.is_retriable());
631 assert!(Error::Connection("reset".to_string()).is_retriable());
632 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
633 assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
634 assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
635 assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
636 }
637
638 #[test]
639 fn transient_http_codes_are_retriable_but_client_codes_are_not() {
640 for status in [
643 408, 429, 500, 501, 502, 503, 504, 507, 509, 511, 520, 527, 599,
644 ] {
645 assert!(
646 Error::Http {
647 status,
648 body: String::new()
649 }
650 .is_retriable(),
651 "http {status} should be retriable"
652 );
653 }
654 for status in [400, 401, 403, 404, 409, 413, 422] {
656 assert!(
657 !Error::Http {
658 status,
659 body: String::new()
660 }
661 .is_retriable(),
662 "http {status} should not be retriable"
663 );
664 }
665 }
666
667 #[test]
668 fn definite_failures_are_not_retriable() {
669 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
670 assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
671 assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
672 assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
673 assert!(!Error::Auth("x".to_string()).is_retriable());
674 assert!(
675 !Error::CommandRejected {
676 code: "GrpcStatus".to_string(),
677 message: "boom".to_string()
678 }
679 .is_retriable()
680 );
681 assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
682 }
683
684 #[test]
685 fn code_is_exposed_only_for_status_errors() {
686 assert_eq!(
687 Error::from(tonic::Status::not_found("x")).code(),
688 Some(tonic::Code::NotFound)
689 );
690 assert_eq!(Error::Timeout.code(), None);
691 assert_eq!(Error::Connection("x".to_string()).code(), None);
692 assert_eq!(
693 Error::Http {
694 status: 503,
695 body: String::new()
696 }
697 .code(),
698 None
699 );
700 }
701
702 #[test]
703 fn display_messages_are_lowercase_and_informative() {
704 assert_eq!(Error::Timeout.to_string(), "operation timed out");
705 assert_eq!(
706 Error::InvalidRequest("bad uri".to_string()).to_string(),
707 "invalid request: bad uri"
708 );
709 assert_eq!(
710 Error::Auth("token expired".to_string()).to_string(),
711 "authentication failed: token expired"
712 );
713 assert_eq!(
714 Error::Http {
715 status: 503,
716 body: "down".to_string()
717 }
718 .to_string(),
719 "http 503: down"
720 );
721 assert_eq!(
722 Error::CommandRejected {
723 code: "INVALID_ARGUMENT".to_string(),
724 message: "nope".to_string()
725 }
726 .to_string(),
727 "command rejected (INVALID_ARGUMENT): nope"
728 );
729 }
730}