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 #[error("payload conversion failed: {0}")]
80 Payload(#[source] Box<dyn std::error::Error + Send + Sync>),
81}
82
83impl Error {
84 #[must_use]
94 pub fn code(&self) -> Option<tonic::Code> {
95 match self {
96 Error::Status(status) => Some(status.code()),
97 Error::Http { body, .. } => http_grpc_code(body),
98 _ => None,
99 }
100 }
101
102 #[must_use]
119 pub fn is_retriable(&self) -> bool {
120 use tonic::Code::{Aborted, DeadlineExceeded, ResourceExhausted, Unavailable};
121 match self {
122 Error::Timeout | Error::Transport(_) | Error::Connection(_) => true,
123 Error::Status(status) => match status_category(status) {
124 Some(category) => category.is_retriable(),
125 None => {
129 status_retry_delay(status).is_some()
130 || matches!(
131 status.code(),
132 Unavailable | DeadlineExceeded | ResourceExhausted | Aborted
133 )
134 }
135 },
136 Error::Http { status, body } => match http_category(body) {
140 Some(category) => category.is_retriable(),
141 None => http_retry_delay(body).is_some() || matches!(status, 408 | 429 | 500..=599),
142 },
143 _ => false,
144 }
145 }
146
147 #[must_use]
155 pub fn category(&self) -> Option<ErrorCategory> {
156 match self {
157 Error::Status(status) => status_category(status),
158 Error::Http { body, .. } => http_category(body),
159 _ => None,
160 }
161 }
162
163 #[must_use]
169 pub fn retry_delay(&self) -> Option<std::time::Duration> {
170 match self {
171 Error::Status(status) => status_retry_delay(status),
172 Error::Http { body, .. } => http_retry_delay(body),
173 _ => None,
174 }
175 }
176
177 #[must_use]
183 pub fn correlation_id(&self) -> Option<String> {
184 match self {
185 Error::Status(status) => {
186 use tonic_types::StatusExt as _;
187 status
188 .get_details_request_info()
189 .map(|info| info.request_id)
190 }
191 Error::Http { body, .. } => http_correlation_id(body),
192 _ => None,
193 }
194 }
195
196 #[must_use]
207 pub fn resource_info(&self) -> Vec<ResourceInfo> {
208 match self {
209 Error::Status(status) => {
210 use tonic_types::StatusExt as _;
211 status
212 .get_details_resource_info()
213 .map(|info| ResourceInfo {
214 resource_type: info.resource_type,
215 resource_name: info.resource_name,
216 owner: info.owner,
217 description: info.description,
218 })
219 .into_iter()
220 .collect()
221 }
222 Error::Http { body, .. } => http_resource_info(body),
223 _ => Vec::new(),
224 }
225 }
226
227 #[must_use]
245 pub fn error_info(&self) -> Option<ErrorInfo> {
246 match self {
247 Error::Status(status) => {
248 use tonic_types::StatusExt as _;
249 status
250 .get_error_details()
251 .error_info()
252 .map(|info| ErrorInfo {
253 reason: info.reason.clone(),
254 domain: info.domain.clone(),
255 metadata: info.metadata.clone(),
256 })
257 }
258 Error::Http { body, .. } => http_error_info(body),
259 _ => None,
260 }
261 }
262}
263
264fn status_category(status: &tonic::Status) -> Option<ErrorCategory> {
266 use tonic_types::StatusExt as _;
267 let info = status.get_details_error_info()?;
268 ErrorCategory::from_i32(info.metadata.get("category")?.parse().ok()?)
269}
270
271fn status_retry_delay(status: &tonic::Status) -> Option<std::time::Duration> {
273 use tonic_types::StatusExt as _;
274 status.get_details_retry_info()?.retry_delay
275}
276
277fn http_category(body: &str) -> Option<ErrorCategory> {
281 let body: serde_json::Value = serde_json::from_str(body).ok()?;
282 let id = body.get("errorCategory")?.as_i64()?;
283 ErrorCategory::from_i32(i32::try_from(id).ok()?)
284}
285
286fn http_retry_delay(body: &str) -> Option<std::time::Duration> {
289 let body: serde_json::Value = serde_json::from_str(body).ok()?;
290 parse_spelled_duration(body.get("retryInfo")?.as_str()?)
291}
292
293fn http_resource_info(body: &str) -> Vec<ResourceInfo> {
298 let Ok(body) = serde_json::from_str::<serde_json::Value>(body) else {
299 return Vec::new();
300 };
301 let Some(resources) = body.get("resources").and_then(serde_json::Value::as_array) else {
302 return Vec::new();
303 };
304 resources
305 .iter()
306 .filter_map(|entry| {
307 let pair = entry.as_array()?;
308 Some(ResourceInfo {
309 resource_type: pair.first()?.as_str()?.to_string(),
310 resource_name: pair.get(1)?.as_str()?.to_string(),
311 owner: String::new(),
312 description: String::new(),
313 })
314 })
315 .collect()
316}
317
318fn http_grpc_code(body: &str) -> Option<tonic::Code> {
325 let body: serde_json::Value = serde_json::from_str(body).ok()?;
326 let value = body.get("grpcCodeValue")?.as_i64()?;
327 Some(tonic::Code::from(i32::try_from(value).ok()?))
328}
329
330fn http_error_info(body: &str) -> Option<ErrorInfo> {
341 let body: serde_json::Value = serde_json::from_str(body).ok()?;
342 let reason = body.get("code")?.as_str()?;
343 if reason.is_empty() || reason == "NA" {
344 return None;
345 }
346 let metadata = body
347 .get("context")
348 .and_then(serde_json::Value::as_object)
349 .map(|context| {
350 context
351 .iter()
352 .map(|(key, value)| {
353 let value = match value.as_str() {
354 Some(text) => text.to_string(),
355 None => value.to_string(),
356 };
357 (key.clone(), value)
358 })
359 .collect()
360 })
361 .unwrap_or_default();
362 Some(ErrorInfo {
363 reason: reason.to_string(),
364 domain: String::new(),
367 metadata,
368 })
369}
370
371fn http_correlation_id(body: &str) -> Option<String> {
374 let body: serde_json::Value = serde_json::from_str(body).ok()?;
375 ["correlationId", "traceId"]
376 .iter()
377 .find_map(|key| Some(body.get(key)?.as_str()?.to_string()))
378}
379
380fn parse_spelled_duration(text: &str) -> Option<std::time::Duration> {
383 let mut words = text.split_whitespace();
384 let amount: f64 = words.next()?.parse().ok()?;
385 let unit = words.next()?;
386 if words.next().is_some() {
387 return None;
388 }
389 let seconds = match unit.strip_suffix('s').unwrap_or(unit) {
390 "day" => amount * 86_400.0,
391 "hour" => amount * 3_600.0,
392 "minute" => amount * 60.0,
393 "second" => amount,
394 "millisecond" => amount / 1e3,
395 "microsecond" => amount / 1e6,
396 "nanosecond" => amount / 1e9,
397 _ => return None,
398 };
399 std::time::Duration::try_from_secs_f64(seconds).ok()
406}
407
408#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
418#[non_exhaustive]
419pub enum ErrorCategory {
420 TransientServerFailure,
423 ContentionOnSharedResources,
426 DeadlineExceededRequestStateUnknown,
429 SystemInternalAssumptionViolated,
432 SecurityAlert,
435 AuthInterceptorInvalidAuthenticationCredentials,
438 InsufficientPermission,
441 InvalidIndependentOfSystemState,
444 InvalidGivenCurrentSystemStateOther,
448 InvalidGivenCurrentSystemStateResourceExists,
451 InvalidGivenCurrentSystemStateResourceMissing,
454 InvalidGivenCurrentSystemStateSeekAfterEnd,
457 InternalUnsupportedOperation,
460}
461
462impl TryFrom<i32> for ErrorCategory {
467 type Error = i32;
468 fn try_from(id: i32) -> std::result::Result<Self, i32> {
469 Self::from_i32(id).ok_or(id)
470 }
471}
472
473impl From<ErrorCategory> for i32 {
474 fn from(category: ErrorCategory) -> i32 {
475 category.as_i32()
476 }
477}
478
479impl ErrorCategory {
480 #[must_use]
482 pub const fn from_i32(id: i32) -> Option<Self> {
483 Some(match id {
484 1 => Self::TransientServerFailure,
485 2 => Self::ContentionOnSharedResources,
486 3 => Self::DeadlineExceededRequestStateUnknown,
487 4 => Self::SystemInternalAssumptionViolated,
488 5 => Self::SecurityAlert,
489 6 => Self::AuthInterceptorInvalidAuthenticationCredentials,
490 7 => Self::InsufficientPermission,
491 8 => Self::InvalidIndependentOfSystemState,
492 9 => Self::InvalidGivenCurrentSystemStateOther,
493 10 => Self::InvalidGivenCurrentSystemStateResourceExists,
494 11 => Self::InvalidGivenCurrentSystemStateResourceMissing,
495 12 => Self::InvalidGivenCurrentSystemStateSeekAfterEnd,
496 14 => Self::InternalUnsupportedOperation,
497 _ => return None,
498 })
499 }
500
501 #[must_use]
503 pub const fn as_i32(self) -> i32 {
504 match self {
505 Self::TransientServerFailure => 1,
506 Self::ContentionOnSharedResources => 2,
507 Self::DeadlineExceededRequestStateUnknown => 3,
508 Self::SystemInternalAssumptionViolated => 4,
509 Self::SecurityAlert => 5,
510 Self::AuthInterceptorInvalidAuthenticationCredentials => 6,
511 Self::InsufficientPermission => 7,
512 Self::InvalidIndependentOfSystemState => 8,
513 Self::InvalidGivenCurrentSystemStateOther => 9,
514 Self::InvalidGivenCurrentSystemStateResourceExists => 10,
515 Self::InvalidGivenCurrentSystemStateResourceMissing => 11,
516 Self::InvalidGivenCurrentSystemStateSeekAfterEnd => 12,
517 Self::InternalUnsupportedOperation => 14,
518 }
519 }
520
521 #[must_use]
525 pub const fn is_retriable(self) -> bool {
526 matches!(
527 self,
528 Self::TransientServerFailure
529 | Self::ContentionOnSharedResources
530 | Self::DeadlineExceededRequestStateUnknown
531 | Self::InvalidGivenCurrentSystemStateSeekAfterEnd
532 )
533 }
534}
535
536#[derive(Clone, Debug, Default, PartialEq, Eq)]
539#[non_exhaustive]
540pub struct ErrorInfo {
541 pub reason: String,
543 pub domain: String,
545 pub metadata: std::collections::HashMap<String, String>,
547}
548
549#[derive(Clone, Debug, Default, PartialEq, Eq)]
553#[non_exhaustive]
554pub struct ResourceInfo {
555 pub resource_type: String,
558 pub resource_name: String,
560 pub owner: String,
563 pub description: String,
566}
567
568impl From<tonic::Status> for Error {
569 fn from(status: tonic::Status) -> Self {
570 Error::Status(Box::new(status))
571 }
572}
573
574impl From<tonic::transport::Error> for Error {
575 fn from(err: tonic::transport::Error) -> Self {
576 Error::Transport(Box::new(err))
577 }
578}
579
580impl From<serde_json::Error> for Error {
581 fn from(err: serde_json::Error) -> Self {
582 Error::Json(Box::new(err))
583 }
584}
585
586pub type Result<T, E = Error> = std::result::Result<T, E>;
588
589#[cfg(test)]
590#[allow(clippy::unwrap_used, clippy::expect_used)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
596 use tonic_types::{ErrorDetails, StatusExt as _};
597
598 let mut metadata = std::collections::HashMap::new();
599 metadata.insert("resource".to_string(), "contract-1".to_string());
600 let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
601 let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
602
603 let info = Error::from(status)
604 .error_info()
605 .expect("error info present");
606 assert_eq!(info.reason, "DUPLICATE_COMMAND");
607 assert_eq!(info.domain, "canton");
608 assert_eq!(
609 info.metadata.get("resource").map(String::as_str),
610 Some("contract-1")
611 );
612
613 assert!(
615 Error::from(tonic::Status::not_found("x"))
616 .error_info()
617 .is_none()
618 );
619 assert!(Error::Timeout.error_info().is_none());
620 }
621
622 fn canton_status(
626 code: tonic::Code,
627 category: i32,
628 delay: Option<std::time::Duration>,
629 ) -> tonic::Status {
630 use tonic_types::{ErrorDetails, StatusExt as _};
631
632 let mut metadata = std::collections::HashMap::new();
633 metadata.insert("category".to_string(), category.to_string());
634 let mut details = ErrorDetails::with_error_info("SOME_ERROR_CODE", "participant", metadata);
635 details.set_request_info("corr-1234", "");
636 if let Some(delay) = delay {
637 details.set_retry_info(Some(delay));
638 }
639 tonic::Status::with_error_details(code, "boom", details)
640 }
641
642 #[test]
643 fn the_canton_category_decides_retryability_over_the_grpc_code() {
644 use std::time::Duration;
645
646 let err = Error::from(canton_status(tonic::Code::Aborted, 10, None));
649 assert_eq!(
650 err.category(),
651 Some(ErrorCategory::InvalidGivenCurrentSystemStateResourceExists)
652 );
653 assert!(!err.is_retriable());
654
655 let err = Error::from(canton_status(
658 tonic::Code::OutOfRange,
659 12,
660 Some(Duration::from_secs(1)),
661 ));
662 assert_eq!(
663 err.category(),
664 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
665 );
666 assert!(err.is_retriable());
667 assert_eq!(err.retry_delay(), Some(Duration::from_secs(1)));
668 }
669
670 #[test]
671 fn correlation_id_and_retry_delay_are_extracted() {
672 use std::time::Duration;
673
674 let err = Error::from(canton_status(
675 tonic::Code::Unavailable,
676 1,
677 Some(Duration::from_millis(250)),
678 ));
679 assert_eq!(err.category(), Some(ErrorCategory::TransientServerFailure));
680 assert_eq!(err.correlation_id().as_deref(), Some("corr-1234"));
681 assert_eq!(err.retry_delay(), Some(Duration::from_millis(250)));
682
683 let plain = Error::from(tonic::Status::unavailable("x"));
685 assert_eq!(plain.category(), None);
686 assert_eq!(plain.correlation_id(), None);
687 assert_eq!(plain.retry_delay(), None);
688 assert_eq!(Error::Timeout.category(), None);
689 }
690
691 #[test]
692 fn statuses_without_a_category_fall_back_to_code_classification() {
693 use tonic_types::{ErrorDetails, StatusExt as _};
694
695 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
697 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
698
699 let err = Error::from(canton_status(tonic::Code::Unavailable, 99, None));
702 assert_eq!(err.category(), None);
703 assert!(err.is_retriable());
704
705 let mut details = ErrorDetails::new();
708 details.set_retry_info(Some(std::time::Duration::from_secs(2)));
709 let status =
710 tonic::Status::with_error_details(tonic::Code::FailedPrecondition, "wait", details);
711 assert!(Error::from(status).is_retriable());
712 }
713
714 #[test]
715 fn json_api_error_bodies_classify_by_category() {
716 let body = r#"{
720 "code": "OFFSET_AFTER_LEDGER_END",
721 "cause": "Begin offset (999999999) is after ledger end (23577)",
722 "correlationId": null,
723 "traceId": "36a33702b2fa7908a7349be166ccfa38",
724 "context": {"participant": "'app-provider'", "category": "12"},
725 "resources": [],
726 "errorCategory": 12,
727 "grpcCodeValue": 11,
728 "retryInfo": "1 second",
729 "definiteAnswer": null
730 }"#;
731 let err = Error::Http {
732 status: 400,
733 body: body.to_string(),
734 };
735 assert_eq!(
736 err.category(),
737 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
738 );
739 assert!(err.is_retriable());
740 assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
741 assert_eq!(
743 err.correlation_id().as_deref(),
744 Some("36a33702b2fa7908a7349be166ccfa38")
745 );
746
747 assert_eq!(err.code(), Some(tonic::Code::OutOfRange));
753 let info = err.error_info().expect("the body names the error");
754 assert_eq!(info.reason, "OFFSET_AFTER_LEDGER_END");
755 assert_eq!(
756 info.metadata.get("category").map(String::as_str),
757 Some("12")
758 );
759 assert_eq!(
760 info.metadata.get("participant").map(String::as_str),
761 Some("'app-provider'")
762 );
763
764 let err = Error::Http {
767 status: 503,
768 body: r#"{"errorCategory": 8}"#.to_string(),
769 };
770 assert_eq!(
771 err.category(),
772 Some(ErrorCategory::InvalidIndependentOfSystemState)
773 );
774 assert!(!err.is_retriable());
775 }
776
777 #[test]
778 fn non_json_http_bodies_fall_back_to_status_code_classification() {
779 let retriable = Error::Http {
780 status: 503,
781 body: "<html>Service Unavailable</html>".to_string(),
782 };
783 assert!(retriable.is_retriable());
784 assert_eq!(retriable.category(), None);
785 assert_eq!(retriable.retry_delay(), None);
786
787 let terminal = Error::Http {
788 status: 404,
789 body: String::new(),
790 };
791 assert!(!terminal.is_retriable());
792 assert_eq!(terminal.correlation_id(), None);
793 }
794
795 #[test]
796 fn spelled_durations_parse_and_garbage_is_refused() {
797 use std::time::Duration;
798 for (text, expected) in [
799 ("1 second", Duration::from_secs(1)),
800 ("5 seconds", Duration::from_secs(5)),
801 ("250 milliseconds", Duration::from_millis(250)),
802 ("2 minutes", Duration::from_secs(120)),
803 ("1 hour", Duration::from_secs(3600)),
804 ("0.5 seconds", Duration::from_millis(500)),
805 ] {
806 assert_eq!(parse_spelled_duration(text), Some(expected), "{text}");
807 }
808 for bad in [
812 "",
813 "soon",
814 "1",
815 "1 fortnight",
816 "-1 second",
817 "1 second ago",
818 "1e300 seconds",
819 "1e300 days",
820 "NaN seconds",
821 "inf seconds",
822 ] {
823 assert_eq!(parse_spelled_duration(bad), None, "{bad}");
824 }
825 }
826
827 #[test]
828 fn category_ids_round_trip_and_follow_the_docs_retryability() {
829 for id in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14] {
830 let category = ErrorCategory::from_i32(id).expect("known id");
831 assert_eq!(category.as_i32(), id);
832 assert_eq!(category.is_retriable(), matches!(id, 1 | 2 | 3 | 12));
834 }
835 assert_eq!(ErrorCategory::from_i32(0), None);
836 assert_eq!(ErrorCategory::from_i32(13), None);
838 assert_eq!(ErrorCategory::from_i32(15), None);
839 }
840
841 #[test]
850 fn a_redacted_status_yields_nothing_except_the_correlation_id() {
851 use tonic_types::{ErrorDetails, StatusExt as _};
852
853 let mut details = ErrorDetails::new();
855 details.set_request_info("93199811c5b2090c51cf45fe8c88060c", "");
856 let status = tonic::Status::with_error_details(
857 tonic::Code::Unauthenticated,
858 "An error occurred. Please contact the operator and inquire about the request \
859 93199811c5b2090c51cf45fe8c88060c",
860 details,
861 );
862 let err = Error::from(status);
863
864 assert_eq!(err.category(), None, "a redacted status has no category");
865 assert_eq!(err.error_info(), None);
866 assert!(err.resource_info().is_empty());
867 assert_eq!(err.retry_delay(), None);
868 assert_eq!(
869 err.correlation_id().as_deref(),
870 Some("93199811c5b2090c51cf45fe8c88060c"),
871 "the correlation id is the only actionable thing left"
872 );
873 assert!(!err.is_retriable());
876
877 let body = r#"{"code":"NA","cause":"An error occurred. Please contact the operator",
880 "errorCategory":-1,"retryInfo":null,"resources":[],
881 "correlationId":"41f217564e4e76f6cbc853a94a82fa80",
882 "traceId":"41f217564e4e76f6cbc853a94a82fa80"}"#;
883 let err = Error::Http {
884 status: 401,
885 body: body.to_string(),
886 };
887
888 assert_eq!(err.category(), None, "-1 is not a category");
889 assert!(err.resource_info().is_empty());
890 assert_eq!(err.retry_delay(), None);
891 assert_eq!(err.error_info(), None, "\"NA\" is not an error id");
893 assert_eq!(
894 err.correlation_id().as_deref(),
895 Some("41f217564e4e76f6cbc853a94a82fa80")
896 );
897 assert!(!err.is_retriable(), "401 is not transient");
898 }
899
900 #[test]
901 fn resource_info_names_what_the_error_is_about() {
902 use tonic_types::{ErrorDetails, StatusExt as _};
903
904 let mut details = ErrorDetails::new();
906 details.set_resource_info("ErrorResource(CONTRACT_ID)", "00abc", "alice", "not found");
907 let status = tonic::Status::with_error_details(tonic::Code::NotFound, "gone", details);
908 let found = Error::from(status).resource_info();
909 assert_eq!(found.len(), 1);
910 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
911 assert_eq!(found[0].resource_name, "00abc");
912 assert_eq!(found[0].owner, "alice");
913
914 assert!(
916 Error::from(tonic::Status::not_found("x"))
917 .resource_info()
918 .is_empty()
919 );
920 assert!(Error::Timeout.resource_info().is_empty());
921 }
922
923 #[test]
924 fn resource_info_reads_the_json_apis_resources_array() {
925 let body = r#"{"code":"CONTRACT_NOT_FOUND","cause":"…","errorCategory":11,
928 "resources":[["ErrorResource(CONTRACT_ID)","00ababab"]],"retryInfo":null}"#;
929 let err = Error::Http {
930 status: 404,
931 body: body.to_string(),
932 };
933 let found = err.resource_info();
934 assert_eq!(found.len(), 1);
935 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
936 assert_eq!(found[0].resource_name, "00ababab");
937 assert!(found[0].owner.is_empty());
939
940 let many = Error::Http {
943 status: 409,
944 body: r#"{"resources":[["A","1"],["B","2"]]}"#.to_string(),
945 };
946 assert_eq!(many.resource_info().len(), 2);
947
948 let ragged = Error::Http {
951 status: 500,
952 body: r#"{"resources":[["A"],["B","2"],42,null]}"#.to_string(),
953 };
954 assert_eq!(ragged.resource_info().len(), 1);
955 for body in [r#"{"resources":[]}"#, "{}", "not json at all", ""] {
956 let err = Error::Http {
957 status: 500,
958 body: body.to_string(),
959 };
960 assert!(err.resource_info().is_empty(), "{body}");
961 }
962 }
963
964 #[test]
965 fn transient_conditions_are_retriable() {
966 assert!(Error::Timeout.is_retriable());
967 assert!(Error::Connection("reset".to_string()).is_retriable());
968 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
969 assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
970 assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
971 assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
972 }
973
974 #[test]
975 fn transient_http_codes_are_retriable_but_client_codes_are_not() {
976 for status in [
979 408, 429, 500, 501, 502, 503, 504, 507, 509, 511, 520, 527, 599,
980 ] {
981 assert!(
982 Error::Http {
983 status,
984 body: String::new()
985 }
986 .is_retriable(),
987 "http {status} should be retriable"
988 );
989 }
990 for status in [400, 401, 403, 404, 409, 413, 422] {
992 assert!(
993 !Error::Http {
994 status,
995 body: String::new()
996 }
997 .is_retriable(),
998 "http {status} should not be retriable"
999 );
1000 }
1001 }
1002
1003 #[test]
1004 fn definite_failures_are_not_retriable() {
1005 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
1006 assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
1007 assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
1008 assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
1009 assert!(!Error::Auth("x".to_string()).is_retriable());
1010 assert!(
1011 !Error::CommandRejected {
1012 code: "GrpcStatus".to_string(),
1013 message: "boom".to_string()
1014 }
1015 .is_retriable()
1016 );
1017 assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
1018 }
1019
1020 #[test]
1021 fn code_is_exposed_only_for_status_errors() {
1022 assert_eq!(
1023 Error::from(tonic::Status::not_found("x")).code(),
1024 Some(tonic::Code::NotFound)
1025 );
1026 assert_eq!(Error::Timeout.code(), None);
1027 assert_eq!(Error::Connection("x".to_string()).code(), None);
1028 assert_eq!(
1029 Error::Http {
1030 status: 503,
1031 body: String::new()
1032 }
1033 .code(),
1034 None
1035 );
1036 }
1037
1038 #[test]
1039 fn display_messages_are_lowercase_and_informative() {
1040 assert_eq!(Error::Timeout.to_string(), "operation timed out");
1041 assert_eq!(
1042 Error::InvalidRequest("bad uri".to_string()).to_string(),
1043 "invalid request: bad uri"
1044 );
1045 assert_eq!(
1046 Error::Auth("token expired".to_string()).to_string(),
1047 "authentication failed: token expired"
1048 );
1049 assert_eq!(
1050 Error::Http {
1051 status: 503,
1052 body: "down".to_string()
1053 }
1054 .to_string(),
1055 "http 503: down"
1056 );
1057 assert_eq!(
1058 Error::CommandRejected {
1059 code: "INVALID_ARGUMENT".to_string(),
1060 message: "nope".to_string()
1061 }
1062 .to_string(),
1063 "command rejected (INVALID_ARGUMENT): nope"
1064 );
1065 }
1066}