Skip to main content

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}