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
310impl VtaError {
311    /// Create from an HTTP response status and error body.
312    ///
313    /// Public so a downstream SDK consumer wiring its own HTTP transport
314    /// (e.g. a wasm `gloo-net` client) can produce typed `VtaError`s
315    /// from status codes without re-implementing the mapping.
316    ///
317    /// A `429` becomes [`Self::RateLimited`] with
318    /// [`RateLimitSource::Upstream`]: without the response headers it can be
319    /// neither attributed nor given a wait. A consumer that has the headers
320    /// should call [`Self::from_http_with_headers`].
321    #[cfg(feature = "client")]
322    pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
323        match status.as_u16() {
324            429 => Self::RateLimited {
325                limited_by: RateLimitSource::Upstream,
326                retry_after: None,
327                limiter: None,
328                url: None,
329            },
330            401 => Self::Auth(body),
331            403 => Self::Forbidden(body),
332            404 => Self::NotFound(body),
333            400 | 422 => Self::Validation(body),
334            409 => Self::Conflict(body),
335            410 => Self::Gone(body),
336            s if s >= 500 => Self::Server { status: s, body },
337            s => Self::Other(format!("{s}: {body}")),
338        }
339    }
340
341    /// [`Self::from_http`], plus what only the response headers and the request
342    /// URL can say.
343    ///
344    /// Identical to `from_http` for every status except `429`, where it reads
345    /// the attribution header ([`crate::rate_limit::SOURCE_HEADER`]) and the
346    /// wait hint (`Retry-After`, else the legacy `x-ratelimit-after`) into
347    /// [`Self::RateLimited`]. `url` is the refused request's URL.
348    ///
349    /// Public, like `from_http`, for consumers wiring their own HTTP transport.
350    #[cfg(feature = "client")]
351    pub fn from_http_with_headers(
352        status: reqwest::StatusCode,
353        headers: &reqwest::header::HeaderMap,
354        body: String,
355        url: Option<&str>,
356    ) -> Self {
357        if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
358            return Self::from_http(status, body);
359        }
360        use crate::rate_limit as rl;
361        let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
362        let limited_by = RateLimitSource::from_source_header(header(rl::SOURCE_HEADER));
363        let now = chrono::Utc::now();
364        let retry_after = header(rl::RETRY_AFTER_HEADER)
365            .and_then(|v| rl::parse_retry_after(v, now))
366            .or_else(|| {
367                header(rl::LEGACY_RETRY_AFTER_HEADER).and_then(|v| rl::parse_retry_after(v, now))
368            });
369        Self::RateLimited {
370            limited_by,
371            retry_after,
372            limiter: limiter_from_body(limited_by, &body),
373            url: url.map(str::to_string),
374        }
375    }
376
377    /// Consume a failed `reqwest::Response` into a typed error, keeping the
378    /// headers and URL that [`Self::from_http_with_headers`] needs. The body is
379    /// passed through verbatim.
380    #[cfg(feature = "client")]
381    pub async fn from_response(resp: reqwest::Response) -> Self {
382        let status = resp.status();
383        let headers = resp.headers().clone();
384        let url = resp.url().to_string();
385        let body = resp.text().await.unwrap_or_default();
386        Self::from_http_with_headers(status, &headers, body, Some(&url))
387    }
388
389    /// `Some` iff `status` is `429`: the typed [`Self::RateLimited`] for it.
390    ///
391    /// For the auth helpers that return a boxed error and otherwise format the
392    /// status into a string. A rate limit is the refusal that must stay typed
393    /// there, because the string form reads as "authentication failed" and
394    /// sends the operator to re-authenticate.
395    #[cfg(feature = "client")]
396    pub fn rate_limited_from_http(
397        status: reqwest::StatusCode,
398        headers: &reqwest::header::HeaderMap,
399        body: &str,
400        url: &str,
401    ) -> Option<Self> {
402        (status == reqwest::StatusCode::TOO_MANY_REQUESTS)
403            .then(|| Self::from_http_with_headers(status, headers, body.to_string(), Some(url)))
404    }
405
406    /// Create from a DIDComm problem-report `code` + `comment`. Mirrors
407    /// the REST [`Self::from_http`] mapping so callers can `match` on the
408    /// same variants regardless of transport.
409    ///
410    /// Standard codes (`e.p.msg.unauthorized` / `bad-request` / `not-found`
411    /// / `conflict` / `internal-error`) become typed variants. Anything
412    /// else lands in [`Self::DidcommRemote`] preserving the original code.
413    pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
414        use crate::protocols::problem_report_codes as c;
415        let comment = comment.into();
416        match code {
417            c::CONFLICT => Self::Conflict(comment),
418            c::NOT_FOUND => Self::NotFound(comment),
419            c::UNAUTHORIZED => Self::Auth(comment),
420            c::FORBIDDEN => Self::Forbidden(comment),
421            c::BAD_REQUEST => Self::Validation(comment),
422            c::INTERNAL => Self::Server {
423                status: 500,
424                body: comment,
425            },
426            other => Self::DidcommRemote {
427                code: other.to_string(),
428                comment,
429            },
430        }
431    }
432
433    /// Reconstruct the typed [`VtaError`] variant from a wire-format
434    /// [`TypedErrorPayload`]. Used by the client when decoding REST
435    /// response bodies / DIDComm problem-report args for the runtime
436    /// service-management surface (spec §4).
437    pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
438        match payload {
439            TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
440            TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
441            TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
442            TypedErrorPayload::MediatorHandshakeFailed { reason } => {
443                Self::MediatorHandshakeFailed { reason }
444            }
445            TypedErrorPayload::DrainTtlOutOfBounds {
446                min,
447                max,
448                requested,
449            } => Self::DrainTtlOutOfBounds {
450                min,
451                max,
452                requested,
453            },
454            TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
455            TypedErrorPayload::UnsupportedTransport { detail } => {
456                Self::UnsupportedTransport(detail)
457            }
458        }
459    }
460
461    /// Project this error onto the wire-format [`TypedErrorPayload`]
462    /// when the variant is one of the runtime service-management
463    /// errors. Returns `None` for variants that don't have a
464    /// structured wire form (network errors, generic conflicts,
465    /// programmer-level protocol errors, …).
466    #[must_use]
467    pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
468        match self {
469            Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
470            Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
471            Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
472            Self::MediatorHandshakeFailed { reason } => {
473                Some(TypedErrorPayload::MediatorHandshakeFailed {
474                    reason: reason.clone(),
475                })
476            }
477            Self::DrainTtlOutOfBounds {
478                min,
479                max,
480                requested,
481            } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
482                min: *min,
483                max: *max,
484                requested: *requested,
485            }),
486            Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
487            Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
488                detail: detail.clone(),
489            }),
490            _ => None,
491        }
492    }
493
494    /// Returns true if a rate limiter refused the request (429).
495    pub fn is_rate_limited(&self) -> bool {
496        matches!(self, Self::RateLimited { .. })
497    }
498
499    /// Returns true if the resource was permanently consumed/gone (410).
500    pub fn is_gone(&self) -> bool {
501        matches!(self, Self::Gone(_))
502    }
503
504    /// Returns true if a create/insert collided with an existing entry (409).
505    pub fn is_conflict(&self) -> bool {
506        matches!(self, Self::Conflict(_))
507    }
508
509    /// Returns true if this is an authentication/authorization error.
510    pub fn is_auth(&self) -> bool {
511        matches!(self, Self::Auth(_) | Self::Forbidden(_))
512    }
513
514    /// Returns true if this is a network-level error (retryable).
515    pub fn is_network(&self) -> bool {
516        #[cfg(feature = "client")]
517        if matches!(self, Self::Network(_)) {
518            return true;
519        }
520        false
521    }
522
523    /// Returns true if the resource was not found.
524    pub fn is_not_found(&self) -> bool {
525        matches!(self, Self::NotFound(_))
526    }
527
528    /// Operator-actionable hint matching this error variant.
529    ///
530    /// `None` for variants where no generic guidance applies (the message
531    /// itself is the hint, or the failure is a programmer error). The
532    /// CLI layer (`vta-cli-common::render::print_cli_error`) already
533    /// implements bin-aware suggestions ("`pnm acl create …`"); this
534    /// method gives **non-CLI consumers** — web UIs, GUIs, custom
535    /// dashboards — the same hint surface without needing to fork the
536    /// dispatch logic.
537    ///
538    /// Returns a `&'static str` so callers can compose it into their
539    /// own UI without lifetime juggling. The bin-specific substitution
540    /// (`pnm` vs `cnm`) is left to the CLI layer because only it
541    /// knows which binary the operator is running.
542    #[must_use]
543    pub fn suggested_fix(&self) -> Option<&'static str> {
544        match self {
545            Self::Auth(_) => Some(
546                "Token may be expired. Re-authenticate against the VTA, or check that \
547                 the `/auth` endpoint is reachable.",
548            ),
549            Self::Forbidden(_) => Some(
550                "Your role or context access doesn't permit this operation. Inspect \
551                 the ACL entry for your DID against the target context.",
552            ),
553            Self::Gone(_) => Some(
554                "The resource was single-use or time-limited and has been consumed or has \
555                 expired — retrying will not succeed. If this was the bootstrap carve-out, \
556                 ask an existing admin to provision-integration a new operator instead.",
557            ),
558            Self::Conflict(_) => Some(
559                "The resource already exists. Use the corresponding `update` or \
560                 `delete-then-create` flow rather than `create`.",
561            ),
562            Self::Unavailable { .. } => Some(
563                "The VTA is temporarily busy — this is a wait, not a failure. If the \
564                 request carried an idempotency key, an earlier attempt on that key is \
565                 still running: retry with the same key and the original result will be \
566                 returned rather than the operation repeated.",
567            ),
568            Self::RateLimited { limited_by, .. } => {
569                Some(crate::rate_limit::suggested_fix(*limited_by))
570            }
571            Self::Validation(_) => Some(
572                "The request body or parameters were rejected by the VTA's schema. \
573                 Inspect the response body for the specific field that failed.",
574            ),
575            Self::Server { .. } => {
576                Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
577            }
578            Self::UnsupportedTransport(_) => Some(
579                "The operation requires a specific transport (REST or DIDComm). \
580                 Check which mode the client is in and whether the endpoint supports it.",
581            ),
582            Self::DidcommTransport(_) => {
583                Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
584            }
585            Self::TspTransport(_) => Some(
586                "The VTA's TSP mediator is unreachable or rejected the frame. Retry, or \
587                 reach the VTA over another transport: `--transport didcomm` / \
588                 `--transport rest`.",
589            ),
590            #[cfg(feature = "client")]
591            Self::Network(_) => Some(
592                "Network error reaching the VTA. Confirm the URL is correct and the \
593                 host is reachable.",
594            ),
595            // Runtime service-management variants (spec §4). The CLI
596            // layer enriches these with the specific kind/command
597            // it just ran; this is the generic fallback hint for
598            // non-CLI consumers.
599            Self::LastServiceRefused => Some(
600                "This operation would leave the VTA with no advertised transport \
601                 services. Enable the other transport first (REST or DIDComm) \
602                 before disabling this one.",
603            ),
604            Self::ServiceNotPresent => Some(
605                "The service kind isn't currently enabled. Use \
606                 `services <kind> enable …` to bring it online before \
607                 updating, disabling, or rolling it back.",
608            ),
609            Self::ServiceAlreadyEnabled => Some(
610                "The service kind is already enabled. Use \
611                 `services <kind> update …` to change its configuration, \
612                 or `disable` to remove it.",
613            ),
614            Self::MediatorHandshakeFailed { .. } => Some(
615                "DIDComm handshake against the candidate mediator failed. \
616                 Confirm the mediator DID is correct and the mediator is \
617                 reachable; check the inner reason for the specific cause.",
618            ),
619            Self::DrainTtlOutOfBounds { .. } => Some(
620                "The supplied drain TTL is outside the allowed range. Pick a \
621                 value within the [min, max] interval shown in the error message.",
622            ),
623            Self::NoPriorMutation => Some(
624                "No prior mutation for this service kind to roll back from. Use \
625                 the direct `enable`/`update`/`disable` command instead.",
626            ),
627            Self::UnsupportedTaskType { .. } => Some(
628                "The peer does not serve this Trust Task at the version this client \
629                 dispatches. When the error names a version the peer does serve, the \
630                 two are different ages and one of them needs upgrading; when it names \
631                 none, check you are pointed at the agent you meant.",
632            ),
633            Self::NoMatchingProtocol { .. } => Some(
634                "The two parties share no transport protocol. Enable a common \
635                 transport (TSP, DIDComm, or REST) on both sides — compare each \
636                 DID document's advertised `service` entries and add the missing one.",
637            ),
638            // No generic hint for these — the message itself is the
639            // hint, or the failure is a protocol/programmer error
640            // surface that an automated suggestion would only confuse.
641            // The hint depends on policy the message already reports — whether
642            // another device must approve, or this one may. A static string
643            // would have to guess, and guessing wrong sends the operator to the
644            // wrong screen. The CLI's consent loop says it precisely instead.
645            Self::ConsentRequired { .. } => None,
646            Self::NotFound(_)
647            | Self::DidcommRemote { .. }
648            | Self::Protocol(_)
649            | Self::Serialization(_)
650            | Self::Other(_) => None,
651        }
652    }
653}
654
655/// Which limiter tripped, when the refusing service said.
656///
657/// A JSON body's `limiter` field wins. Failing that, a plain-text body from a
658/// *labelled* source is taken as its description of the limiter — the VTA's
659/// contract is that the body names it. An unlabelled `429`'s body is whatever a
660/// proxy or an older VTA wrote (`"Too Many Requests! Wait for 4s"`), which names
661/// no limiter, so it is not promoted to one.
662#[cfg(feature = "client")]
663fn limiter_from_body(limited_by: RateLimitSource, body: &str) -> Option<String> {
664    const MAX_LIMITER_LEN: usize = 128;
665    if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
666        return v
667            .get("limiter")
668            .and_then(|l| l.as_str())
669            .map(|l| l.chars().take(MAX_LIMITER_LEN).collect());
670    }
671    let text = body.trim();
672    (limited_by != RateLimitSource::Upstream && !text.is_empty())
673        .then(|| text.chars().take(MAX_LIMITER_LEN).collect())
674}
675
676impl From<crate::did_key::DidKeyError> for VtaError {
677    fn from(e: crate::did_key::DidKeyError) -> Self {
678        Self::Validation(e.to_string())
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[cfg(feature = "client")]
687    #[test]
688    fn from_http_410_maps_to_gone() {
689        let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
690        assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
691    }
692
693    #[cfg(feature = "client")]
694    fn headers(pairs: &[(&'static str, &str)]) -> reqwest::header::HeaderMap {
695        let mut h = reqwest::header::HeaderMap::new();
696        for (k, v) in pairs {
697            h.insert(*k, v.parse().unwrap());
698        }
699        h
700    }
701
702    #[cfg(feature = "client")]
703    #[test]
704    fn a_labelled_429_is_attributed_to_the_vta_with_its_wait() {
705        let before = chrono::Utc::now();
706        let err = VtaError::from_http_with_headers(
707            reqwest::StatusCode::TOO_MANY_REQUESTS,
708            &headers(&[("x-rate-limit-source", "vta"), ("retry-after", "4")]),
709            r#"{"error":"rate limited","limiter":"auth"}"#.into(),
710            Some("https://vta.example.com/auth/challenge"),
711        );
712        let VtaError::RateLimited {
713            limited_by,
714            retry_after,
715            limiter,
716            url,
717        } = &err
718        else {
719            panic!("429 must map to RateLimited, got {err:?}");
720        };
721        assert_eq!(*limited_by, RateLimitSource::Vta);
722        let wait = retry_after.expect("Retry-After must be kept") - before;
723        assert!(
724            (3..=5).contains(&wait.num_seconds()),
725            "retry_after should be ~4s out, was {wait}"
726        );
727        assert_eq!(limiter.as_deref(), Some("auth"));
728        assert_eq!(
729            url.as_deref(),
730            Some("https://vta.example.com/auth/challenge")
731        );
732        assert!(err.is_rate_limited());
733        assert!(
734            err.suggested_fix()
735                .unwrap()
736                .contains("rate_limit_interval_secs"),
737            "a VTA refusal must point at the VTA's knobs"
738        );
739    }
740
741    #[cfg(feature = "client")]
742    #[test]
743    fn a_plain_text_body_from_a_labelled_vta_names_the_limiter() {
744        let err = VtaError::from_http_with_headers(
745            reqwest::StatusCode::TOO_MANY_REQUESTS,
746            &headers(&[("x-rate-limit-source", "vta")]),
747            "did-log".into(),
748            None,
749        );
750        assert!(
751            matches!(&err, VtaError::RateLimited { limiter: Some(l), .. } if l == "did-log"),
752            "got {err:?}"
753        );
754    }
755
756    #[cfg(feature = "client")]
757    #[test]
758    fn an_unlabelled_429_is_upstream_and_keeps_the_legacy_wait_hint() {
759        let err = VtaError::from_http_with_headers(
760            reqwest::StatusCode::TOO_MANY_REQUESTS,
761            // What tower-governor sends from a VTA older than the source header.
762            &headers(&[("x-ratelimit-after", "4")]),
763            "Too Many Requests! Wait for 4s".into(),
764            Some("https://vta.example.com/auth/challenge"),
765        );
766        let VtaError::RateLimited {
767            limited_by,
768            retry_after,
769            limiter,
770            ..
771        } = &err
772        else {
773            panic!("got {err:?}");
774        };
775        assert_eq!(*limited_by, RateLimitSource::Upstream);
776        assert!(retry_after.is_some(), "the legacy header is still a hint");
777        assert_eq!(
778            *limiter, None,
779            "a proxy's body names no limiter and must not be promoted to one"
780        );
781        assert!(
782            err.suggested_fix().unwrap().contains("proxy"),
783            "an unattributable 429 must send the operator to the proxy, not only the VTA"
784        );
785    }
786
787    #[cfg(feature = "client")]
788    #[test]
789    fn retry_after_http_date_is_read() {
790        let err = VtaError::from_http_with_headers(
791            reqwest::StatusCode::TOO_MANY_REQUESTS,
792            &headers(&[
793                ("x-rate-limit-source", "vta"),
794                ("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT"),
795            ]),
796            String::new(),
797            None,
798        );
799        let VtaError::RateLimited { retry_after, .. } = err else {
800            panic!("got {err:?}")
801        };
802        assert_eq!(
803            retry_after.map(|t| t.to_rfc3339()),
804            Some("2015-10-21T07:28:00+00:00".to_string())
805        );
806    }
807
808    #[cfg(feature = "client")]
809    #[test]
810    fn from_http_without_headers_still_types_a_429() {
811        let err = VtaError::from_http(
812            reqwest::StatusCode::TOO_MANY_REQUESTS,
813            "Too Many Requests! Wait for 4s".into(),
814        );
815        assert!(
816            matches!(
817                err,
818                VtaError::RateLimited {
819                    limited_by: RateLimitSource::Upstream,
820                    retry_after: None,
821                    ..
822                }
823            ),
824            "got {err:?}"
825        );
826    }
827
828    #[cfg(feature = "client")]
829    #[test]
830    fn other_statuses_are_unchanged_by_the_header_aware_constructor() {
831        let err = VtaError::from_http_with_headers(
832            reqwest::StatusCode::GONE,
833            &headers(&[("x-rate-limit-source", "vta")]),
834            "carve-out closed".into(),
835            None,
836        );
837        assert!(err.is_gone(), "got {err:?}");
838        assert!(
839            VtaError::rate_limited_from_http(
840                reqwest::StatusCode::UNAUTHORIZED,
841                &headers(&[]),
842                "",
843                "https://vta.example.com/auth/"
844            )
845            .is_none()
846        );
847    }
848
849    #[test]
850    fn problem_report_conflict_maps_to_typed_conflict() {
851        let err = VtaError::from_problem_report(
852            crate::protocols::problem_report_codes::CONFLICT,
853            "key id already exists",
854        );
855        assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
856        assert!(err.is_conflict());
857    }
858
859    #[test]
860    fn problem_report_unknown_code_lands_in_didcomm_remote() {
861        let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
862        match err {
863            VtaError::DidcommRemote { code, comment } => {
864                assert_eq!(code, "e.custom.xyz");
865                assert_eq!(comment, "weird thing");
866            }
867            other => panic!("expected DidcommRemote, got {other:?}"),
868        }
869    }
870
871    #[test]
872    fn suggested_fix_present_for_actionable_variants() {
873        // Each "operator can do something about this" variant must have
874        // a hint string; the message-is-the-hint / programmer-error
875        // variants return None.
876        assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
877        assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
878        assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
879        assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
880        assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
881        assert!(
882            VtaError::Server {
883                status: 500,
884                body: "boom".into(),
885            }
886            .suggested_fix()
887            .is_some()
888        );
889        assert!(
890            VtaError::UnsupportedTransport("rest only".into())
891                .suggested_fix()
892                .is_some()
893        );
894        assert!(
895            VtaError::DidcommTransport("offline".into())
896                .suggested_fix()
897                .is_some()
898        );
899
900        // Runtime service-management variants (spec §4) all have hints.
901        assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
902        assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
903        assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
904        assert!(
905            VtaError::MediatorHandshakeFailed {
906                reason: "trust-ping timeout".into()
907            }
908            .suggested_fix()
909            .is_some()
910        );
911        assert!(
912            VtaError::DrainTtlOutOfBounds {
913                min: 3600,
914                max: 2_592_000,
915                requested: 30,
916            }
917            .suggested_fix()
918            .is_some()
919        );
920        assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
921
922        // Self-explanatory / programmer-error: no canned hint.
923        assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
924        assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
925        assert!(
926            VtaError::DidcommRemote {
927                code: "e.unknown".into(),
928                comment: "x".into()
929            }
930            .suggested_fix()
931            .is_none()
932        );
933    }
934
935    /// Every typed runtime service-management variant must round-trip
936    /// through [`TypedErrorPayload`] without losing structured data.
937    /// The test cases line up 1:1 with the spec §4 list.
938    #[test]
939    fn typed_payload_round_trips_every_runtime_service_variant() {
940        let cases: Vec<VtaError> = vec![
941            VtaError::LastServiceRefused,
942            VtaError::ServiceNotPresent,
943            VtaError::ServiceAlreadyEnabled,
944            VtaError::MediatorHandshakeFailed {
945                reason: "trust-ping timeout after 10s".into(),
946            },
947            VtaError::DrainTtlOutOfBounds {
948                min: 3600,
949                max: 2_592_000,
950                requested: 30,
951            },
952            VtaError::NoPriorMutation,
953            VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
954        ];
955
956        for original in cases {
957            let payload = original.to_typed_payload().unwrap_or_else(|| {
958                panic!("variant must project to TypedErrorPayload: {original:?}")
959            });
960
961            // Round-trip through JSON to mirror what REST and DIDComm
962            // transports actually do on the wire.
963            let json = serde_json::to_string(&payload)
964                .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
965            let restored: TypedErrorPayload = serde_json::from_str(&json)
966                .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
967
968            assert_eq!(
969                payload, restored,
970                "TypedErrorPayload must round-trip through JSON",
971            );
972
973            // Reconstructing back to VtaError preserves the variant
974            // discriminant and any structured data.
975            let reconstructed = VtaError::from_typed_payload(restored);
976            match (&original, &reconstructed) {
977                (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
978                | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
979                | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
980                | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
981                (
982                    VtaError::MediatorHandshakeFailed { reason: a },
983                    VtaError::MediatorHandshakeFailed { reason: b },
984                ) => assert_eq!(a, b),
985                (
986                    VtaError::DrainTtlOutOfBounds {
987                        min: m1,
988                        max: x1,
989                        requested: r1,
990                    },
991                    VtaError::DrainTtlOutOfBounds {
992                        min: m2,
993                        max: x2,
994                        requested: r2,
995                    },
996                ) => {
997                    assert_eq!(m1, m2);
998                    assert_eq!(x1, x2);
999                    assert_eq!(r1, r2);
1000                }
1001                (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
1002                    assert_eq!(a, b)
1003                }
1004                (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
1005            }
1006        }
1007    }
1008
1009    /// The kebab-case `code` discriminator on the wire JSON is part of
1010    /// the contract for both REST and DIDComm transports — pin it
1011    /// explicitly so a `serde(rename)` change doesn't silently break
1012    /// existing peers.
1013    #[test]
1014    fn typed_payload_wire_discriminator_is_kebab_case() {
1015        let payload = TypedErrorPayload::DrainTtlOutOfBounds {
1016            min: 3600,
1017            max: 2_592_000,
1018            requested: 30,
1019        };
1020        let json = serde_json::to_value(&payload).unwrap();
1021        assert_eq!(json["code"], "drain-ttl-out-of-bounds");
1022        assert_eq!(json["min"], 3600);
1023        assert_eq!(json["max"], 2_592_000);
1024        assert_eq!(json["requested"], 30);
1025    }
1026
1027    /// `to_typed_payload` returns `None` for variants outside the
1028    /// runtime service-management surface — the wire-format channel
1029    /// is reserved for those typed variants and shouldn't blanket
1030    /// every error.
1031    #[test]
1032    fn typed_payload_is_none_for_non_service_management_variants() {
1033        assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
1034        assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
1035        assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
1036        assert!(
1037            VtaError::Server {
1038                status: 500,
1039                body: "x".into(),
1040            }
1041            .to_typed_payload()
1042            .is_none()
1043        );
1044        assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
1045        assert!(
1046            VtaError::DidcommRemote {
1047                code: "e.x".into(),
1048                comment: "x".into()
1049            }
1050            .to_typed_payload()
1051            .is_none()
1052        );
1053        assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
1054    }
1055}