1#[derive(Debug, thiserror::Error)]
5pub enum VtaError {
6 #[cfg(feature = "client")]
8 #[error("network error: {0}")]
9 Network(#[from] reqwest::Error),
10
11 #[error("authentication failed: {0}")]
13 Auth(String),
14
15 #[error("not found: {0}")]
17 NotFound(String),
18
19 #[error("validation error: {0}")]
21 Validation(String),
22
23 #[error("forbidden: {0}")]
25 Forbidden(String),
26
27 #[error("conflict: {0}")]
29 Conflict(String),
30
31 #[error("gone: {0}")]
36 Gone(String),
37
38 #[error("server error ({status}): {body}")]
40 Server { status: u16, body: String },
41
42 #[error("unsupported transport: {0}")]
46 UnsupportedTransport(String),
47
48 #[error("didcomm transport error: {0}")]
52 DidcommTransport(String),
53
54 #[error("tsp transport error: {0}")]
60 TspTransport(String),
61
62 #[error("didcomm remote error ({code}): {comment}")]
69 DidcommRemote { code: String, comment: String },
70
71 #[error("protocol error: {0}")]
76 Protocol(String),
77
78 #[error("serialization error: {0}")]
80 Serialization(#[from] serde_json::Error),
81
82 #[error("refusing operation: would leave the VTA with no advertised services")]
94 LastServiceRefused,
95
96 #[error("service is not present (not currently enabled)")]
99 ServiceNotPresent,
100
101 #[error("service is already enabled")]
104 ServiceAlreadyEnabled,
105
106 #[error("mediator handshake failed: {reason}")]
109 MediatorHandshakeFailed { reason: String },
110
111 #[error("drain ttl {requested}s outside allowed range [{min}s, {max}s]")]
116 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
117
118 #[error("no prior mutation to roll back from")]
121 NoPriorMutation,
122
123 #[error(
131 "no transport protocol in common with {counterparty_did}: \
132 we advertise {ours:?}, they advertise {theirs:?}"
133 )]
134 NoMatchingProtocol {
135 counterparty_did: String,
136 ours: Vec<crate::protocol::matching::Protocol>,
137 theirs: Vec<crate::protocol::matching::Protocol>,
138 },
139
140 #[error("{0}")]
141 Other(String),
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163#[serde(tag = "code", rename_all = "kebab-case")]
164pub enum TypedErrorPayload {
165 LastServiceRefused,
166 ServiceNotPresent,
167 ServiceAlreadyEnabled,
168 MediatorHandshakeFailed { reason: String },
169 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
170 NoPriorMutation,
171 UnsupportedTransport { detail: String },
172}
173
174impl VtaError {
175 #[cfg(feature = "client")]
181 pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
182 match status.as_u16() {
183 401 => Self::Auth(body),
184 403 => Self::Forbidden(body),
185 404 => Self::NotFound(body),
186 400 | 422 => Self::Validation(body),
187 409 => Self::Conflict(body),
188 410 => Self::Gone(body),
189 s if s >= 500 => Self::Server { status: s, body },
190 s => Self::Other(format!("{s}: {body}")),
191 }
192 }
193
194 pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
202 use crate::protocols::problem_report_codes as c;
203 let comment = comment.into();
204 match code {
205 c::CONFLICT => Self::Conflict(comment),
206 c::NOT_FOUND => Self::NotFound(comment),
207 c::UNAUTHORIZED => Self::Auth(comment),
208 c::FORBIDDEN => Self::Forbidden(comment),
209 c::BAD_REQUEST => Self::Validation(comment),
210 c::INTERNAL => Self::Server {
211 status: 500,
212 body: comment,
213 },
214 other => Self::DidcommRemote {
215 code: other.to_string(),
216 comment,
217 },
218 }
219 }
220
221 pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
226 match payload {
227 TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
228 TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
229 TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
230 TypedErrorPayload::MediatorHandshakeFailed { reason } => {
231 Self::MediatorHandshakeFailed { reason }
232 }
233 TypedErrorPayload::DrainTtlOutOfBounds {
234 min,
235 max,
236 requested,
237 } => Self::DrainTtlOutOfBounds {
238 min,
239 max,
240 requested,
241 },
242 TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
243 TypedErrorPayload::UnsupportedTransport { detail } => {
244 Self::UnsupportedTransport(detail)
245 }
246 }
247 }
248
249 #[must_use]
255 pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
256 match self {
257 Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
258 Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
259 Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
260 Self::MediatorHandshakeFailed { reason } => {
261 Some(TypedErrorPayload::MediatorHandshakeFailed {
262 reason: reason.clone(),
263 })
264 }
265 Self::DrainTtlOutOfBounds {
266 min,
267 max,
268 requested,
269 } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
270 min: *min,
271 max: *max,
272 requested: *requested,
273 }),
274 Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
275 Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
276 detail: detail.clone(),
277 }),
278 _ => None,
279 }
280 }
281
282 pub fn is_gone(&self) -> bool {
284 matches!(self, Self::Gone(_))
285 }
286
287 pub fn is_conflict(&self) -> bool {
289 matches!(self, Self::Conflict(_))
290 }
291
292 pub fn is_auth(&self) -> bool {
294 matches!(self, Self::Auth(_) | Self::Forbidden(_))
295 }
296
297 pub fn is_network(&self) -> bool {
299 #[cfg(feature = "client")]
300 if matches!(self, Self::Network(_)) {
301 return true;
302 }
303 false
304 }
305
306 pub fn is_not_found(&self) -> bool {
308 matches!(self, Self::NotFound(_))
309 }
310
311 #[must_use]
326 pub fn suggested_fix(&self) -> Option<&'static str> {
327 match self {
328 Self::Auth(_) => Some(
329 "Token may be expired. Re-authenticate against the VTA, or check that \
330 the `/auth` endpoint is reachable.",
331 ),
332 Self::Forbidden(_) => Some(
333 "Your role or context access doesn't permit this operation. Inspect \
334 the ACL entry for your DID against the target context.",
335 ),
336 Self::Gone(_) => Some(
337 "The resource has been permanently consumed. The single-use bootstrap \
338 carve-out has likely already been used; ask an existing admin to \
339 provision-integration a new operator instead.",
340 ),
341 Self::Conflict(_) => Some(
342 "The resource already exists. Use the corresponding `update` or \
343 `delete-then-create` flow rather than `create`.",
344 ),
345 Self::Validation(_) => Some(
346 "The request body or parameters were rejected by the VTA's schema. \
347 Inspect the response body for the specific field that failed.",
348 ),
349 Self::Server { .. } => {
350 Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
351 }
352 Self::UnsupportedTransport(_) => Some(
353 "The operation requires a specific transport (REST or DIDComm). \
354 Check which mode the client is in and whether the endpoint supports it.",
355 ),
356 Self::DidcommTransport(_) => {
357 Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
358 }
359 Self::TspTransport(_) => Some(
360 "The VTA's TSP mediator is unreachable or rejected the frame. Retry, or \
361 reach the VTA over another transport: `--transport didcomm` / \
362 `--transport rest`.",
363 ),
364 #[cfg(feature = "client")]
365 Self::Network(_) => Some(
366 "Network error reaching the VTA. Confirm the URL is correct and the \
367 host is reachable.",
368 ),
369 Self::LastServiceRefused => Some(
374 "This operation would leave the VTA with no advertised transport \
375 services. Enable the other transport first (REST or DIDComm) \
376 before disabling this one.",
377 ),
378 Self::ServiceNotPresent => Some(
379 "The service kind isn't currently enabled. Use \
380 `services <kind> enable …` to bring it online before \
381 updating, disabling, or rolling it back.",
382 ),
383 Self::ServiceAlreadyEnabled => Some(
384 "The service kind is already enabled. Use \
385 `services <kind> update …` to change its configuration, \
386 or `disable` to remove it.",
387 ),
388 Self::MediatorHandshakeFailed { .. } => Some(
389 "DIDComm handshake against the candidate mediator failed. \
390 Confirm the mediator DID is correct and the mediator is \
391 reachable; check the inner reason for the specific cause.",
392 ),
393 Self::DrainTtlOutOfBounds { .. } => Some(
394 "The supplied drain TTL is outside the allowed range. Pick a \
395 value within the [min, max] interval shown in the error message.",
396 ),
397 Self::NoPriorMutation => Some(
398 "No prior mutation for this service kind to roll back from. Use \
399 the direct `enable`/`update`/`disable` command instead.",
400 ),
401 Self::NoMatchingProtocol { .. } => Some(
402 "The two parties share no transport protocol. Enable a common \
403 transport (TSP, DIDComm, or REST) on both sides — compare each \
404 DID document's advertised `service` entries and add the missing one.",
405 ),
406 Self::NotFound(_)
410 | Self::DidcommRemote { .. }
411 | Self::Protocol(_)
412 | Self::Serialization(_)
413 | Self::Other(_) => None,
414 }
415 }
416}
417
418impl From<crate::did_key::DidKeyError> for VtaError {
419 fn from(e: crate::did_key::DidKeyError) -> Self {
420 Self::Validation(e.to_string())
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[cfg(feature = "client")]
429 #[test]
430 fn from_http_410_maps_to_gone() {
431 let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
432 assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
433 }
434
435 #[test]
436 fn problem_report_conflict_maps_to_typed_conflict() {
437 let err = VtaError::from_problem_report(
438 crate::protocols::problem_report_codes::CONFLICT,
439 "key id already exists",
440 );
441 assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
442 assert!(err.is_conflict());
443 }
444
445 #[test]
446 fn problem_report_unknown_code_lands_in_didcomm_remote() {
447 let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
448 match err {
449 VtaError::DidcommRemote { code, comment } => {
450 assert_eq!(code, "e.custom.xyz");
451 assert_eq!(comment, "weird thing");
452 }
453 other => panic!("expected DidcommRemote, got {other:?}"),
454 }
455 }
456
457 #[test]
458 fn suggested_fix_present_for_actionable_variants() {
459 assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
463 assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
464 assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
465 assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
466 assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
467 assert!(
468 VtaError::Server {
469 status: 500,
470 body: "boom".into(),
471 }
472 .suggested_fix()
473 .is_some()
474 );
475 assert!(
476 VtaError::UnsupportedTransport("rest only".into())
477 .suggested_fix()
478 .is_some()
479 );
480 assert!(
481 VtaError::DidcommTransport("offline".into())
482 .suggested_fix()
483 .is_some()
484 );
485
486 assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
488 assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
489 assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
490 assert!(
491 VtaError::MediatorHandshakeFailed {
492 reason: "trust-ping timeout".into()
493 }
494 .suggested_fix()
495 .is_some()
496 );
497 assert!(
498 VtaError::DrainTtlOutOfBounds {
499 min: 3600,
500 max: 2_592_000,
501 requested: 30,
502 }
503 .suggested_fix()
504 .is_some()
505 );
506 assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
507
508 assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
510 assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
511 assert!(
512 VtaError::DidcommRemote {
513 code: "e.unknown".into(),
514 comment: "x".into()
515 }
516 .suggested_fix()
517 .is_none()
518 );
519 }
520
521 #[test]
525 fn typed_payload_round_trips_every_runtime_service_variant() {
526 let cases: Vec<VtaError> = vec![
527 VtaError::LastServiceRefused,
528 VtaError::ServiceNotPresent,
529 VtaError::ServiceAlreadyEnabled,
530 VtaError::MediatorHandshakeFailed {
531 reason: "trust-ping timeout after 10s".into(),
532 },
533 VtaError::DrainTtlOutOfBounds {
534 min: 3600,
535 max: 2_592_000,
536 requested: 30,
537 },
538 VtaError::NoPriorMutation,
539 VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
540 ];
541
542 for original in cases {
543 let payload = original.to_typed_payload().unwrap_or_else(|| {
544 panic!("variant must project to TypedErrorPayload: {original:?}")
545 });
546
547 let json = serde_json::to_string(&payload)
550 .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
551 let restored: TypedErrorPayload = serde_json::from_str(&json)
552 .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
553
554 assert_eq!(
555 payload, restored,
556 "TypedErrorPayload must round-trip through JSON",
557 );
558
559 let reconstructed = VtaError::from_typed_payload(restored);
562 match (&original, &reconstructed) {
563 (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
564 | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
565 | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
566 | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
567 (
568 VtaError::MediatorHandshakeFailed { reason: a },
569 VtaError::MediatorHandshakeFailed { reason: b },
570 ) => assert_eq!(a, b),
571 (
572 VtaError::DrainTtlOutOfBounds {
573 min: m1,
574 max: x1,
575 requested: r1,
576 },
577 VtaError::DrainTtlOutOfBounds {
578 min: m2,
579 max: x2,
580 requested: r2,
581 },
582 ) => {
583 assert_eq!(m1, m2);
584 assert_eq!(x1, x2);
585 assert_eq!(r1, r2);
586 }
587 (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
588 assert_eq!(a, b)
589 }
590 (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
591 }
592 }
593 }
594
595 #[test]
600 fn typed_payload_wire_discriminator_is_kebab_case() {
601 let payload = TypedErrorPayload::DrainTtlOutOfBounds {
602 min: 3600,
603 max: 2_592_000,
604 requested: 30,
605 };
606 let json = serde_json::to_value(&payload).unwrap();
607 assert_eq!(json["code"], "drain-ttl-out-of-bounds");
608 assert_eq!(json["min"], 3600);
609 assert_eq!(json["max"], 2_592_000);
610 assert_eq!(json["requested"], 30);
611 }
612
613 #[test]
618 fn typed_payload_is_none_for_non_service_management_variants() {
619 assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
620 assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
621 assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
622 assert!(
623 VtaError::Server {
624 status: 500,
625 body: "x".into(),
626 }
627 .to_typed_payload()
628 .is_none()
629 );
630 assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
631 assert!(
632 VtaError::DidcommRemote {
633 code: "e.x".into(),
634 comment: "x".into()
635 }
636 .to_typed_payload()
637 .is_none()
638 );
639 assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
640 }
641}