Skip to main content

agent_uri_dht/
error.rs

1//! Custom error types for DHT operations.
2
3use std::fmt;
4use std::time::Duration;
5
6use crate::MutationKind;
7
8/// Errors that can occur during DHT operations.
9///
10/// `#[non_exhaustive]`, so a `match` on this enum needs a `_` arm. New variants
11/// name failures currently reported more coarsely, and adding one should not
12/// be a major release.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum DhtError {
16    /// The agent is not registered.
17    NotFound {
18        /// The agent URI that was not found
19        agent_uri: String,
20    },
21    /// The registration already exists.
22    AlreadyRegistered {
23        /// The agent URI that was already registered
24        agent_uri: String,
25    },
26    /// The registration has expired.
27    Expired {
28        /// The agent URI whose registration expired
29        agent_uri: String,
30    },
31    /// The attestation is invalid or missing.
32    InvalidAttestation {
33        /// The agent URI with invalid attestation
34        agent_uri: String,
35        /// Reason the attestation is invalid
36        reason: String,
37    },
38    /// An existing agent identifier was presented under a different capability path.
39    IdentityCapabilityConflict {
40        /// Stable trust-root and agent-ID identity key
41        identity: String,
42        /// Capability path already bound to the identity
43        existing_path: String,
44        /// New conflicting capability path
45        requested_path: String,
46    },
47    /// Too many agents already hold the capability path being registered.
48    ///
49    /// The key named is the one the registration derives from its own path.
50    /// A crowded ancestor never produces this: an agent is charged for the key
51    /// it chose, not for the subtree it happens to fall inside.
52    KeyCapacityExceeded {
53        /// The DHT key that is at capacity
54        key: String,
55        /// Maximum allowed registrations
56        max: usize,
57    },
58    /// The endpoints list is empty.
59    NoEndpoints,
60    /// The write presented no valid proof that the agent's key holder
61    /// authorized it.
62    Unauthorized {
63        /// The agent URI whose record the write targeted
64        agent_uri: String,
65        /// The operation that was refused
66        operation: MutationKind,
67    },
68    /// The attestation names a different agent key than the registration.
69    AgentKeyMismatch {
70        /// The agent URI whose registration was refused
71        agent_uri: String,
72    },
73    /// A node refused to store the record.
74    ///
75    /// Distinct from the variants that name a specific defect. A distributed
76    /// backend applies checks the in-process index has no reason to: whether a
77    /// record's lifetime is one this node will hold, whether its encoding is
78    /// one this node speaks. Collapsing those into `InvalidAttestation` would
79    /// send an operator looking at trust roots for a clock problem.
80    Rejected {
81        /// The agent URI whose record was refused
82        agent_uri: String,
83        /// Why the node refused it
84        reason: String,
85    },
86    /// The write's sequence number did not advance past the record's.
87    ///
88    /// Either the write is a replay of one already applied, or two writers are
89    /// racing against the same record.
90    StaleSequence {
91        /// The agent URI whose record the write targeted
92        agent_uri: String,
93        /// The sequence number the write presented
94        presented: u64,
95        /// The sequence number the record already holds
96        current: u64,
97    },
98    /// The operation did not complete within its deadline.
99    Timeout {
100        /// The operation that timed out
101        operation: &'static str,
102        /// How long the operation was given
103        after: Duration,
104    },
105    /// Too few replicas answered to satisfy the requested quorum.
106    QuorumFailed {
107        /// Replicas that answered
108        achieved: usize,
109        /// Replicas the requested quorum needed
110        required: usize,
111    },
112    /// The node is not connected to any peer that can serve the operation.
113    NoPeers,
114    /// The local node cannot serve the request at all.
115    ///
116    /// Distinct from [`DhtError::NoPeers`], which describes the overlay. This
117    /// describes the node the caller is holding: it has shut down, or its own
118    /// store will not take the record. Retrying against the same handle will
119    /// fail the same way.
120    Unavailable {
121        /// What is wrong with this node
122        reason: String,
123    },
124}
125
126impl fmt::Display for DhtError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::NotFound { agent_uri } => write!(
130                f,
131                "agent '{agent_uri}' not found in DHT; verify the URI is correct and the agent \
132                 is registered"
133            ),
134            Self::AlreadyRegistered { agent_uri } => write!(
135                f,
136                "agent '{agent_uri}' is already registered; use update_endpoint to modify the \
137                 registration"
138            ),
139            Self::Expired { agent_uri } => write!(
140                f,
141                "registration for agent '{agent_uri}' has expired; re-register to restore"
142            ),
143            Self::InvalidAttestation { agent_uri, reason } => {
144                write!(f, "invalid attestation for agent '{agent_uri}': {reason}")
145            }
146            Self::IdentityCapabilityConflict {
147                identity,
148                existing_path,
149                requested_path,
150            } => write!(
151                f,
152                "agent identity '{identity}' is already bound to capability path \
153                 '{existing_path}' and cannot be reused at '{requested_path}'; mint a new agent ID"
154            ),
155            Self::KeyCapacityExceeded { key, max } => write!(
156                f,
157                "DHT key '{key}' has reached maximum capacity of {max} registrations"
158            ),
159            Self::NoEndpoints => write!(f, "registration must have at least one endpoint"),
160            Self::Unauthorized {
161                agent_uri,
162                operation,
163            } => write!(
164                f,
165                "{operation} on agent '{agent_uri}' was not authorized by the agent key bound \
166                 to its registration; the proof must be signed by that key over this exact \
167                 operation"
168            ),
169            Self::AgentKeyMismatch { agent_uri } => write!(
170                f,
171                "the attestation presented for agent '{agent_uri}' attests a different key \
172                 than the registration names; a token attests one agent's key and cannot be \
173                 presented for another"
174            ),
175            Self::Rejected { agent_uri, reason } => write!(
176                f,
177                "a node refused to store the record for agent '{agent_uri}': {reason}"
178            ),
179            Self::StaleSequence {
180                agent_uri,
181                presented,
182                current,
183            } => write!(
184                f,
185                "write to agent '{agent_uri}' presented sequence {presented}, which does not \
186                 advance past the record's {current}; re-read the registration and sign a \
187                 higher sequence"
188            ),
189            Self::Timeout { operation, after } => write!(
190                f,
191                "{operation} did not complete within {after:?}; the network may be slow or \
192                 partitioned, and the operation may still have taken effect"
193            ),
194            Self::QuorumFailed { achieved, required } => write!(
195                f,
196                "only {achieved} of the {required} replicas required by the requested quorum \
197                 responded; the operation may have partially applied"
198            ),
199            Self::NoPeers => write!(
200                f,
201                "not connected to any peer; bootstrap against a known peer before issuing \
202                 operations"
203            ),
204            Self::Unavailable { reason } => {
205                write!(f, "this DHT node cannot serve the request: {reason}")
206            }
207        }
208    }
209}
210
211impl std::error::Error for DhtError {}
212
213impl DhtError {
214    /// Creates a `NotFound` error.
215    #[must_use]
216    pub fn not_found(agent_uri: impl Into<String>) -> Self {
217        Self::NotFound {
218            agent_uri: agent_uri.into(),
219        }
220    }
221
222    /// Creates an `AlreadyRegistered` error.
223    #[must_use]
224    pub fn already_registered(agent_uri: impl Into<String>) -> Self {
225        Self::AlreadyRegistered {
226            agent_uri: agent_uri.into(),
227        }
228    }
229
230    /// Creates an `Expired` error.
231    #[must_use]
232    pub fn expired(agent_uri: impl Into<String>) -> Self {
233        Self::Expired {
234            agent_uri: agent_uri.into(),
235        }
236    }
237
238    /// Creates an `InvalidAttestation` error.
239    #[must_use]
240    pub fn invalid_attestation(agent_uri: impl Into<String>, reason: impl Into<String>) -> Self {
241        Self::InvalidAttestation {
242            agent_uri: agent_uri.into(),
243            reason: reason.into(),
244        }
245    }
246
247    /// Creates an `Unavailable` error.
248    #[must_use]
249    pub fn unavailable(reason: impl Into<String>) -> Self {
250        Self::Unavailable {
251            reason: reason.into(),
252        }
253    }
254
255    /// Creates a `Rejected` error.
256    #[must_use]
257    pub fn rejected(agent_uri: impl Into<String>, reason: impl Into<String>) -> Self {
258        Self::Rejected {
259            agent_uri: agent_uri.into(),
260            reason: reason.into(),
261        }
262    }
263
264    /// Creates a `KeyCapacityExceeded` error.
265    #[must_use]
266    pub fn key_capacity_exceeded(key: impl Into<String>, max: usize) -> Self {
267        Self::KeyCapacityExceeded {
268            key: key.into(),
269            max,
270        }
271    }
272
273    /// Returns true if this error indicates the agent was not found.
274    #[must_use]
275    pub const fn is_not_found(&self) -> bool {
276        matches!(self, Self::NotFound { .. })
277    }
278
279    /// Returns true if this error indicates the registration expired.
280    #[must_use]
281    pub const fn is_expired(&self) -> bool {
282        matches!(self, Self::Expired { .. })
283    }
284
285    /// Returns true if retrying the operation could plausibly succeed.
286    ///
287    /// Transient failures describe the network, not the request: the same call
288    /// against a healthier overlay may succeed. Every other variant rejects the
289    /// request itself and will keep rejecting it.
290    ///
291    /// A transient failure is not proof the operation did not take effect. A
292    /// write that timed out may have reached some replicas, so retries must be
293    /// safe to repeat.
294    #[must_use]
295    pub const fn is_transient(&self) -> bool {
296        matches!(
297            self,
298            Self::Timeout { .. } | Self::QuorumFailed { .. } | Self::NoPeers
299        )
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn not_found_error_display() {
309        let err = DhtError::not_found("agent://example.com/test/agent_123");
310        assert!(err.to_string().contains("not found"));
311        assert!(err.is_not_found());
312    }
313
314    #[test]
315    fn already_registered_error_display() {
316        let err = DhtError::already_registered("agent://example.com/test/agent_123");
317        assert!(err.to_string().contains("already registered"));
318    }
319
320    #[test]
321    fn expired_error_display() {
322        let err = DhtError::expired("agent://example.com/test/agent_123");
323        assert!(err.to_string().contains("expired"));
324        assert!(err.is_expired());
325    }
326
327    #[test]
328    fn no_endpoints_error_display() {
329        let err = DhtError::NoEndpoints;
330        assert!(err.to_string().contains("at least one endpoint"));
331    }
332
333    #[test]
334    fn key_capacity_exceeded_error_display() {
335        let err = DhtError::key_capacity_exceeded("abc123...", 20);
336        assert!(err.to_string().contains("maximum capacity"));
337        assert!(err.to_string().contains("20"));
338    }
339
340    #[test]
341    fn timeout_error_display_warns_the_write_may_have_landed() {
342        let err = DhtError::Timeout {
343            operation: "register",
344            after: Duration::from_secs(30),
345        };
346        let shown = err.to_string();
347        assert!(shown.contains("register"));
348        assert!(shown.contains("may still have taken effect"));
349    }
350
351    #[test]
352    fn quorum_failed_error_reports_both_counts() {
353        let err = DhtError::QuorumFailed {
354            achieved: 3,
355            required: 11,
356        };
357        let shown = err.to_string();
358        assert!(shown.contains('3'));
359        assert!(shown.contains("11"));
360    }
361
362    #[test]
363    fn no_peers_error_points_at_bootstrap() {
364        assert!(DhtError::NoPeers.to_string().contains("bootstrap"));
365    }
366
367    #[test]
368    fn network_failures_are_transient() {
369        assert!(
370            DhtError::Timeout {
371                operation: "lookup",
372                after: Duration::from_secs(1)
373            }
374            .is_transient()
375        );
376        assert!(
377            DhtError::QuorumFailed {
378                achieved: 1,
379                required: 2
380            }
381            .is_transient()
382        );
383        assert!(DhtError::NoPeers.is_transient());
384    }
385
386    #[test]
387    fn request_rejections_are_not_transient() {
388        // Retrying these is pure waste: the request itself is the problem.
389        assert!(!DhtError::NoEndpoints.is_transient());
390        assert!(!DhtError::not_found("agent://a.com/b/c_1").is_transient());
391        assert!(!DhtError::already_registered("agent://a.com/b/c_1").is_transient());
392        assert!(!DhtError::expired("agent://a.com/b/c_1").is_transient());
393        assert!(!DhtError::invalid_attestation("agent://a.com/b/c_1", "bad").is_transient());
394        assert!(!DhtError::key_capacity_exceeded("k", 1).is_transient());
395        // A node that refused a record refuses it again: the record is the
396        // problem, not the path to the node.
397        assert!(!DhtError::rejected("agent://a.com/b/c_1", "expiry too far ahead").is_transient());
398        // A node that has shut down does not come back; the caller needs a new
399        // one, not another attempt against this one.
400        assert!(!DhtError::unavailable("the node has shut down").is_transient());
401        assert!(
402            !DhtError::AgentKeyMismatch {
403                agent_uri: "agent://a.com/b/c_1".to_string()
404            }
405            .is_transient()
406        );
407        // Retrying an unauthorized write with the same proof is pure waste, and
408        // a stale sequence needs a fresh signature rather than a repeat.
409        assert!(
410            !DhtError::Unauthorized {
411                agent_uri: "agent://a.com/b/c_1".to_string(),
412                operation: MutationKind::Deregister,
413            }
414            .is_transient()
415        );
416        assert!(
417            !DhtError::StaleSequence {
418                agent_uri: "agent://a.com/b/c_1".to_string(),
419                presented: 1,
420                current: 4,
421            }
422            .is_transient()
423        );
424    }
425
426    #[test]
427    fn unauthorized_error_names_the_refused_operation() {
428        let err = DhtError::Unauthorized {
429            agent_uri: "agent://a.com/b/c_1".to_string(),
430            operation: MutationKind::UpdateEndpoint,
431        };
432        let shown = err.to_string();
433        assert!(shown.contains("update_endpoint"));
434        assert!(shown.contains("agent://a.com/b/c_1"));
435        assert!(shown.contains("agent key"));
436    }
437
438    #[test]
439    fn agent_key_mismatch_error_says_the_token_is_someone_elses() {
440        let err = DhtError::AgentKeyMismatch {
441            agent_uri: "agent://a.com/b/c_1".to_string(),
442        };
443        let shown = err.to_string();
444        assert!(shown.contains("agent://a.com/b/c_1"));
445        assert!(shown.contains("different key"));
446    }
447
448    #[test]
449    fn rejected_error_carries_the_nodes_reason() {
450        let err = DhtError::rejected("agent://a.com/b/c_1", "record expiry is in the past");
451        let shown = err.to_string();
452        assert!(shown.contains("agent://a.com/b/c_1"));
453        assert!(shown.contains("record expiry is in the past"));
454    }
455
456    #[test]
457    fn unavailable_error_names_the_local_node_not_the_overlay() {
458        let err = DhtError::unavailable("the node has shut down");
459        assert!(err.to_string().contains("this DHT node"));
460        assert!(err.to_string().contains("shut down"));
461    }
462
463    #[test]
464    fn stale_sequence_error_reports_both_sequences() {
465        let err = DhtError::StaleSequence {
466            agent_uri: "agent://a.com/b/c_1".to_string(),
467            presented: 3,
468            current: 9,
469        };
470        let shown = err.to_string();
471        assert!(shown.contains('3'));
472        assert!(shown.contains('9'));
473    }
474}