1pub use crate::rate_limit::RateLimitSource;
4
5#[derive(Debug, thiserror::Error)]
7pub enum VtaError {
8 #[cfg(feature = "client")]
10 #[error("network error: {0}")]
11 Network(#[from] reqwest::Error),
12
13 #[error("authentication failed: {0}")]
15 Auth(String),
16
17 #[error("not found: {0}")]
19 NotFound(String),
20
21 #[error("validation error: {0}")]
23 Validation(String),
24
25 #[error("forbidden: {0}")]
27 Forbidden(String),
28
29 #[error("conflict: {0}")]
31 Conflict(String),
32
33 #[error("gone: {0}")]
38 Gone(String),
39
40 #[error("server error ({status}): {body}")]
42 Server { status: u16, body: String },
43
44 #[error("unsupported transport: {0}")]
48 UnsupportedTransport(String),
49
50 #[error("didcomm transport error: {0}")]
54 DidcommTransport(String),
55
56 #[error("tsp transport error: {0}")]
62 TspTransport(String),
63
64 #[error("didcomm remote error ({code}): {comment}")]
71 DidcommRemote { code: String, comment: String },
72
73 #[error("protocol error: {0}")]
78 Protocol(String),
79
80 #[error(
96 "consent required: {min_approvals} approval(s) from `{approver_set}` — \
97 approve code {payload_digest} on an approving device"
98 )]
99 ConsentRequired {
100 payload_digest: String,
102 challenge: String,
105 approver_set: String,
107 min_approvals: u32,
109 exclude_requester: bool,
114 },
115
116 #[error("serialization error: {0}")]
118 Serialization(#[from] serde_json::Error),
119
120 #[error("refusing operation: would leave the VTA with no advertised services")]
132 LastServiceRefused,
133
134 #[error("service is not present (not currently enabled)")]
137 ServiceNotPresent,
138
139 #[error("service is already enabled")]
142 ServiceAlreadyEnabled,
143
144 #[error("mediator handshake failed: {reason}")]
147 MediatorHandshakeFailed { reason: String },
148
149 #[error("drain ttl {requested}s outside allowed range [{min}s, {max}s]")]
154 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
155
156 #[error("no prior mutation to roll back from")]
159 NoPriorMutation,
160
161 #[error(
169 "no transport protocol in common with {counterparty_did}: \
170 we advertise {ours:?}, they advertise {theirs:?}"
171 )]
172 NoMatchingProtocol {
173 counterparty_did: String,
174 ours: Vec<crate::protocol::matching::Protocol>,
175 theirs: Vec<crate::protocol::matching::Protocol>,
176 },
177
178 #[error("{}", match .served_versions.is_empty() {
207 true => format!("peer does not serve {}", .type_uri),
208 false => format!(
209 "peer does not serve {} — it serves {}",
210 .type_uri,
211 .served_versions.join(", "),
212 ),
213 })]
214 UnsupportedTaskType {
215 type_uri: String,
217 served_versions: Vec<String>,
219 },
220
221 #[error("temporarily unavailable{}", match .retry_after {
234 Some(t) => format!(" (retry after {t})"),
235 None => String::new(),
236 })]
237 Unavailable {
238 retry_after: Option<chrono::DateTime<chrono::Utc>>,
239 },
240
241 #[error("rate limited by {limited_by}{}{}", match .retry_after {
263 Some(t) => format!(" (retry after {t})"),
264 None => String::new(),
265 }, match .url {
266 Some(u) => format!(" at {u}"),
267 None => String::new(),
268 })]
269 RateLimited {
270 limited_by: RateLimitSource,
271 retry_after: Option<chrono::DateTime<chrono::Utc>>,
272 limiter: Option<String>,
273 url: Option<String>,
274 },
275
276 #[error("{0}")]
277 Other(String),
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
299#[serde(tag = "code", rename_all = "kebab-case")]
300pub enum TypedErrorPayload {
301 LastServiceRefused,
302 ServiceNotPresent,
303 ServiceAlreadyEnabled,
304 MediatorHandshakeFailed { reason: String },
305 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
306 NoPriorMutation,
307 UnsupportedTransport { detail: String },
308}
309
310impl VtaError {
311 #[cfg(feature = "client")]
322 pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
323 match status.as_u16() {
324 429 => Self::RateLimited {
325 limited_by: RateLimitSource::Upstream,
326 retry_after: None,
327 limiter: None,
328 url: None,
329 },
330 401 => Self::Auth(body),
331 403 => Self::Forbidden(body),
332 404 => Self::NotFound(body),
333 400 | 422 => Self::Validation(body),
334 409 => Self::Conflict(body),
335 410 => Self::Gone(body),
336 s if s >= 500 => Self::Server { status: s, body },
337 s => Self::Other(format!("{s}: {body}")),
338 }
339 }
340
341 #[cfg(feature = "client")]
351 pub fn from_http_with_headers(
352 status: reqwest::StatusCode,
353 headers: &reqwest::header::HeaderMap,
354 body: String,
355 url: Option<&str>,
356 ) -> Self {
357 if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
358 return Self::from_http(status, body);
359 }
360 use crate::rate_limit as rl;
361 let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
362 let limited_by = RateLimitSource::from_source_header(header(rl::SOURCE_HEADER));
363 let now = chrono::Utc::now();
364 let retry_after = header(rl::RETRY_AFTER_HEADER)
365 .and_then(|v| rl::parse_retry_after(v, now))
366 .or_else(|| {
367 header(rl::LEGACY_RETRY_AFTER_HEADER).and_then(|v| rl::parse_retry_after(v, now))
368 });
369 Self::RateLimited {
370 limited_by,
371 retry_after,
372 limiter: limiter_from_body(limited_by, &body),
373 url: url.map(str::to_string),
374 }
375 }
376
377 #[cfg(feature = "client")]
381 pub async fn from_response(resp: reqwest::Response) -> Self {
382 let status = resp.status();
383 let headers = resp.headers().clone();
384 let url = resp.url().to_string();
385 let body = resp.text().await.unwrap_or_default();
386 Self::from_http_with_headers(status, &headers, body, Some(&url))
387 }
388
389 #[cfg(feature = "client")]
396 pub fn rate_limited_from_http(
397 status: reqwest::StatusCode,
398 headers: &reqwest::header::HeaderMap,
399 body: &str,
400 url: &str,
401 ) -> Option<Self> {
402 (status == reqwest::StatusCode::TOO_MANY_REQUESTS)
403 .then(|| Self::from_http_with_headers(status, headers, body.to_string(), Some(url)))
404 }
405
406 pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
414 use crate::protocols::problem_report_codes as c;
415 let comment = comment.into();
416 match code {
417 c::CONFLICT => Self::Conflict(comment),
418 c::NOT_FOUND => Self::NotFound(comment),
419 c::UNAUTHORIZED => Self::Auth(comment),
420 c::FORBIDDEN => Self::Forbidden(comment),
421 c::BAD_REQUEST => Self::Validation(comment),
422 c::INTERNAL => Self::Server {
423 status: 500,
424 body: comment,
425 },
426 other => Self::DidcommRemote {
427 code: other.to_string(),
428 comment,
429 },
430 }
431 }
432
433 pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
438 match payload {
439 TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
440 TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
441 TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
442 TypedErrorPayload::MediatorHandshakeFailed { reason } => {
443 Self::MediatorHandshakeFailed { reason }
444 }
445 TypedErrorPayload::DrainTtlOutOfBounds {
446 min,
447 max,
448 requested,
449 } => Self::DrainTtlOutOfBounds {
450 min,
451 max,
452 requested,
453 },
454 TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
455 TypedErrorPayload::UnsupportedTransport { detail } => {
456 Self::UnsupportedTransport(detail)
457 }
458 }
459 }
460
461 #[must_use]
467 pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
468 match self {
469 Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
470 Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
471 Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
472 Self::MediatorHandshakeFailed { reason } => {
473 Some(TypedErrorPayload::MediatorHandshakeFailed {
474 reason: reason.clone(),
475 })
476 }
477 Self::DrainTtlOutOfBounds {
478 min,
479 max,
480 requested,
481 } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
482 min: *min,
483 max: *max,
484 requested: *requested,
485 }),
486 Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
487 Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
488 detail: detail.clone(),
489 }),
490 _ => None,
491 }
492 }
493
494 pub fn is_rate_limited(&self) -> bool {
496 matches!(self, Self::RateLimited { .. })
497 }
498
499 pub fn is_gone(&self) -> bool {
501 matches!(self, Self::Gone(_))
502 }
503
504 pub fn is_conflict(&self) -> bool {
506 matches!(self, Self::Conflict(_))
507 }
508
509 pub fn is_auth(&self) -> bool {
511 matches!(self, Self::Auth(_) | Self::Forbidden(_))
512 }
513
514 pub fn is_network(&self) -> bool {
516 #[cfg(feature = "client")]
517 if matches!(self, Self::Network(_)) {
518 return true;
519 }
520 false
521 }
522
523 pub fn is_not_found(&self) -> bool {
525 matches!(self, Self::NotFound(_))
526 }
527
528 #[must_use]
543 pub fn suggested_fix(&self) -> Option<&'static str> {
544 match self {
545 Self::Auth(_) => Some(
546 "Token may be expired. Re-authenticate against the VTA, or check that \
547 the `/auth` endpoint is reachable.",
548 ),
549 Self::Forbidden(_) => Some(
550 "Your role or context access doesn't permit this operation. Inspect \
551 the ACL entry for your DID against the target context.",
552 ),
553 Self::Gone(_) => Some(
554 "The resource was single-use or time-limited and has been consumed or has \
555 expired — retrying will not succeed. If this was the bootstrap carve-out, \
556 ask an existing admin to provision-integration a new operator instead.",
557 ),
558 Self::Conflict(_) => Some(
559 "The resource already exists. Use the corresponding `update` or \
560 `delete-then-create` flow rather than `create`.",
561 ),
562 Self::Unavailable { .. } => Some(
563 "The VTA is temporarily busy — this is a wait, not a failure. If the \
564 request carried an idempotency key, an earlier attempt on that key is \
565 still running: retry with the same key and the original result will be \
566 returned rather than the operation repeated.",
567 ),
568 Self::RateLimited { limited_by, .. } => {
569 Some(crate::rate_limit::suggested_fix(*limited_by))
570 }
571 Self::Validation(_) => Some(
572 "The request body or parameters were rejected by the VTA's schema. \
573 Inspect the response body for the specific field that failed.",
574 ),
575 Self::Server { .. } => {
576 Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
577 }
578 Self::UnsupportedTransport(_) => Some(
579 "The operation requires a specific transport (REST or DIDComm). \
580 Check which mode the client is in and whether the endpoint supports it.",
581 ),
582 Self::DidcommTransport(_) => {
583 Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
584 }
585 Self::TspTransport(_) => Some(
586 "The VTA's TSP mediator is unreachable or rejected the frame. Retry, or \
587 reach the VTA over another transport: `--transport didcomm` / \
588 `--transport rest`.",
589 ),
590 #[cfg(feature = "client")]
591 Self::Network(_) => Some(
592 "Network error reaching the VTA. Confirm the URL is correct and the \
593 host is reachable.",
594 ),
595 Self::LastServiceRefused => Some(
600 "This operation would leave the VTA with no advertised transport \
601 services. Enable the other transport first (REST or DIDComm) \
602 before disabling this one.",
603 ),
604 Self::ServiceNotPresent => Some(
605 "The service kind isn't currently enabled. Use \
606 `services <kind> enable …` to bring it online before \
607 updating, disabling, or rolling it back.",
608 ),
609 Self::ServiceAlreadyEnabled => Some(
610 "The service kind is already enabled. Use \
611 `services <kind> update …` to change its configuration, \
612 or `disable` to remove it.",
613 ),
614 Self::MediatorHandshakeFailed { .. } => Some(
615 "DIDComm handshake against the candidate mediator failed. \
616 Confirm the mediator DID is correct and the mediator is \
617 reachable; check the inner reason for the specific cause.",
618 ),
619 Self::DrainTtlOutOfBounds { .. } => Some(
620 "The supplied drain TTL is outside the allowed range. Pick a \
621 value within the [min, max] interval shown in the error message.",
622 ),
623 Self::NoPriorMutation => Some(
624 "No prior mutation for this service kind to roll back from. Use \
625 the direct `enable`/`update`/`disable` command instead.",
626 ),
627 Self::UnsupportedTaskType { .. } => Some(
628 "The peer does not serve this Trust Task at the version this client \
629 dispatches. When the error names a version the peer does serve, the \
630 two are different ages and one of them needs upgrading; when it names \
631 none, check you are pointed at the agent you meant.",
632 ),
633 Self::NoMatchingProtocol { .. } => Some(
634 "The two parties share no transport protocol. Enable a common \
635 transport (TSP, DIDComm, or REST) on both sides — compare each \
636 DID document's advertised `service` entries and add the missing one.",
637 ),
638 Self::ConsentRequired { .. } => None,
646 Self::NotFound(_)
647 | Self::DidcommRemote { .. }
648 | Self::Protocol(_)
649 | Self::Serialization(_)
650 | Self::Other(_) => None,
651 }
652 }
653}
654
655#[cfg(feature = "client")]
663fn limiter_from_body(limited_by: RateLimitSource, body: &str) -> Option<String> {
664 const MAX_LIMITER_LEN: usize = 128;
665 if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
666 return v
667 .get("limiter")
668 .and_then(|l| l.as_str())
669 .map(|l| l.chars().take(MAX_LIMITER_LEN).collect());
670 }
671 let text = body.trim();
672 (limited_by != RateLimitSource::Upstream && !text.is_empty())
673 .then(|| text.chars().take(MAX_LIMITER_LEN).collect())
674}
675
676impl From<crate::did_key::DidKeyError> for VtaError {
677 fn from(e: crate::did_key::DidKeyError) -> Self {
678 Self::Validation(e.to_string())
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[cfg(feature = "client")]
687 #[test]
688 fn from_http_410_maps_to_gone() {
689 let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
690 assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
691 }
692
693 #[cfg(feature = "client")]
694 fn headers(pairs: &[(&'static str, &str)]) -> reqwest::header::HeaderMap {
695 let mut h = reqwest::header::HeaderMap::new();
696 for (k, v) in pairs {
697 h.insert(*k, v.parse().unwrap());
698 }
699 h
700 }
701
702 #[cfg(feature = "client")]
703 #[test]
704 fn a_labelled_429_is_attributed_to_the_vta_with_its_wait() {
705 let before = chrono::Utc::now();
706 let err = VtaError::from_http_with_headers(
707 reqwest::StatusCode::TOO_MANY_REQUESTS,
708 &headers(&[("x-rate-limit-source", "vta"), ("retry-after", "4")]),
709 r#"{"error":"rate limited","limiter":"auth"}"#.into(),
710 Some("https://vta.example.com/auth/challenge"),
711 );
712 let VtaError::RateLimited {
713 limited_by,
714 retry_after,
715 limiter,
716 url,
717 } = &err
718 else {
719 panic!("429 must map to RateLimited, got {err:?}");
720 };
721 assert_eq!(*limited_by, RateLimitSource::Vta);
722 let wait = retry_after.expect("Retry-After must be kept") - before;
723 assert!(
724 (3..=5).contains(&wait.num_seconds()),
725 "retry_after should be ~4s out, was {wait}"
726 );
727 assert_eq!(limiter.as_deref(), Some("auth"));
728 assert_eq!(
729 url.as_deref(),
730 Some("https://vta.example.com/auth/challenge")
731 );
732 assert!(err.is_rate_limited());
733 assert!(
734 err.suggested_fix()
735 .unwrap()
736 .contains("rate_limit_interval_secs"),
737 "a VTA refusal must point at the VTA's knobs"
738 );
739 }
740
741 #[cfg(feature = "client")]
742 #[test]
743 fn a_plain_text_body_from_a_labelled_vta_names_the_limiter() {
744 let err = VtaError::from_http_with_headers(
745 reqwest::StatusCode::TOO_MANY_REQUESTS,
746 &headers(&[("x-rate-limit-source", "vta")]),
747 "did-log".into(),
748 None,
749 );
750 assert!(
751 matches!(&err, VtaError::RateLimited { limiter: Some(l), .. } if l == "did-log"),
752 "got {err:?}"
753 );
754 }
755
756 #[cfg(feature = "client")]
757 #[test]
758 fn an_unlabelled_429_is_upstream_and_keeps_the_legacy_wait_hint() {
759 let err = VtaError::from_http_with_headers(
760 reqwest::StatusCode::TOO_MANY_REQUESTS,
761 &headers(&[("x-ratelimit-after", "4")]),
763 "Too Many Requests! Wait for 4s".into(),
764 Some("https://vta.example.com/auth/challenge"),
765 );
766 let VtaError::RateLimited {
767 limited_by,
768 retry_after,
769 limiter,
770 ..
771 } = &err
772 else {
773 panic!("got {err:?}");
774 };
775 assert_eq!(*limited_by, RateLimitSource::Upstream);
776 assert!(retry_after.is_some(), "the legacy header is still a hint");
777 assert_eq!(
778 *limiter, None,
779 "a proxy's body names no limiter and must not be promoted to one"
780 );
781 assert!(
782 err.suggested_fix().unwrap().contains("proxy"),
783 "an unattributable 429 must send the operator to the proxy, not only the VTA"
784 );
785 }
786
787 #[cfg(feature = "client")]
788 #[test]
789 fn retry_after_http_date_is_read() {
790 let err = VtaError::from_http_with_headers(
791 reqwest::StatusCode::TOO_MANY_REQUESTS,
792 &headers(&[
793 ("x-rate-limit-source", "vta"),
794 ("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT"),
795 ]),
796 String::new(),
797 None,
798 );
799 let VtaError::RateLimited { retry_after, .. } = err else {
800 panic!("got {err:?}")
801 };
802 assert_eq!(
803 retry_after.map(|t| t.to_rfc3339()),
804 Some("2015-10-21T07:28:00+00:00".to_string())
805 );
806 }
807
808 #[cfg(feature = "client")]
809 #[test]
810 fn from_http_without_headers_still_types_a_429() {
811 let err = VtaError::from_http(
812 reqwest::StatusCode::TOO_MANY_REQUESTS,
813 "Too Many Requests! Wait for 4s".into(),
814 );
815 assert!(
816 matches!(
817 err,
818 VtaError::RateLimited {
819 limited_by: RateLimitSource::Upstream,
820 retry_after: None,
821 ..
822 }
823 ),
824 "got {err:?}"
825 );
826 }
827
828 #[cfg(feature = "client")]
829 #[test]
830 fn other_statuses_are_unchanged_by_the_header_aware_constructor() {
831 let err = VtaError::from_http_with_headers(
832 reqwest::StatusCode::GONE,
833 &headers(&[("x-rate-limit-source", "vta")]),
834 "carve-out closed".into(),
835 None,
836 );
837 assert!(err.is_gone(), "got {err:?}");
838 assert!(
839 VtaError::rate_limited_from_http(
840 reqwest::StatusCode::UNAUTHORIZED,
841 &headers(&[]),
842 "",
843 "https://vta.example.com/auth/"
844 )
845 .is_none()
846 );
847 }
848
849 #[test]
850 fn problem_report_conflict_maps_to_typed_conflict() {
851 let err = VtaError::from_problem_report(
852 crate::protocols::problem_report_codes::CONFLICT,
853 "key id already exists",
854 );
855 assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
856 assert!(err.is_conflict());
857 }
858
859 #[test]
860 fn problem_report_unknown_code_lands_in_didcomm_remote() {
861 let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
862 match err {
863 VtaError::DidcommRemote { code, comment } => {
864 assert_eq!(code, "e.custom.xyz");
865 assert_eq!(comment, "weird thing");
866 }
867 other => panic!("expected DidcommRemote, got {other:?}"),
868 }
869 }
870
871 #[test]
872 fn suggested_fix_present_for_actionable_variants() {
873 assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
877 assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
878 assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
879 assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
880 assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
881 assert!(
882 VtaError::Server {
883 status: 500,
884 body: "boom".into(),
885 }
886 .suggested_fix()
887 .is_some()
888 );
889 assert!(
890 VtaError::UnsupportedTransport("rest only".into())
891 .suggested_fix()
892 .is_some()
893 );
894 assert!(
895 VtaError::DidcommTransport("offline".into())
896 .suggested_fix()
897 .is_some()
898 );
899
900 assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
902 assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
903 assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
904 assert!(
905 VtaError::MediatorHandshakeFailed {
906 reason: "trust-ping timeout".into()
907 }
908 .suggested_fix()
909 .is_some()
910 );
911 assert!(
912 VtaError::DrainTtlOutOfBounds {
913 min: 3600,
914 max: 2_592_000,
915 requested: 30,
916 }
917 .suggested_fix()
918 .is_some()
919 );
920 assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
921
922 assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
924 assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
925 assert!(
926 VtaError::DidcommRemote {
927 code: "e.unknown".into(),
928 comment: "x".into()
929 }
930 .suggested_fix()
931 .is_none()
932 );
933 }
934
935 #[test]
939 fn typed_payload_round_trips_every_runtime_service_variant() {
940 let cases: Vec<VtaError> = vec![
941 VtaError::LastServiceRefused,
942 VtaError::ServiceNotPresent,
943 VtaError::ServiceAlreadyEnabled,
944 VtaError::MediatorHandshakeFailed {
945 reason: "trust-ping timeout after 10s".into(),
946 },
947 VtaError::DrainTtlOutOfBounds {
948 min: 3600,
949 max: 2_592_000,
950 requested: 30,
951 },
952 VtaError::NoPriorMutation,
953 VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
954 ];
955
956 for original in cases {
957 let payload = original.to_typed_payload().unwrap_or_else(|| {
958 panic!("variant must project to TypedErrorPayload: {original:?}")
959 });
960
961 let json = serde_json::to_string(&payload)
964 .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
965 let restored: TypedErrorPayload = serde_json::from_str(&json)
966 .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
967
968 assert_eq!(
969 payload, restored,
970 "TypedErrorPayload must round-trip through JSON",
971 );
972
973 let reconstructed = VtaError::from_typed_payload(restored);
976 match (&original, &reconstructed) {
977 (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
978 | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
979 | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
980 | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
981 (
982 VtaError::MediatorHandshakeFailed { reason: a },
983 VtaError::MediatorHandshakeFailed { reason: b },
984 ) => assert_eq!(a, b),
985 (
986 VtaError::DrainTtlOutOfBounds {
987 min: m1,
988 max: x1,
989 requested: r1,
990 },
991 VtaError::DrainTtlOutOfBounds {
992 min: m2,
993 max: x2,
994 requested: r2,
995 },
996 ) => {
997 assert_eq!(m1, m2);
998 assert_eq!(x1, x2);
999 assert_eq!(r1, r2);
1000 }
1001 (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
1002 assert_eq!(a, b)
1003 }
1004 (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
1005 }
1006 }
1007 }
1008
1009 #[test]
1014 fn typed_payload_wire_discriminator_is_kebab_case() {
1015 let payload = TypedErrorPayload::DrainTtlOutOfBounds {
1016 min: 3600,
1017 max: 2_592_000,
1018 requested: 30,
1019 };
1020 let json = serde_json::to_value(&payload).unwrap();
1021 assert_eq!(json["code"], "drain-ttl-out-of-bounds");
1022 assert_eq!(json["min"], 3600);
1023 assert_eq!(json["max"], 2_592_000);
1024 assert_eq!(json["requested"], 30);
1025 }
1026
1027 #[test]
1032 fn typed_payload_is_none_for_non_service_management_variants() {
1033 assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
1034 assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
1035 assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
1036 assert!(
1037 VtaError::Server {
1038 status: 500,
1039 body: "x".into(),
1040 }
1041 .to_typed_payload()
1042 .is_none()
1043 );
1044 assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
1045 assert!(
1046 VtaError::DidcommRemote {
1047 code: "e.x".into(),
1048 comment: "x".into()
1049 }
1050 .to_typed_payload()
1051 .is_none()
1052 );
1053 assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
1054 }
1055}