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("didcomm remote error ({code}): {comment}")]
61 DidcommRemote { code: String, comment: String },
62
63 #[error("protocol error: {0}")]
68 Protocol(String),
69
70 #[error("serialization error: {0}")]
72 Serialization(#[from] serde_json::Error),
73
74 #[error("refusing operation: would leave the VTA with no advertised services")]
86 LastServiceRefused,
87
88 #[error("service is not present (not currently enabled)")]
91 ServiceNotPresent,
92
93 #[error("service is already enabled")]
96 ServiceAlreadyEnabled,
97
98 #[error("mediator handshake failed: {reason}")]
101 MediatorHandshakeFailed { reason: String },
102
103 #[error("drain ttl {requested}s outside allowed range [{min}s, {max}s]")]
108 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
109
110 #[error("no prior mutation to roll back from")]
113 NoPriorMutation,
114
115 #[error(
123 "no transport protocol in common with {counterparty_did}: \
124 we advertise {ours:?}, they advertise {theirs:?}"
125 )]
126 NoMatchingProtocol {
127 counterparty_did: String,
128 ours: Vec<crate::protocol::matching::Protocol>,
129 theirs: Vec<crate::protocol::matching::Protocol>,
130 },
131
132 #[error("{0}")]
133 Other(String),
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
155#[serde(tag = "code", rename_all = "kebab-case")]
156pub enum TypedErrorPayload {
157 LastServiceRefused,
158 ServiceNotPresent,
159 ServiceAlreadyEnabled,
160 MediatorHandshakeFailed { reason: String },
161 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
162 NoPriorMutation,
163 UnsupportedTransport { detail: String },
164}
165
166impl VtaError {
167 #[cfg(feature = "client")]
173 pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
174 match status.as_u16() {
175 401 => Self::Auth(body),
176 403 => Self::Forbidden(body),
177 404 => Self::NotFound(body),
178 400 | 422 => Self::Validation(body),
179 409 => Self::Conflict(body),
180 410 => Self::Gone(body),
181 s if s >= 500 => Self::Server { status: s, body },
182 s => Self::Other(format!("{s}: {body}")),
183 }
184 }
185
186 pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
194 use crate::protocols::problem_report_codes as c;
195 let comment = comment.into();
196 match code {
197 c::CONFLICT => Self::Conflict(comment),
198 c::NOT_FOUND => Self::NotFound(comment),
199 c::UNAUTHORIZED => Self::Auth(comment),
200 c::FORBIDDEN => Self::Forbidden(comment),
201 c::BAD_REQUEST => Self::Validation(comment),
202 c::INTERNAL => Self::Server {
203 status: 500,
204 body: comment,
205 },
206 other => Self::DidcommRemote {
207 code: other.to_string(),
208 comment,
209 },
210 }
211 }
212
213 pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
218 match payload {
219 TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
220 TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
221 TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
222 TypedErrorPayload::MediatorHandshakeFailed { reason } => {
223 Self::MediatorHandshakeFailed { reason }
224 }
225 TypedErrorPayload::DrainTtlOutOfBounds {
226 min,
227 max,
228 requested,
229 } => Self::DrainTtlOutOfBounds {
230 min,
231 max,
232 requested,
233 },
234 TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
235 TypedErrorPayload::UnsupportedTransport { detail } => {
236 Self::UnsupportedTransport(detail)
237 }
238 }
239 }
240
241 #[must_use]
247 pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
248 match self {
249 Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
250 Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
251 Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
252 Self::MediatorHandshakeFailed { reason } => {
253 Some(TypedErrorPayload::MediatorHandshakeFailed {
254 reason: reason.clone(),
255 })
256 }
257 Self::DrainTtlOutOfBounds {
258 min,
259 max,
260 requested,
261 } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
262 min: *min,
263 max: *max,
264 requested: *requested,
265 }),
266 Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
267 Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
268 detail: detail.clone(),
269 }),
270 _ => None,
271 }
272 }
273
274 pub fn is_gone(&self) -> bool {
276 matches!(self, Self::Gone(_))
277 }
278
279 pub fn is_conflict(&self) -> bool {
281 matches!(self, Self::Conflict(_))
282 }
283
284 pub fn is_auth(&self) -> bool {
286 matches!(self, Self::Auth(_) | Self::Forbidden(_))
287 }
288
289 pub fn is_network(&self) -> bool {
291 #[cfg(feature = "client")]
292 if matches!(self, Self::Network(_)) {
293 return true;
294 }
295 false
296 }
297
298 pub fn is_not_found(&self) -> bool {
300 matches!(self, Self::NotFound(_))
301 }
302
303 #[must_use]
318 pub fn suggested_fix(&self) -> Option<&'static str> {
319 match self {
320 Self::Auth(_) => Some(
321 "Token may be expired. Re-authenticate against the VTA, or check that \
322 the `/auth` endpoint is reachable.",
323 ),
324 Self::Forbidden(_) => Some(
325 "Your role or context access doesn't permit this operation. Inspect \
326 the ACL entry for your DID against the target context.",
327 ),
328 Self::Gone(_) => Some(
329 "The resource has been permanently consumed. The single-use bootstrap \
330 carve-out has likely already been used; ask an existing admin to \
331 provision-integration a new operator instead.",
332 ),
333 Self::Conflict(_) => Some(
334 "The resource already exists. Use the corresponding `update` or \
335 `delete-then-create` flow rather than `create`.",
336 ),
337 Self::Validation(_) => Some(
338 "The request body or parameters were rejected by the VTA's schema. \
339 Inspect the response body for the specific field that failed.",
340 ),
341 Self::Server { .. } => {
342 Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
343 }
344 Self::UnsupportedTransport(_) => Some(
345 "The operation requires a specific transport (REST or DIDComm). \
346 Check which mode the client is in and whether the endpoint supports it.",
347 ),
348 Self::DidcommTransport(_) => {
349 Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
350 }
351 #[cfg(feature = "client")]
352 Self::Network(_) => Some(
353 "Network error reaching the VTA. Confirm the URL is correct and the \
354 host is reachable.",
355 ),
356 Self::LastServiceRefused => Some(
361 "This operation would leave the VTA with no advertised transport \
362 services. Enable the other transport first (REST or DIDComm) \
363 before disabling this one.",
364 ),
365 Self::ServiceNotPresent => Some(
366 "The service kind isn't currently enabled. Use \
367 `services <kind> enable …` to bring it online before \
368 updating, disabling, or rolling it back.",
369 ),
370 Self::ServiceAlreadyEnabled => Some(
371 "The service kind is already enabled. Use \
372 `services <kind> update …` to change its configuration, \
373 or `disable` to remove it.",
374 ),
375 Self::MediatorHandshakeFailed { .. } => Some(
376 "DIDComm handshake against the candidate mediator failed. \
377 Confirm the mediator DID is correct and the mediator is \
378 reachable; check the inner reason for the specific cause.",
379 ),
380 Self::DrainTtlOutOfBounds { .. } => Some(
381 "The supplied drain TTL is outside the allowed range. Pick a \
382 value within the [min, max] interval shown in the error message.",
383 ),
384 Self::NoPriorMutation => Some(
385 "No prior mutation for this service kind to roll back from. Use \
386 the direct `enable`/`update`/`disable` command instead.",
387 ),
388 Self::NoMatchingProtocol { .. } => Some(
389 "The two parties share no transport protocol. Enable a common \
390 transport (TSP, DIDComm, or REST) on both sides — compare each \
391 DID document's advertised `service` entries and add the missing one.",
392 ),
393 Self::NotFound(_)
397 | Self::DidcommRemote { .. }
398 | Self::Protocol(_)
399 | Self::Serialization(_)
400 | Self::Other(_) => None,
401 }
402 }
403}
404
405impl From<crate::did_key::DidKeyError> for VtaError {
406 fn from(e: crate::did_key::DidKeyError) -> Self {
407 Self::Validation(e.to_string())
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[cfg(feature = "client")]
416 #[test]
417 fn from_http_410_maps_to_gone() {
418 let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
419 assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
420 }
421
422 #[test]
423 fn problem_report_conflict_maps_to_typed_conflict() {
424 let err = VtaError::from_problem_report(
425 crate::protocols::problem_report_codes::CONFLICT,
426 "key id already exists",
427 );
428 assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
429 assert!(err.is_conflict());
430 }
431
432 #[test]
433 fn problem_report_unknown_code_lands_in_didcomm_remote() {
434 let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
435 match err {
436 VtaError::DidcommRemote { code, comment } => {
437 assert_eq!(code, "e.custom.xyz");
438 assert_eq!(comment, "weird thing");
439 }
440 other => panic!("expected DidcommRemote, got {other:?}"),
441 }
442 }
443
444 #[test]
445 fn suggested_fix_present_for_actionable_variants() {
446 assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
450 assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
451 assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
452 assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
453 assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
454 assert!(
455 VtaError::Server {
456 status: 500,
457 body: "boom".into(),
458 }
459 .suggested_fix()
460 .is_some()
461 );
462 assert!(
463 VtaError::UnsupportedTransport("rest only".into())
464 .suggested_fix()
465 .is_some()
466 );
467 assert!(
468 VtaError::DidcommTransport("offline".into())
469 .suggested_fix()
470 .is_some()
471 );
472
473 assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
475 assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
476 assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
477 assert!(
478 VtaError::MediatorHandshakeFailed {
479 reason: "trust-ping timeout".into()
480 }
481 .suggested_fix()
482 .is_some()
483 );
484 assert!(
485 VtaError::DrainTtlOutOfBounds {
486 min: 3600,
487 max: 2_592_000,
488 requested: 30,
489 }
490 .suggested_fix()
491 .is_some()
492 );
493 assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
494
495 assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
497 assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
498 assert!(
499 VtaError::DidcommRemote {
500 code: "e.unknown".into(),
501 comment: "x".into()
502 }
503 .suggested_fix()
504 .is_none()
505 );
506 }
507
508 #[test]
512 fn typed_payload_round_trips_every_runtime_service_variant() {
513 let cases: Vec<VtaError> = vec![
514 VtaError::LastServiceRefused,
515 VtaError::ServiceNotPresent,
516 VtaError::ServiceAlreadyEnabled,
517 VtaError::MediatorHandshakeFailed {
518 reason: "trust-ping timeout after 10s".into(),
519 },
520 VtaError::DrainTtlOutOfBounds {
521 min: 3600,
522 max: 2_592_000,
523 requested: 30,
524 },
525 VtaError::NoPriorMutation,
526 VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
527 ];
528
529 for original in cases {
530 let payload = original.to_typed_payload().unwrap_or_else(|| {
531 panic!("variant must project to TypedErrorPayload: {original:?}")
532 });
533
534 let json = serde_json::to_string(&payload)
537 .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
538 let restored: TypedErrorPayload = serde_json::from_str(&json)
539 .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
540
541 assert_eq!(
542 payload, restored,
543 "TypedErrorPayload must round-trip through JSON",
544 );
545
546 let reconstructed = VtaError::from_typed_payload(restored);
549 match (&original, &reconstructed) {
550 (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
551 | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
552 | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
553 | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
554 (
555 VtaError::MediatorHandshakeFailed { reason: a },
556 VtaError::MediatorHandshakeFailed { reason: b },
557 ) => assert_eq!(a, b),
558 (
559 VtaError::DrainTtlOutOfBounds {
560 min: m1,
561 max: x1,
562 requested: r1,
563 },
564 VtaError::DrainTtlOutOfBounds {
565 min: m2,
566 max: x2,
567 requested: r2,
568 },
569 ) => {
570 assert_eq!(m1, m2);
571 assert_eq!(x1, x2);
572 assert_eq!(r1, r2);
573 }
574 (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
575 assert_eq!(a, b)
576 }
577 (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
578 }
579 }
580 }
581
582 #[test]
587 fn typed_payload_wire_discriminator_is_kebab_case() {
588 let payload = TypedErrorPayload::DrainTtlOutOfBounds {
589 min: 3600,
590 max: 2_592_000,
591 requested: 30,
592 };
593 let json = serde_json::to_value(&payload).unwrap();
594 assert_eq!(json["code"], "drain-ttl-out-of-bounds");
595 assert_eq!(json["min"], 3600);
596 assert_eq!(json["max"], 2_592_000);
597 assert_eq!(json["requested"], 30);
598 }
599
600 #[test]
605 fn typed_payload_is_none_for_non_service_management_variants() {
606 assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
607 assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
608 assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
609 assert!(
610 VtaError::Server {
611 status: 500,
612 body: "x".into(),
613 }
614 .to_typed_payload()
615 .is_none()
616 );
617 assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
618 assert!(
619 VtaError::DidcommRemote {
620 code: "e.x".into(),
621 comment: "x".into()
622 }
623 .to_typed_payload()
624 .is_none()
625 );
626 assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
627 }
628}