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 ErrorCategory {
463 #[must_use]
465 pub const fn from_i32(id: i32) -> Option<Self> {
466 Some(match id {
467 1 => Self::TransientServerFailure,
468 2 => Self::ContentionOnSharedResources,
469 3 => Self::DeadlineExceededRequestStateUnknown,
470 4 => Self::SystemInternalAssumptionViolated,
471 5 => Self::SecurityAlert,
472 6 => Self::AuthInterceptorInvalidAuthenticationCredentials,
473 7 => Self::InsufficientPermission,
474 8 => Self::InvalidIndependentOfSystemState,
475 9 => Self::InvalidGivenCurrentSystemStateOther,
476 10 => Self::InvalidGivenCurrentSystemStateResourceExists,
477 11 => Self::InvalidGivenCurrentSystemStateResourceMissing,
478 12 => Self::InvalidGivenCurrentSystemStateSeekAfterEnd,
479 14 => Self::InternalUnsupportedOperation,
480 _ => return None,
481 })
482 }
483
484 #[must_use]
486 pub const fn as_i32(self) -> i32 {
487 match self {
488 Self::TransientServerFailure => 1,
489 Self::ContentionOnSharedResources => 2,
490 Self::DeadlineExceededRequestStateUnknown => 3,
491 Self::SystemInternalAssumptionViolated => 4,
492 Self::SecurityAlert => 5,
493 Self::AuthInterceptorInvalidAuthenticationCredentials => 6,
494 Self::InsufficientPermission => 7,
495 Self::InvalidIndependentOfSystemState => 8,
496 Self::InvalidGivenCurrentSystemStateOther => 9,
497 Self::InvalidGivenCurrentSystemStateResourceExists => 10,
498 Self::InvalidGivenCurrentSystemStateResourceMissing => 11,
499 Self::InvalidGivenCurrentSystemStateSeekAfterEnd => 12,
500 Self::InternalUnsupportedOperation => 14,
501 }
502 }
503
504 #[must_use]
508 pub const fn is_retriable(self) -> bool {
509 matches!(
510 self,
511 Self::TransientServerFailure
512 | Self::ContentionOnSharedResources
513 | Self::DeadlineExceededRequestStateUnknown
514 | Self::InvalidGivenCurrentSystemStateSeekAfterEnd
515 )
516 }
517}
518
519#[derive(Clone, Debug, Default, PartialEq, Eq)]
522#[non_exhaustive]
523pub struct ErrorInfo {
524 pub reason: String,
526 pub domain: String,
528 pub metadata: std::collections::HashMap<String, String>,
530}
531
532#[derive(Clone, Debug, Default, PartialEq, Eq)]
536#[non_exhaustive]
537pub struct ResourceInfo {
538 pub resource_type: String,
541 pub resource_name: String,
543 pub owner: String,
546 pub description: String,
549}
550
551impl From<tonic::Status> for Error {
552 fn from(status: tonic::Status) -> Self {
553 Error::Status(Box::new(status))
554 }
555}
556
557impl From<tonic::transport::Error> for Error {
558 fn from(err: tonic::transport::Error) -> Self {
559 Error::Transport(Box::new(err))
560 }
561}
562
563impl From<serde_json::Error> for Error {
564 fn from(err: serde_json::Error) -> Self {
565 Error::Json(Box::new(err))
566 }
567}
568
569pub type Result<T, E = Error> = std::result::Result<T, E>;
571
572#[cfg(test)]
573#[allow(clippy::unwrap_used, clippy::expect_used)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
579 use tonic_types::{ErrorDetails, StatusExt as _};
580
581 let mut metadata = std::collections::HashMap::new();
582 metadata.insert("resource".to_string(), "contract-1".to_string());
583 let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
584 let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
585
586 let info = Error::from(status)
587 .error_info()
588 .expect("error info present");
589 assert_eq!(info.reason, "DUPLICATE_COMMAND");
590 assert_eq!(info.domain, "canton");
591 assert_eq!(
592 info.metadata.get("resource").map(String::as_str),
593 Some("contract-1")
594 );
595
596 assert!(
598 Error::from(tonic::Status::not_found("x"))
599 .error_info()
600 .is_none()
601 );
602 assert!(Error::Timeout.error_info().is_none());
603 }
604
605 fn canton_status(
609 code: tonic::Code,
610 category: i32,
611 delay: Option<std::time::Duration>,
612 ) -> tonic::Status {
613 use tonic_types::{ErrorDetails, StatusExt as _};
614
615 let mut metadata = std::collections::HashMap::new();
616 metadata.insert("category".to_string(), category.to_string());
617 let mut details = ErrorDetails::with_error_info("SOME_ERROR_CODE", "participant", metadata);
618 details.set_request_info("corr-1234", "");
619 if let Some(delay) = delay {
620 details.set_retry_info(Some(delay));
621 }
622 tonic::Status::with_error_details(code, "boom", details)
623 }
624
625 #[test]
626 fn the_canton_category_decides_retryability_over_the_grpc_code() {
627 use std::time::Duration;
628
629 let err = Error::from(canton_status(tonic::Code::Aborted, 10, None));
632 assert_eq!(
633 err.category(),
634 Some(ErrorCategory::InvalidGivenCurrentSystemStateResourceExists)
635 );
636 assert!(!err.is_retriable());
637
638 let err = Error::from(canton_status(
641 tonic::Code::OutOfRange,
642 12,
643 Some(Duration::from_secs(1)),
644 ));
645 assert_eq!(
646 err.category(),
647 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
648 );
649 assert!(err.is_retriable());
650 assert_eq!(err.retry_delay(), Some(Duration::from_secs(1)));
651 }
652
653 #[test]
654 fn correlation_id_and_retry_delay_are_extracted() {
655 use std::time::Duration;
656
657 let err = Error::from(canton_status(
658 tonic::Code::Unavailable,
659 1,
660 Some(Duration::from_millis(250)),
661 ));
662 assert_eq!(err.category(), Some(ErrorCategory::TransientServerFailure));
663 assert_eq!(err.correlation_id().as_deref(), Some("corr-1234"));
664 assert_eq!(err.retry_delay(), Some(Duration::from_millis(250)));
665
666 let plain = Error::from(tonic::Status::unavailable("x"));
668 assert_eq!(plain.category(), None);
669 assert_eq!(plain.correlation_id(), None);
670 assert_eq!(plain.retry_delay(), None);
671 assert_eq!(Error::Timeout.category(), None);
672 }
673
674 #[test]
675 fn statuses_without_a_category_fall_back_to_code_classification() {
676 use tonic_types::{ErrorDetails, StatusExt as _};
677
678 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
680 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
681
682 let err = Error::from(canton_status(tonic::Code::Unavailable, 99, None));
685 assert_eq!(err.category(), None);
686 assert!(err.is_retriable());
687
688 let mut details = ErrorDetails::new();
691 details.set_retry_info(Some(std::time::Duration::from_secs(2)));
692 let status =
693 tonic::Status::with_error_details(tonic::Code::FailedPrecondition, "wait", details);
694 assert!(Error::from(status).is_retriable());
695 }
696
697 #[test]
698 fn json_api_error_bodies_classify_by_category() {
699 let body = r#"{
703 "code": "OFFSET_AFTER_LEDGER_END",
704 "cause": "Begin offset (999999999) is after ledger end (23577)",
705 "correlationId": null,
706 "traceId": "36a33702b2fa7908a7349be166ccfa38",
707 "context": {"participant": "'app-provider'", "category": "12"},
708 "resources": [],
709 "errorCategory": 12,
710 "grpcCodeValue": 11,
711 "retryInfo": "1 second",
712 "definiteAnswer": null
713 }"#;
714 let err = Error::Http {
715 status: 400,
716 body: body.to_string(),
717 };
718 assert_eq!(
719 err.category(),
720 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
721 );
722 assert!(err.is_retriable());
723 assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
724 assert_eq!(
726 err.correlation_id().as_deref(),
727 Some("36a33702b2fa7908a7349be166ccfa38")
728 );
729
730 assert_eq!(err.code(), Some(tonic::Code::OutOfRange));
736 let info = err.error_info().expect("the body names the error");
737 assert_eq!(info.reason, "OFFSET_AFTER_LEDGER_END");
738 assert_eq!(
739 info.metadata.get("category").map(String::as_str),
740 Some("12")
741 );
742 assert_eq!(
743 info.metadata.get("participant").map(String::as_str),
744 Some("'app-provider'")
745 );
746
747 let err = Error::Http {
750 status: 503,
751 body: r#"{"errorCategory": 8}"#.to_string(),
752 };
753 assert_eq!(
754 err.category(),
755 Some(ErrorCategory::InvalidIndependentOfSystemState)
756 );
757 assert!(!err.is_retriable());
758 }
759
760 #[test]
761 fn non_json_http_bodies_fall_back_to_status_code_classification() {
762 let retriable = Error::Http {
763 status: 503,
764 body: "<html>Service Unavailable</html>".to_string(),
765 };
766 assert!(retriable.is_retriable());
767 assert_eq!(retriable.category(), None);
768 assert_eq!(retriable.retry_delay(), None);
769
770 let terminal = Error::Http {
771 status: 404,
772 body: String::new(),
773 };
774 assert!(!terminal.is_retriable());
775 assert_eq!(terminal.correlation_id(), None);
776 }
777
778 #[test]
779 fn spelled_durations_parse_and_garbage_is_refused() {
780 use std::time::Duration;
781 for (text, expected) in [
782 ("1 second", Duration::from_secs(1)),
783 ("5 seconds", Duration::from_secs(5)),
784 ("250 milliseconds", Duration::from_millis(250)),
785 ("2 minutes", Duration::from_secs(120)),
786 ("1 hour", Duration::from_secs(3600)),
787 ("0.5 seconds", Duration::from_millis(500)),
788 ] {
789 assert_eq!(parse_spelled_duration(text), Some(expected), "{text}");
790 }
791 for bad in [
795 "",
796 "soon",
797 "1",
798 "1 fortnight",
799 "-1 second",
800 "1 second ago",
801 "1e300 seconds",
802 "1e300 days",
803 "NaN seconds",
804 "inf seconds",
805 ] {
806 assert_eq!(parse_spelled_duration(bad), None, "{bad}");
807 }
808 }
809
810 #[test]
811 fn category_ids_round_trip_and_follow_the_docs_retryability() {
812 for id in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14] {
813 let category = ErrorCategory::from_i32(id).expect("known id");
814 assert_eq!(category.as_i32(), id);
815 assert_eq!(category.is_retriable(), matches!(id, 1 | 2 | 3 | 12));
817 }
818 assert_eq!(ErrorCategory::from_i32(0), None);
819 assert_eq!(ErrorCategory::from_i32(13), None);
821 assert_eq!(ErrorCategory::from_i32(15), None);
822 }
823
824 #[test]
833 fn a_redacted_status_yields_nothing_except_the_correlation_id() {
834 use tonic_types::{ErrorDetails, StatusExt as _};
835
836 let mut details = ErrorDetails::new();
838 details.set_request_info("93199811c5b2090c51cf45fe8c88060c", "");
839 let status = tonic::Status::with_error_details(
840 tonic::Code::Unauthenticated,
841 "An error occurred. Please contact the operator and inquire about the request \
842 93199811c5b2090c51cf45fe8c88060c",
843 details,
844 );
845 let err = Error::from(status);
846
847 assert_eq!(err.category(), None, "a redacted status has no category");
848 assert_eq!(err.error_info(), None);
849 assert!(err.resource_info().is_empty());
850 assert_eq!(err.retry_delay(), None);
851 assert_eq!(
852 err.correlation_id().as_deref(),
853 Some("93199811c5b2090c51cf45fe8c88060c"),
854 "the correlation id is the only actionable thing left"
855 );
856 assert!(!err.is_retriable());
859
860 let body = r#"{"code":"NA","cause":"An error occurred. Please contact the operator",
863 "errorCategory":-1,"retryInfo":null,"resources":[],
864 "correlationId":"41f217564e4e76f6cbc853a94a82fa80",
865 "traceId":"41f217564e4e76f6cbc853a94a82fa80"}"#;
866 let err = Error::Http {
867 status: 401,
868 body: body.to_string(),
869 };
870
871 assert_eq!(err.category(), None, "-1 is not a category");
872 assert!(err.resource_info().is_empty());
873 assert_eq!(err.retry_delay(), None);
874 assert_eq!(err.error_info(), None, "\"NA\" is not an error id");
876 assert_eq!(
877 err.correlation_id().as_deref(),
878 Some("41f217564e4e76f6cbc853a94a82fa80")
879 );
880 assert!(!err.is_retriable(), "401 is not transient");
881 }
882
883 #[test]
884 fn resource_info_names_what_the_error_is_about() {
885 use tonic_types::{ErrorDetails, StatusExt as _};
886
887 let mut details = ErrorDetails::new();
889 details.set_resource_info("ErrorResource(CONTRACT_ID)", "00abc", "alice", "not found");
890 let status = tonic::Status::with_error_details(tonic::Code::NotFound, "gone", details);
891 let found = Error::from(status).resource_info();
892 assert_eq!(found.len(), 1);
893 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
894 assert_eq!(found[0].resource_name, "00abc");
895 assert_eq!(found[0].owner, "alice");
896
897 assert!(
899 Error::from(tonic::Status::not_found("x"))
900 .resource_info()
901 .is_empty()
902 );
903 assert!(Error::Timeout.resource_info().is_empty());
904 }
905
906 #[test]
907 fn resource_info_reads_the_json_apis_resources_array() {
908 let body = r#"{"code":"CONTRACT_NOT_FOUND","cause":"…","errorCategory":11,
911 "resources":[["ErrorResource(CONTRACT_ID)","00ababab"]],"retryInfo":null}"#;
912 let err = Error::Http {
913 status: 404,
914 body: body.to_string(),
915 };
916 let found = err.resource_info();
917 assert_eq!(found.len(), 1);
918 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
919 assert_eq!(found[0].resource_name, "00ababab");
920 assert!(found[0].owner.is_empty());
922
923 let many = Error::Http {
926 status: 409,
927 body: r#"{"resources":[["A","1"],["B","2"]]}"#.to_string(),
928 };
929 assert_eq!(many.resource_info().len(), 2);
930
931 let ragged = Error::Http {
934 status: 500,
935 body: r#"{"resources":[["A"],["B","2"],42,null]}"#.to_string(),
936 };
937 assert_eq!(ragged.resource_info().len(), 1);
938 for body in [r#"{"resources":[]}"#, "{}", "not json at all", ""] {
939 let err = Error::Http {
940 status: 500,
941 body: body.to_string(),
942 };
943 assert!(err.resource_info().is_empty(), "{body}");
944 }
945 }
946
947 #[test]
948 fn transient_conditions_are_retriable() {
949 assert!(Error::Timeout.is_retriable());
950 assert!(Error::Connection("reset".to_string()).is_retriable());
951 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
952 assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
953 assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
954 assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
955 }
956
957 #[test]
958 fn transient_http_codes_are_retriable_but_client_codes_are_not() {
959 for status in [
962 408, 429, 500, 501, 502, 503, 504, 507, 509, 511, 520, 527, 599,
963 ] {
964 assert!(
965 Error::Http {
966 status,
967 body: String::new()
968 }
969 .is_retriable(),
970 "http {status} should be retriable"
971 );
972 }
973 for status in [400, 401, 403, 404, 409, 413, 422] {
975 assert!(
976 !Error::Http {
977 status,
978 body: String::new()
979 }
980 .is_retriable(),
981 "http {status} should not be retriable"
982 );
983 }
984 }
985
986 #[test]
987 fn definite_failures_are_not_retriable() {
988 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
989 assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
990 assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
991 assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
992 assert!(!Error::Auth("x".to_string()).is_retriable());
993 assert!(
994 !Error::CommandRejected {
995 code: "GrpcStatus".to_string(),
996 message: "boom".to_string()
997 }
998 .is_retriable()
999 );
1000 assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
1001 }
1002
1003 #[test]
1004 fn code_is_exposed_only_for_status_errors() {
1005 assert_eq!(
1006 Error::from(tonic::Status::not_found("x")).code(),
1007 Some(tonic::Code::NotFound)
1008 );
1009 assert_eq!(Error::Timeout.code(), None);
1010 assert_eq!(Error::Connection("x".to_string()).code(), None);
1011 assert_eq!(
1012 Error::Http {
1013 status: 503,
1014 body: String::new()
1015 }
1016 .code(),
1017 None
1018 );
1019 }
1020
1021 #[test]
1022 fn display_messages_are_lowercase_and_informative() {
1023 assert_eq!(Error::Timeout.to_string(), "operation timed out");
1024 assert_eq!(
1025 Error::InvalidRequest("bad uri".to_string()).to_string(),
1026 "invalid request: bad uri"
1027 );
1028 assert_eq!(
1029 Error::Auth("token expired".to_string()).to_string(),
1030 "authentication failed: token expired"
1031 );
1032 assert_eq!(
1033 Error::Http {
1034 status: 503,
1035 body: "down".to_string()
1036 }
1037 .to_string(),
1038 "http 503: down"
1039 );
1040 assert_eq!(
1041 Error::CommandRejected {
1042 code: "INVALID_ARGUMENT".to_string(),
1043 message: "nope".to_string()
1044 }
1045 .to_string(),
1046 "command rejected (INVALID_ARGUMENT): nope"
1047 );
1048 }
1049}