trql_client/error.rs
1//! The client's error taxonomy.
2//!
3//! Variants separate the three failure classes an operator must be able to
4//! tell apart: the transport failed (retry / check connectivity), the registry
5//! rejected the task (act on the machine-readable code), or one side broke the
6//! wire contract (a bug — never retried).
7
8use chrono::{DateTime, Utc};
9use trust_tasks_rs::TrustTaskCode;
10
11use crate::transport::TransportKind;
12
13/// Errors returned by [`crate::TrqlClient`] and [`crate::TrqlTransport`]
14/// implementations.
15#[derive(Debug, thiserror::Error)]
16pub enum TrqlError {
17 /// The client or transport was built with missing/invalid configuration.
18 #[error("configuration error: {0}")]
19 Config(String),
20
21 /// The transport could not complete the exchange (connect, send, or
22 /// receive failure). Retryable at the caller's discretion.
23 #[error("{kind} transport error: {detail}")]
24 Transport {
25 /// Which binding failed.
26 kind: TransportKind,
27 /// What actually happened, named per failure (never a generic hint).
28 detail: String,
29 },
30
31 /// No reply arrived within the transport's configured reply window.
32 #[error("{kind} reply timed out after {waited_secs}s")]
33 Timeout {
34 /// Which binding timed out.
35 kind: TransportKind,
36 /// How long the transport waited.
37 waited_secs: u64,
38 },
39
40 /// The registry answered with a `trust-task-error` document.
41 #[error("registry rejected the task ({code}): {}", message.as_deref().unwrap_or("no detail"))]
42 Rejected {
43 /// Machine-readable trust-task error code.
44 code: TrustTaskCode,
45 /// Whether the registry marked the failure retryable.
46 retryable: bool,
47 /// Earliest retry instant, when the registry supplied one.
48 retry_after: Option<DateTime<Utc>>,
49 /// Human-readable detail from the registry, if any.
50 message: Option<String>,
51 },
52
53 /// The peer's reply violated the Trust Task contract: it didn't parse,
54 /// wasn't correlated to the request, or carried an unexpected type. This
55 /// is a bug on one side of the wire, not a transient condition — it is
56 /// never worth retrying.
57 #[error("contract violation: {0}")]
58 Contract(String),
59
60 /// The reply is correlated to our request but answers a *different* TRQP
61 /// tuple than the one we asked about.
62 ///
63 /// Correlation proves the reply belongs to our exchange; it says nothing
64 /// about what the reply is an answer to. A registry that echoes a
65 /// different `entity_id`/`authority_id`/`action`/`resource` has answered
66 /// someone else's question, and treating it as ours silently substitutes
67 /// the subject of an authorization decision.
68 #[error("registry answered for a different {field}: asked `{asked}`, answered `{answered}`")]
69 AnswerMismatch {
70 /// Which tuple member disagreed.
71 field: &'static str,
72 /// The value we sent on the request.
73 asked: String,
74 /// The value the registry echoed back.
75 answered: String,
76 },
77
78 /// We followed a referral to this registry, and its answer does not claim
79 /// the authority that referred us.
80 ///
81 /// A `TrustRegistry` referral in a VTC's DID document is a
82 /// self-assertion — anyone may publish a document naming any registry.
83 /// Authority flows registry → subject, so a referral is only closed when
84 /// the registry answers *for* the DID we started from. Until then the
85 /// referral has established where to ask and nothing about the answer.
86 #[error(
87 "referral from `{origin}` not closed: registry answered for authority `{answered}`, \
88 which does not confirm the referral"
89 )]
90 ReferralNotClosed {
91 /// The DID whose document referred us here.
92 origin: String,
93 /// The authority the registry actually answered for.
94 answered: String,
95 },
96
97 /// The registry advertises no transport this client also speaks.
98 ///
99 /// Carries both sides' sets so an operator can see what to enable rather
100 /// than guess. Never a silent downgrade to a transport the registry did
101 /// not advertise.
102 #[error(
103 "no shared transport with the registry (we speak [{}], it advertises [{}])",
104 format_kinds(ours),
105 format_kinds(theirs)
106 )]
107 NoMatchingTransport {
108 /// Transports this client can use (compiled in and offered).
109 ours: Vec<TransportKind>,
110 /// Transports the registry advertises in its DID document.
111 theirs: Vec<TransportKind>,
112 },
113}
114
115/// Render a transport list for an error message; `"none"` when empty, so a
116/// registry advertising nothing reads clearly rather than as `[]`.
117fn format_kinds(kinds: &[TransportKind]) -> String {
118 if kinds.is_empty() {
119 return "none".to_string();
120 }
121 kinds
122 .iter()
123 .map(ToString::to_string)
124 .collect::<Vec<_>>()
125 .join(", ")
126}
127
128impl TrqlError {
129 /// Whether retrying the same query could plausibly succeed.
130 ///
131 /// Contract violations and configuration errors are never retryable; a
132 /// registry rejection is retryable only when the registry said so.
133 pub fn is_retryable(&self) -> bool {
134 match self {
135 Self::Transport { .. } | Self::Timeout { .. } => true,
136 Self::Rejected { retryable, .. } => *retryable,
137 // A capability mismatch is a deployment fact, not a transient
138 // condition: retrying the same pair of DID documents cannot help.
139 // Nor can it help a registry that answers the wrong question or a
140 // referral that does not close: both are stable facts about the
141 // deployment, and retrying only re-asks a question already
142 // answered wrongly.
143 Self::Config(_)
144 | Self::Contract(_)
145 | Self::NoMatchingTransport { .. }
146 | Self::AnswerMismatch { .. }
147 | Self::ReferralNotClosed { .. } => false,
148 }
149 }
150}