1use std::fmt;
4use std::time::Duration;
5
6use crate::MutationKind;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum DhtError {
16 NotFound {
18 agent_uri: String,
20 },
21 AlreadyRegistered {
23 agent_uri: String,
25 },
26 Expired {
28 agent_uri: String,
30 },
31 InvalidAttestation {
33 agent_uri: String,
35 reason: String,
37 },
38 IdentityCapabilityConflict {
40 identity: String,
42 existing_path: String,
44 requested_path: String,
46 },
47 KeyCapacityExceeded {
53 key: String,
55 max: usize,
57 },
58 NoEndpoints,
60 Unauthorized {
63 agent_uri: String,
65 operation: MutationKind,
67 },
68 AgentKeyMismatch {
70 agent_uri: String,
72 },
73 Rejected {
81 agent_uri: String,
83 reason: String,
85 },
86 StaleSequence {
91 agent_uri: String,
93 presented: u64,
95 current: u64,
97 },
98 Timeout {
100 operation: &'static str,
102 after: Duration,
104 },
105 QuorumFailed {
107 achieved: usize,
109 required: usize,
111 },
112 NoPeers,
114 Unavailable {
121 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 #[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 #[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 #[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 #[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 #[must_use]
249 pub fn unavailable(reason: impl Into<String>) -> Self {
250 Self::Unavailable {
251 reason: reason.into(),
252 }
253 }
254
255 #[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 #[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 #[must_use]
275 pub const fn is_not_found(&self) -> bool {
276 matches!(self, Self::NotFound { .. })
277 }
278
279 #[must_use]
281 pub const fn is_expired(&self) -> bool {
282 matches!(self, Self::Expired { .. })
283 }
284
285 #[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 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 assert!(!DhtError::rejected("agent://a.com/b/c_1", "expiry too far ahead").is_transient());
398 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 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}