vta_sdk/error.rs
1//! Structured error type for VTA SDK operations.
2
3pub use crate::rate_limit::RateLimitSource;
4
5/// Errors returned by VTA SDK client operations.
6#[derive(Debug, thiserror::Error)]
7pub enum VtaError {
8 /// Network-level error (connection refused, timeout, DNS failure).
9 #[cfg(feature = "client")]
10 #[error("network error: {0}")]
11 Network(#[from] reqwest::Error),
12
13 /// Authentication failed (401) or token expired.
14 #[error("authentication failed: {0}")]
15 Auth(String),
16
17 /// Resource not found (404).
18 #[error("not found: {0}")]
19 NotFound(String),
20
21 /// Request validation error (400).
22 #[error("validation error: {0}")]
23 Validation(String),
24
25 /// Permission denied (403).
26 #[error("forbidden: {0}")]
27 Forbidden(String),
28
29 /// Conflict (409) — e.g. duplicate key ID.
30 #[error("conflict: {0}")]
31 Conflict(String),
32
33 /// Gone (410) — the resource existed but is now permanently unavailable.
34 /// Most often emitted by the bootstrap carve-out endpoint after it has
35 /// been consumed; the CLI surfaces this with a "did you mean to run
36 /// `… provision-request`" hint instead of a flat string.
37 #[error("gone: {0}")]
38 Gone(String),
39
40 /// Server error (5xx).
41 #[error("server error ({status}): {body}")]
42 Server { status: u16, body: String },
43
44 /// The operation does not support the transport the client is
45 /// configured for (e.g. calling a REST-only helper on a client built
46 /// with DIDComm-only transport, or vice versa).
47 #[error("unsupported transport: {0}")]
48 UnsupportedTransport(String),
49
50 /// DIDComm transport failure (pack/send/pickup). Network-ish —
51 /// caller may want to retry. Distinct from [`Self::Network`] which
52 /// is REST-specific and carries a `reqwest::Error`.
53 #[error("didcomm transport error: {0}")]
54 DidcommTransport(String),
55
56 /// TSP transport failure (seal/route/websocket). Network-ish — caller may
57 /// want to retry. Kept distinct from [`Self::DidcommTransport`] rather than
58 /// folded into it: the two transports fail for different reasons and have
59 /// different recovery flags, and one shared message is what R6.4 exists to
60 /// prevent.
61 #[error("tsp transport error: {0}")]
62 TspTransport(String),
63
64 /// Remote endpoint returned a DIDComm problem-report whose `code`
65 /// did not match any of the standard `e.p.msg.*` taxonomy variants
66 /// (which map to the typed REST-aligned variants above). Inspect
67 /// `code` to handle it; a typed [`Self::Conflict`] / [`Self::NotFound`]
68 /// / [`Self::Auth`] / [`Self::Validation`] / [`Self::Server`] will
69 /// already have been emitted for the standard codes.
70 #[error("didcomm remote error ({code}): {comment}")]
71 DidcommRemote { code: String, comment: String },
72
73 /// Programmer-level protocol error (response shape did not match
74 /// what the SDK expected — version mismatch or bug). Distinct from
75 /// remote-error: a peer that returned a problem-report becomes a
76 /// typed variant via [`Self::from_problem_report`], not this one.
77 #[error("protocol error: {0}")]
78 Protocol(String),
79
80 /// The task needs a human approval that has not been given yet.
81 ///
82 /// Structured rather than folded into [`Self::Protocol`] because a caller
83 /// has to *act* on it: show the operator `payload_digest` so they can
84 /// compare it against the code on the approving device, then re-submit the
85 /// byte-identical request once approved. A flat string cannot carry that,
86 /// and the CLI's only option was to print the refusal and exit — which is
87 /// why a consent-gated task was unreachable from `pnm` entirely.
88 ///
89 /// The re-submit is safe to repeat *while the request is pending*: the
90 /// server returns the same `challenge` and deliberately does not re-notify
91 /// (the push follows the question, not the submit). It is NOT safe to
92 /// repeat blindly after a decision — a denial deletes the pending request,
93 /// so the next submit raises a new one and pushes again. Callers must stop
94 /// when `challenge` changes; see `vta_cli_common::consent`.
95 #[error(
96 "consent required: {min_approvals} approval(s) from `{approver_set}` — \
97 approve code {payload_digest} on an approving device"
98 )]
99 ConsentRequired {
100 /// The salted digest the approver signs and both screens compare.
101 payload_digest: String,
102 /// Nonce binding the decision to this request. Changes when the
103 /// request is resolved and a new one is raised.
104 challenge: String,
105 /// Named approver set the policy requires.
106 approver_set: String,
107 /// Distinct approvals needed.
108 min_approvals: u32,
109 /// Whether the requesting device is barred from counting toward the
110 /// threshold. `true` means this caller cannot self-approve however it
111 /// is enrolled, and must wait for another device; `false` means it may
112 /// approve its own request if it is a member of the set.
113 exclude_requester: bool,
114 },
115
116 /// Serialization/deserialization error.
117 #[error("serialization error: {0}")]
118 Serialization(#[from] serde_json::Error),
119
120 // ── Runtime service-management variants (spec §4) ──────────────
121 //
122 // These are emitted by the post-setup service-management surface
123 // (`services {rest,didcomm} {enable,update,disable,rollback}`).
124 // Structured data for the variants that carry numeric fields
125 // round-trips lossless via [`TypedErrorPayload`] across both
126 // REST response bodies and DIDComm problem-report args.
127 /// The operation would leave the VTA's DID document with no
128 /// advertised transport services. Per spec §3.2, this is rejected
129 /// without a `--force` escape hatch — enable the other transport
130 /// first if a swap is intended.
131 #[error("refusing operation: would leave the VTA with no advertised services")]
132 LastServiceRefused,
133
134 /// `update`, `disable`, or a kind-specific drain action was
135 /// invoked for a service kind that isn't currently enabled.
136 #[error("service is not present (not currently enabled)")]
137 ServiceNotPresent,
138
139 /// `enable` was invoked for a service kind that's already
140 /// enabled. Use `update` to change its configuration.
141 #[error("service is already enabled")]
142 ServiceAlreadyEnabled,
143
144 /// DIDComm handshake against the candidate mediator failed
145 /// (trust-ping refused, timed out, or peer was unreachable).
146 #[error("mediator handshake failed: {reason}")]
147 MediatorHandshakeFailed { reason: String },
148
149 /// Drain TTL is outside the valid range. Bounds are
150 /// `MIN_DRAIN_TTL_OVER_DIDCOMM` (3600s, when the disable command
151 /// is itself delivered over DIDComm) and `MAX_DRAIN_TTL`
152 /// (30 days). All three fields are in seconds.
153 #[error("drain ttl {requested}s outside allowed range [{min}s, {max}s]")]
154 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
155
156 /// `rollback` was invoked for a service kind that has no prior
157 /// mutation in its snapshot store to fail-forward from.
158 #[error("no prior mutation to roll back from")]
159 NoPriorMutation,
160
161 /// Catch-all for other errors.
162 /// No transport protocol is advertised by **both** this party and the
163 /// counterparty, so there is no way to communicate. Carries each side's
164 /// advertised set (in preference order) so the CLI can show the operator
165 /// what each offers and which transport to enable. Determined locally by
166 /// [`crate::protocol::matching::select_protocol`] after resolving the
167 /// peer's DID document — never a server-returned wire error.
168 #[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 /// The peer does not serve this Trust Task type — the standard
179 /// `unsupportedType` / `unsupportedVersion` rejections.
180 ///
181 /// Typed rather than folded into [`Self::Protocol`] because it is the one
182 /// rejection whose fix is **upgrade something**, and a caller can only say
183 /// which thing if it can read the two facts apart: what we asked for, and
184 /// what the peer serves instead.
185 ///
186 /// A live incident (2026-08-31) is why this exists. #1147 cut
187 /// `provision/integration` 0.2 → 0.3 with no dual-accept window — the two
188 /// response schemas are mutually exclusive, so there could not be one — and
189 /// an operator on a current client hit a VTA still serving 0.2. All the
190 /// wizard could render was the flat string
191 ///
192 /// ```text
193 /// trust task failed [unsupportedType]: unsupported type:
194 /// https://trusttasks.org/spec/provision/integration/0.3
195 /// ```
196 ///
197 /// which reads as "this VTA cannot provision" rather than "this VTA is
198 /// older than your client", and sends the reader to the wrong half of the
199 /// system. The version in that message is the whole diagnosis — 0.2 means
200 /// the *client* is behind, 0.3 means the *VTA* is — and nothing said so.
201 ///
202 /// `served_versions` is the peer's own answer, from the rejection's
203 /// `details.servedVersions`. Empty means the peer does not know the family
204 /// at all **or** is old enough not to send the field, so a consumer must
205 /// not read empty as "the family does not exist".
206 #[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 /// The Type URI this client dispatched.
216 type_uri: String,
217 /// Versions of the same family the peer reported serving.
218 served_versions: Vec<String>,
219 },
220
221 /// The VTA is temporarily unable to process this task — the standard
222 /// `unavailable` rejection (HTTP 503).
223 ///
224 /// Typed rather than folded into [`VtaError::Protocol`] because it is the
225 /// one wire rejection that means **"ask again"** rather than "this failed".
226 /// The idempotency layer returns it when a first attempt on the same key is
227 /// still running, so a retry loop that reads it as a terminal error gives up
228 /// on the one answer it was supposed to wait for.
229 ///
230 /// `retry_after` carries the server's hint verbatim when it supplied one. A
231 /// client should honour it and cap it — an unbounded wait on a
232 /// server-controlled value is a denial of service the server can trigger.
233 #[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 /// A rate limiter refused the request (HTTP 429). Not a server fault, and
242 /// not necessarily the VTA's doing.
243 ///
244 /// Typed rather than folded into [`Self::Other`] because the two questions
245 /// an operator has — *who* refused, and *how long* to wait — have different
246 /// answers per refusing service, and a flat `"429: Too Many Requests"`
247 /// answers neither. It was indistinguishable from a fault, and the wait
248 /// hint the server sent was thrown away before the error was built.
249 ///
250 /// - `limited_by`: which service's limiter it was, read from the
251 /// `x-rate-limit-source` response header
252 /// ([`crate::rate_limit::SOURCE_HEADER`]). Absent means
253 /// [`RateLimitSource::Upstream`]: a proxy / load balancer, or a VTA older
254 /// than the header. (Not named `source`: `thiserror` treats a field of
255 /// that name as the underlying error.)
256 /// - `retry_after`: the server's `Retry-After` hint as an instant, when it
257 /// sent one — the same shape as [`Self::Unavailable`]. Cap it before
258 /// sleeping on it.
259 /// - `limiter`: which of the service's limiters tripped, when it said.
260 /// - `url`: the refused request's URL, when known — the quickest way to
261 /// tell the VTA apart from a DID host or a proxy on another hostname.
262 #[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/// Wire-format companion to the typed [`VtaError`] variants emitted
281/// by the runtime service-management surface.
282///
283/// The free-form `comment` string carried by DIDComm problem-reports
284/// (and the `body` string of REST error responses) is fine for the
285/// variants whose only data is a human-readable message
286/// ([`VtaError::Conflict`], [`VtaError::NotFound`], …) but lossy for
287/// variants like [`VtaError::DrainTtlOutOfBounds`] that carry three
288/// numeric fields the CLI needs to switch on.
289///
290/// Servers serialize a `TypedErrorPayload` into the response body
291/// (REST) or problem-report `args` (DIDComm); clients deserialize
292/// it back via [`VtaError::from_typed_payload`]. The discriminator
293/// is the kebab-cased variant name in the `code` field.
294///
295/// Variants line up 1:1 with the §4 spec list — the existing
296/// [`VtaError::UnsupportedTransport`] is included so the same
297/// channel carries every typed-error wire form.
298#[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
310/// The message prefix every TSP reply-timeout carries. Shared between the
311/// producers (`DIDCommSession::await_tsp_reply` / `TspSession::await_reply`) and
312/// [`VtaError::is_tsp_reply_timeout`], so the self-repair path recognises a
313/// §7.2.2 silent drop without a brittle literal repeated in three places.
314#[cfg(feature = "tsp")]
315pub(crate) const TSP_REPLY_TIMEOUT_PREFIX: &str = "timed out waiting for the TSP reply";
316
317impl VtaError {
318 /// True for a TSP request that timed out waiting for its reply — the
319 /// signature of a §7.2.2 silent drop (the peer dropped our frame because it
320 /// holds no relationship with us). The self-repair path in
321 /// `dispatch_trust_task` keys on this to re-form the relationship and retry.
322 #[cfg(feature = "tsp")]
323 pub(crate) fn is_tsp_reply_timeout(&self) -> bool {
324 matches!(self, VtaError::TspTransport(msg) if msg.starts_with(TSP_REPLY_TIMEOUT_PREFIX))
325 }
326
327 /// Create from an HTTP response status and error body.
328 ///
329 /// Public so a downstream SDK consumer wiring its own HTTP transport
330 /// (e.g. a wasm `gloo-net` client) can produce typed `VtaError`s
331 /// from status codes without re-implementing the mapping.
332 ///
333 /// A `429` becomes [`Self::RateLimited`] with
334 /// [`RateLimitSource::Upstream`]: without the response headers it can be
335 /// neither attributed nor given a wait. A consumer that has the headers
336 /// should call [`Self::from_http_with_headers`].
337 #[cfg(feature = "client")]
338 pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
339 match status.as_u16() {
340 429 => Self::RateLimited {
341 limited_by: RateLimitSource::Upstream,
342 retry_after: None,
343 limiter: None,
344 url: None,
345 },
346 401 => Self::Auth(body),
347 403 => Self::Forbidden(body),
348 404 => Self::NotFound(body),
349 400 | 422 => Self::Validation(body),
350 409 => Self::Conflict(body),
351 410 => Self::Gone(body),
352 s if s >= 500 => Self::Server { status: s, body },
353 s => Self::Other(format!("{s}: {body}")),
354 }
355 }
356
357 /// [`Self::from_http`], plus what only the response headers and the request
358 /// URL can say.
359 ///
360 /// Identical to `from_http` for every status except `429`, where it reads
361 /// the attribution header ([`crate::rate_limit::SOURCE_HEADER`]) and the
362 /// wait hint (`Retry-After`, else the legacy `x-ratelimit-after`) into
363 /// [`Self::RateLimited`]. `url` is the refused request's URL.
364 ///
365 /// Public, like `from_http`, for consumers wiring their own HTTP transport.
366 #[cfg(feature = "client")]
367 pub fn from_http_with_headers(
368 status: reqwest::StatusCode,
369 headers: &reqwest::header::HeaderMap,
370 body: String,
371 url: Option<&str>,
372 ) -> Self {
373 if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
374 return Self::from_http(status, body);
375 }
376 use crate::rate_limit as rl;
377 let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
378 let limited_by = RateLimitSource::from_source_header(header(rl::SOURCE_HEADER));
379 let now = chrono::Utc::now();
380 let retry_after = header(rl::RETRY_AFTER_HEADER)
381 .and_then(|v| rl::parse_retry_after(v, now))
382 .or_else(|| {
383 header(rl::LEGACY_RETRY_AFTER_HEADER).and_then(|v| rl::parse_retry_after(v, now))
384 });
385 Self::RateLimited {
386 limited_by,
387 retry_after,
388 limiter: limiter_from_body(limited_by, &body),
389 url: url.map(str::to_string),
390 }
391 }
392
393 /// Consume a failed `reqwest::Response` into a typed error, keeping the
394 /// headers and URL that [`Self::from_http_with_headers`] needs. The body is
395 /// passed through verbatim.
396 #[cfg(feature = "client")]
397 pub async fn from_response(resp: reqwest::Response) -> Self {
398 let status = resp.status();
399 let headers = resp.headers().clone();
400 let url = resp.url().to_string();
401 let body = resp.text().await.unwrap_or_default();
402 Self::from_http_with_headers(status, &headers, body, Some(&url))
403 }
404
405 /// `Some` iff `status` is `429`: the typed [`Self::RateLimited`] for it.
406 ///
407 /// For the auth helpers that return a boxed error and otherwise format the
408 /// status into a string. A rate limit is the refusal that must stay typed
409 /// there, because the string form reads as "authentication failed" and
410 /// sends the operator to re-authenticate.
411 #[cfg(feature = "client")]
412 pub fn rate_limited_from_http(
413 status: reqwest::StatusCode,
414 headers: &reqwest::header::HeaderMap,
415 body: &str,
416 url: &str,
417 ) -> Option<Self> {
418 (status == reqwest::StatusCode::TOO_MANY_REQUESTS)
419 .then(|| Self::from_http_with_headers(status, headers, body.to_string(), Some(url)))
420 }
421
422 /// Create from a DIDComm problem-report `code` + `comment`. Mirrors
423 /// the REST [`Self::from_http`] mapping so callers can `match` on the
424 /// same variants regardless of transport.
425 ///
426 /// Standard codes (`e.p.msg.unauthorized` / `bad-request` / `not-found`
427 /// / `conflict` / `internal-error`) become typed variants. Anything
428 /// else lands in [`Self::DidcommRemote`] preserving the original code.
429 pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
430 use crate::protocols::problem_report_codes as c;
431 let comment = comment.into();
432 match code {
433 c::CONFLICT => Self::Conflict(comment),
434 c::NOT_FOUND => Self::NotFound(comment),
435 c::UNAUTHORIZED => Self::Auth(comment),
436 c::FORBIDDEN => Self::Forbidden(comment),
437 c::BAD_REQUEST => Self::Validation(comment),
438 c::INTERNAL => Self::Server {
439 status: 500,
440 body: comment,
441 },
442 other => Self::DidcommRemote {
443 code: other.to_string(),
444 comment,
445 },
446 }
447 }
448
449 /// Reconstruct the typed [`VtaError`] variant from a wire-format
450 /// [`TypedErrorPayload`]. Used by the client when decoding REST
451 /// response bodies / DIDComm problem-report args for the runtime
452 /// service-management surface (spec §4).
453 pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
454 match payload {
455 TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
456 TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
457 TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
458 TypedErrorPayload::MediatorHandshakeFailed { reason } => {
459 Self::MediatorHandshakeFailed { reason }
460 }
461 TypedErrorPayload::DrainTtlOutOfBounds {
462 min,
463 max,
464 requested,
465 } => Self::DrainTtlOutOfBounds {
466 min,
467 max,
468 requested,
469 },
470 TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
471 TypedErrorPayload::UnsupportedTransport { detail } => {
472 Self::UnsupportedTransport(detail)
473 }
474 }
475 }
476
477 /// Project this error onto the wire-format [`TypedErrorPayload`]
478 /// when the variant is one of the runtime service-management
479 /// errors. Returns `None` for variants that don't have a
480 /// structured wire form (network errors, generic conflicts,
481 /// programmer-level protocol errors, …).
482 #[must_use]
483 pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
484 match self {
485 Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
486 Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
487 Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
488 Self::MediatorHandshakeFailed { reason } => {
489 Some(TypedErrorPayload::MediatorHandshakeFailed {
490 reason: reason.clone(),
491 })
492 }
493 Self::DrainTtlOutOfBounds {
494 min,
495 max,
496 requested,
497 } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
498 min: *min,
499 max: *max,
500 requested: *requested,
501 }),
502 Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
503 Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
504 detail: detail.clone(),
505 }),
506 _ => None,
507 }
508 }
509
510 /// Returns true if a rate limiter refused the request (429).
511 pub fn is_rate_limited(&self) -> bool {
512 matches!(self, Self::RateLimited { .. })
513 }
514
515 /// Returns true if the resource was permanently consumed/gone (410).
516 pub fn is_gone(&self) -> bool {
517 matches!(self, Self::Gone(_))
518 }
519
520 /// Returns true if a create/insert collided with an existing entry (409).
521 pub fn is_conflict(&self) -> bool {
522 matches!(self, Self::Conflict(_))
523 }
524
525 /// Returns true if this is an authentication/authorization error.
526 pub fn is_auth(&self) -> bool {
527 matches!(self, Self::Auth(_) | Self::Forbidden(_))
528 }
529
530 /// Returns true if this is a network-level error (retryable).
531 pub fn is_network(&self) -> bool {
532 #[cfg(feature = "client")]
533 if matches!(self, Self::Network(_)) {
534 return true;
535 }
536 false
537 }
538
539 /// Returns true if the resource was not found.
540 pub fn is_not_found(&self) -> bool {
541 matches!(self, Self::NotFound(_))
542 }
543
544 /// Operator-actionable hint matching this error variant.
545 ///
546 /// `None` for variants where no generic guidance applies (the message
547 /// itself is the hint, or the failure is a programmer error). The
548 /// CLI layer (`vta-cli-common::render::print_cli_error`) already
549 /// implements bin-aware suggestions ("`pnm acl create …`"); this
550 /// method gives **non-CLI consumers** — web UIs, GUIs, custom
551 /// dashboards — the same hint surface without needing to fork the
552 /// dispatch logic.
553 ///
554 /// Returns a `&'static str` so callers can compose it into their
555 /// own UI without lifetime juggling. The bin-specific substitution
556 /// (`pnm` vs `cnm`) is left to the CLI layer because only it
557 /// knows which binary the operator is running.
558 #[must_use]
559 pub fn suggested_fix(&self) -> Option<&'static str> {
560 match self {
561 Self::Auth(_) => Some(
562 "Token may be expired. Re-authenticate against the VTA, or check that \
563 the `/auth` endpoint is reachable.",
564 ),
565 Self::Forbidden(_) => Some(
566 "Your role or context access doesn't permit this operation. Inspect \
567 the ACL entry for your DID against the target context.",
568 ),
569 Self::Gone(_) => Some(
570 "The resource was single-use or time-limited and has been consumed or has \
571 expired — retrying will not succeed. If this was the bootstrap carve-out, \
572 ask an existing admin to provision-integration a new operator instead.",
573 ),
574 Self::Conflict(_) => Some(
575 "The resource already exists. Use the corresponding `update` or \
576 `delete-then-create` flow rather than `create`.",
577 ),
578 Self::Unavailable { .. } => Some(
579 "The VTA is temporarily busy — this is a wait, not a failure. If the \
580 request carried an idempotency key, an earlier attempt on that key is \
581 still running: retry with the same key and the original result will be \
582 returned rather than the operation repeated.",
583 ),
584 Self::RateLimited { limited_by, .. } => {
585 Some(crate::rate_limit::suggested_fix(*limited_by))
586 }
587 Self::Validation(_) => Some(
588 "The request body or parameters were rejected by the VTA's schema. \
589 Inspect the response body for the specific field that failed.",
590 ),
591 Self::Server { .. } => {
592 Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
593 }
594 Self::UnsupportedTransport(_) => Some(
595 "The operation requires a specific transport (REST or DIDComm). \
596 Check which mode the client is in and whether the endpoint supports it.",
597 ),
598 Self::DidcommTransport(_) => {
599 Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
600 }
601 Self::TspTransport(_) => Some(
602 "The VTA's TSP mediator is unreachable or rejected the frame. Retry, or \
603 reach the VTA over another transport: `--transport didcomm` / \
604 `--transport rest`.",
605 ),
606 #[cfg(feature = "client")]
607 Self::Network(_) => Some(
608 "Network error reaching the VTA. Confirm the URL is correct and the \
609 host is reachable.",
610 ),
611 // Runtime service-management variants (spec §4). The CLI
612 // layer enriches these with the specific kind/command
613 // it just ran; this is the generic fallback hint for
614 // non-CLI consumers.
615 Self::LastServiceRefused => Some(
616 "This operation would leave the VTA with no advertised transport \
617 services. Enable the other transport first (REST or DIDComm) \
618 before disabling this one.",
619 ),
620 Self::ServiceNotPresent => Some(
621 "The service kind isn't currently enabled. Use \
622 `services <kind> enable …` to bring it online before \
623 updating, disabling, or rolling it back.",
624 ),
625 Self::ServiceAlreadyEnabled => Some(
626 "The service kind is already enabled. Use \
627 `services <kind> update …` to change its configuration, \
628 or `disable` to remove it.",
629 ),
630 Self::MediatorHandshakeFailed { .. } => Some(
631 "DIDComm handshake against the candidate mediator failed. \
632 Confirm the mediator DID is correct and the mediator is \
633 reachable; check the inner reason for the specific cause.",
634 ),
635 Self::DrainTtlOutOfBounds { .. } => Some(
636 "The supplied drain TTL is outside the allowed range. Pick a \
637 value within the [min, max] interval shown in the error message.",
638 ),
639 Self::NoPriorMutation => Some(
640 "No prior mutation for this service kind to roll back from. Use \
641 the direct `enable`/`update`/`disable` command instead.",
642 ),
643 Self::UnsupportedTaskType { .. } => Some(
644 "The peer does not serve this Trust Task at the version this client \
645 dispatches. When the error names a version the peer does serve, the \
646 two are different ages and one of them needs upgrading; when it names \
647 none, check you are pointed at the agent you meant.",
648 ),
649 Self::NoMatchingProtocol { .. } => Some(
650 "The two parties share no transport protocol. Enable a common \
651 transport (TSP, DIDComm, or REST) on both sides — compare each \
652 DID document's advertised `service` entries and add the missing one.",
653 ),
654 // No generic hint for these — the message itself is the
655 // hint, or the failure is a protocol/programmer error
656 // surface that an automated suggestion would only confuse.
657 // The hint depends on policy the message already reports — whether
658 // another device must approve, or this one may. A static string
659 // would have to guess, and guessing wrong sends the operator to the
660 // wrong screen. The CLI's consent loop says it precisely instead.
661 Self::ConsentRequired { .. } => None,
662 Self::NotFound(_)
663 | Self::DidcommRemote { .. }
664 | Self::Protocol(_)
665 | Self::Serialization(_)
666 | Self::Other(_) => None,
667 }
668 }
669}
670
671/// Which limiter tripped, when the refusing service said.
672///
673/// A JSON body's `limiter` field wins. Failing that, a plain-text body from a
674/// *labelled* source is taken as its description of the limiter — the VTA's
675/// contract is that the body names it. An unlabelled `429`'s body is whatever a
676/// proxy or an older VTA wrote (`"Too Many Requests! Wait for 4s"`), which names
677/// no limiter, so it is not promoted to one.
678#[cfg(feature = "client")]
679fn limiter_from_body(limited_by: RateLimitSource, body: &str) -> Option<String> {
680 const MAX_LIMITER_LEN: usize = 128;
681 if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
682 return v
683 .get("limiter")
684 .and_then(|l| l.as_str())
685 .map(|l| l.chars().take(MAX_LIMITER_LEN).collect());
686 }
687 let text = body.trim();
688 (limited_by != RateLimitSource::Upstream && !text.is_empty())
689 .then(|| text.chars().take(MAX_LIMITER_LEN).collect())
690}
691
692impl From<crate::did_key::DidKeyError> for VtaError {
693 fn from(e: crate::did_key::DidKeyError) -> Self {
694 Self::Validation(e.to_string())
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 #[cfg(feature = "client")]
703 #[test]
704 fn from_http_410_maps_to_gone() {
705 let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
706 assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
707 }
708
709 #[cfg(feature = "client")]
710 fn headers(pairs: &[(&'static str, &str)]) -> reqwest::header::HeaderMap {
711 let mut h = reqwest::header::HeaderMap::new();
712 for (k, v) in pairs {
713 h.insert(*k, v.parse().unwrap());
714 }
715 h
716 }
717
718 #[cfg(feature = "client")]
719 #[test]
720 fn a_labelled_429_is_attributed_to_the_vta_with_its_wait() {
721 let before = chrono::Utc::now();
722 let err = VtaError::from_http_with_headers(
723 reqwest::StatusCode::TOO_MANY_REQUESTS,
724 &headers(&[("x-rate-limit-source", "vta"), ("retry-after", "4")]),
725 r#"{"error":"rate limited","limiter":"auth"}"#.into(),
726 Some("https://vta.example.com/auth/challenge"),
727 );
728 let VtaError::RateLimited {
729 limited_by,
730 retry_after,
731 limiter,
732 url,
733 } = &err
734 else {
735 panic!("429 must map to RateLimited, got {err:?}");
736 };
737 assert_eq!(*limited_by, RateLimitSource::Vta);
738 let wait = retry_after.expect("Retry-After must be kept") - before;
739 assert!(
740 (3..=5).contains(&wait.num_seconds()),
741 "retry_after should be ~4s out, was {wait}"
742 );
743 assert_eq!(limiter.as_deref(), Some("auth"));
744 assert_eq!(
745 url.as_deref(),
746 Some("https://vta.example.com/auth/challenge")
747 );
748 assert!(err.is_rate_limited());
749 assert!(
750 err.suggested_fix()
751 .unwrap()
752 .contains("rate_limit_interval_secs"),
753 "a VTA refusal must point at the VTA's knobs"
754 );
755 }
756
757 #[cfg(feature = "client")]
758 #[test]
759 fn a_plain_text_body_from_a_labelled_vta_names_the_limiter() {
760 let err = VtaError::from_http_with_headers(
761 reqwest::StatusCode::TOO_MANY_REQUESTS,
762 &headers(&[("x-rate-limit-source", "vta")]),
763 "did-log".into(),
764 None,
765 );
766 assert!(
767 matches!(&err, VtaError::RateLimited { limiter: Some(l), .. } if l == "did-log"),
768 "got {err:?}"
769 );
770 }
771
772 #[cfg(feature = "client")]
773 #[test]
774 fn an_unlabelled_429_is_upstream_and_keeps_the_legacy_wait_hint() {
775 let err = VtaError::from_http_with_headers(
776 reqwest::StatusCode::TOO_MANY_REQUESTS,
777 // What tower-governor sends from a VTA older than the source header.
778 &headers(&[("x-ratelimit-after", "4")]),
779 "Too Many Requests! Wait for 4s".into(),
780 Some("https://vta.example.com/auth/challenge"),
781 );
782 let VtaError::RateLimited {
783 limited_by,
784 retry_after,
785 limiter,
786 ..
787 } = &err
788 else {
789 panic!("got {err:?}");
790 };
791 assert_eq!(*limited_by, RateLimitSource::Upstream);
792 assert!(retry_after.is_some(), "the legacy header is still a hint");
793 assert_eq!(
794 *limiter, None,
795 "a proxy's body names no limiter and must not be promoted to one"
796 );
797 assert!(
798 err.suggested_fix().unwrap().contains("proxy"),
799 "an unattributable 429 must send the operator to the proxy, not only the VTA"
800 );
801 }
802
803 #[cfg(feature = "client")]
804 #[test]
805 fn retry_after_http_date_is_read() {
806 let err = VtaError::from_http_with_headers(
807 reqwest::StatusCode::TOO_MANY_REQUESTS,
808 &headers(&[
809 ("x-rate-limit-source", "vta"),
810 ("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT"),
811 ]),
812 String::new(),
813 None,
814 );
815 let VtaError::RateLimited { retry_after, .. } = err else {
816 panic!("got {err:?}")
817 };
818 assert_eq!(
819 retry_after.map(|t| t.to_rfc3339()),
820 Some("2015-10-21T07:28:00+00:00".to_string())
821 );
822 }
823
824 #[cfg(feature = "client")]
825 #[test]
826 fn from_http_without_headers_still_types_a_429() {
827 let err = VtaError::from_http(
828 reqwest::StatusCode::TOO_MANY_REQUESTS,
829 "Too Many Requests! Wait for 4s".into(),
830 );
831 assert!(
832 matches!(
833 err,
834 VtaError::RateLimited {
835 limited_by: RateLimitSource::Upstream,
836 retry_after: None,
837 ..
838 }
839 ),
840 "got {err:?}"
841 );
842 }
843
844 #[cfg(feature = "client")]
845 #[test]
846 fn other_statuses_are_unchanged_by_the_header_aware_constructor() {
847 let err = VtaError::from_http_with_headers(
848 reqwest::StatusCode::GONE,
849 &headers(&[("x-rate-limit-source", "vta")]),
850 "carve-out closed".into(),
851 None,
852 );
853 assert!(err.is_gone(), "got {err:?}");
854 assert!(
855 VtaError::rate_limited_from_http(
856 reqwest::StatusCode::UNAUTHORIZED,
857 &headers(&[]),
858 "",
859 "https://vta.example.com/auth/"
860 )
861 .is_none()
862 );
863 }
864
865 #[test]
866 fn problem_report_conflict_maps_to_typed_conflict() {
867 let err = VtaError::from_problem_report(
868 crate::protocols::problem_report_codes::CONFLICT,
869 "key id already exists",
870 );
871 assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
872 assert!(err.is_conflict());
873 }
874
875 #[test]
876 fn problem_report_unknown_code_lands_in_didcomm_remote() {
877 let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
878 match err {
879 VtaError::DidcommRemote { code, comment } => {
880 assert_eq!(code, "e.custom.xyz");
881 assert_eq!(comment, "weird thing");
882 }
883 other => panic!("expected DidcommRemote, got {other:?}"),
884 }
885 }
886
887 #[test]
888 fn suggested_fix_present_for_actionable_variants() {
889 // Each "operator can do something about this" variant must have
890 // a hint string; the message-is-the-hint / programmer-error
891 // variants return None.
892 assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
893 assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
894 assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
895 assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
896 assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
897 assert!(
898 VtaError::Server {
899 status: 500,
900 body: "boom".into(),
901 }
902 .suggested_fix()
903 .is_some()
904 );
905 assert!(
906 VtaError::UnsupportedTransport("rest only".into())
907 .suggested_fix()
908 .is_some()
909 );
910 assert!(
911 VtaError::DidcommTransport("offline".into())
912 .suggested_fix()
913 .is_some()
914 );
915
916 // Runtime service-management variants (spec §4) all have hints.
917 assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
918 assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
919 assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
920 assert!(
921 VtaError::MediatorHandshakeFailed {
922 reason: "trust-ping timeout".into()
923 }
924 .suggested_fix()
925 .is_some()
926 );
927 assert!(
928 VtaError::DrainTtlOutOfBounds {
929 min: 3600,
930 max: 2_592_000,
931 requested: 30,
932 }
933 .suggested_fix()
934 .is_some()
935 );
936 assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
937
938 // Self-explanatory / programmer-error: no canned hint.
939 assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
940 assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
941 assert!(
942 VtaError::DidcommRemote {
943 code: "e.unknown".into(),
944 comment: "x".into()
945 }
946 .suggested_fix()
947 .is_none()
948 );
949 }
950
951 /// Every typed runtime service-management variant must round-trip
952 /// through [`TypedErrorPayload`] without losing structured data.
953 /// The test cases line up 1:1 with the spec §4 list.
954 #[test]
955 fn typed_payload_round_trips_every_runtime_service_variant() {
956 let cases: Vec<VtaError> = vec![
957 VtaError::LastServiceRefused,
958 VtaError::ServiceNotPresent,
959 VtaError::ServiceAlreadyEnabled,
960 VtaError::MediatorHandshakeFailed {
961 reason: "trust-ping timeout after 10s".into(),
962 },
963 VtaError::DrainTtlOutOfBounds {
964 min: 3600,
965 max: 2_592_000,
966 requested: 30,
967 },
968 VtaError::NoPriorMutation,
969 VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
970 ];
971
972 for original in cases {
973 let payload = original.to_typed_payload().unwrap_or_else(|| {
974 panic!("variant must project to TypedErrorPayload: {original:?}")
975 });
976
977 // Round-trip through JSON to mirror what REST and DIDComm
978 // transports actually do on the wire.
979 let json = serde_json::to_string(&payload)
980 .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
981 let restored: TypedErrorPayload = serde_json::from_str(&json)
982 .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
983
984 assert_eq!(
985 payload, restored,
986 "TypedErrorPayload must round-trip through JSON",
987 );
988
989 // Reconstructing back to VtaError preserves the variant
990 // discriminant and any structured data.
991 let reconstructed = VtaError::from_typed_payload(restored);
992 match (&original, &reconstructed) {
993 (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
994 | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
995 | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
996 | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
997 (
998 VtaError::MediatorHandshakeFailed { reason: a },
999 VtaError::MediatorHandshakeFailed { reason: b },
1000 ) => assert_eq!(a, b),
1001 (
1002 VtaError::DrainTtlOutOfBounds {
1003 min: m1,
1004 max: x1,
1005 requested: r1,
1006 },
1007 VtaError::DrainTtlOutOfBounds {
1008 min: m2,
1009 max: x2,
1010 requested: r2,
1011 },
1012 ) => {
1013 assert_eq!(m1, m2);
1014 assert_eq!(x1, x2);
1015 assert_eq!(r1, r2);
1016 }
1017 (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
1018 assert_eq!(a, b)
1019 }
1020 (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
1021 }
1022 }
1023 }
1024
1025 /// The kebab-case `code` discriminator on the wire JSON is part of
1026 /// the contract for both REST and DIDComm transports — pin it
1027 /// explicitly so a `serde(rename)` change doesn't silently break
1028 /// existing peers.
1029 #[test]
1030 fn typed_payload_wire_discriminator_is_kebab_case() {
1031 let payload = TypedErrorPayload::DrainTtlOutOfBounds {
1032 min: 3600,
1033 max: 2_592_000,
1034 requested: 30,
1035 };
1036 let json = serde_json::to_value(&payload).unwrap();
1037 assert_eq!(json["code"], "drain-ttl-out-of-bounds");
1038 assert_eq!(json["min"], 3600);
1039 assert_eq!(json["max"], 2_592_000);
1040 assert_eq!(json["requested"], 30);
1041 }
1042
1043 /// `to_typed_payload` returns `None` for variants outside the
1044 /// runtime service-management surface — the wire-format channel
1045 /// is reserved for those typed variants and shouldn't blanket
1046 /// every error.
1047 #[test]
1048 fn typed_payload_is_none_for_non_service_management_variants() {
1049 assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
1050 assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
1051 assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
1052 assert!(
1053 VtaError::Server {
1054 status: 500,
1055 body: "x".into(),
1056 }
1057 .to_typed_payload()
1058 .is_none()
1059 );
1060 assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
1061 assert!(
1062 VtaError::DidcommRemote {
1063 code: "e.x".into(),
1064 comment: "x".into()
1065 }
1066 .to_typed_payload()
1067 .is_none()
1068 );
1069 assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
1070 }
1071}