1#![warn(missing_docs)]
2use thiserror::Error;
8
9#[derive(Debug, Error)]
14#[non_exhaustive]
15pub enum DispatchError {
16 #[error("server not found: {0}")]
18 ServerNotFound(String),
19
20 #[error("tool not found: '{tool}' on server '{server}'")]
22 ToolNotFound {
23 server: String,
25 tool: String,
27 },
28
29 #[error("timeout after {timeout_ms}ms on server '{server}'")]
31 Timeout {
32 server: String,
34 timeout_ms: u64,
36 },
37
38 #[error("circuit breaker open for server: {0}")]
40 CircuitOpen(String),
41
42 #[error("group policy denied: {reason}")]
44 GroupPolicyDenied {
45 reason: String,
47 },
48
49 #[error("upstream error from '{server}': {message}")]
55 Upstream {
56 server: String,
58 message: String,
60 },
61
62 #[error("transport dead for server '{server}': {reason}")]
65 TransportDead {
66 server: String,
68 reason: String,
70 },
71
72 #[error("tool error on '{server}' calling '{tool}': {message}")]
78 ToolError {
79 server: String,
81 tool: String,
83 message: String,
85 },
86
87 #[error("rate limit exceeded: {0}")]
89 RateLimit(String),
90
91 #[error(transparent)]
93 Internal(#[from] anyhow::Error),
94}
95
96impl DispatchError {
97 pub fn code(&self) -> &'static str {
99 match self {
100 Self::ServerNotFound(_) => "SERVER_NOT_FOUND",
101 Self::ToolNotFound { .. } => "TOOL_NOT_FOUND",
102 Self::Timeout { .. } => "TIMEOUT",
103 Self::CircuitOpen(_) => "CIRCUIT_OPEN",
104 Self::GroupPolicyDenied { .. } => "GROUP_POLICY_DENIED",
105 Self::Upstream { .. } => "UPSTREAM_ERROR",
106 Self::TransportDead { .. } => "TRANSPORT_DEAD",
107 Self::ToolError { .. } => "TOOL_ERROR",
108 Self::RateLimit(_) => "RATE_LIMIT",
109 Self::Internal(_) => "INTERNAL",
110 }
111 }
112
113 pub fn trips_circuit_breaker(&self) -> bool {
121 match self {
122 Self::Timeout { .. } => true,
123 Self::Upstream { .. } => true,
124 Self::TransportDead { .. } => true,
125 Self::Internal(_) => true,
126 Self::ToolError { .. } => false,
127 Self::ServerNotFound(_) => false,
128 Self::ToolNotFound { .. } => false,
129 Self::GroupPolicyDenied { .. } => false,
130 Self::RateLimit(_) => false,
131 Self::CircuitOpen(_) => false,
132 }
133 }
134
135 pub fn retryable(&self) -> bool {
137 match self {
138 Self::Timeout { .. } => true,
139 Self::CircuitOpen(_) => true,
140 Self::RateLimit(_) => true,
141 Self::Upstream { .. } => true,
142 Self::TransportDead { .. } => false,
143 Self::ToolError { .. } => false,
144 Self::ServerNotFound(_) => false,
145 Self::ToolNotFound { .. } => false,
146 Self::GroupPolicyDenied { .. } => false,
147 Self::Internal(_) => false,
148 }
149 }
150
151 pub fn to_structured_error(&self, known_tools: Option<&[(&str, &str)]>) -> serde_json::Value {
161 let suggested_fix = match self {
162 Self::ToolNotFound { server, tool } => {
163 if let Some(tools) = known_tools {
164 find_similar_tool(server, tool, tools)
165 } else {
166 None
167 }
168 }
169 Self::ServerNotFound(name) => {
170 if let Some(tools) = known_tools {
171 find_similar_server(name, tools)
172 } else {
173 None
174 }
175 }
176 Self::ToolError { .. } => {
177 Some("Check the tool's input_schema for correct parameter names".to_string())
178 }
179 Self::CircuitOpen(_) => Some("Retry after a delay".to_string()),
180 Self::Timeout { .. } => Some("Retry with a simpler operation".to_string()),
181 Self::RateLimit(_) => Some("Reduce request frequency".to_string()),
182 Self::TransportDead { .. } => Some(
183 "Server transport is dead. Gateway may auto-reconnect, or restart the gateway."
184 .to_string(),
185 ),
186 _ => None,
187 };
188
189 let mut obj = serde_json::json!({
190 "error": true,
191 "code": self.code(),
192 "message": self.to_string(),
193 "retryable": self.retryable(),
194 });
195
196 if let Some(fix) = suggested_fix {
197 obj["suggested_fix"] = serde_json::Value::String(fix);
198 }
199
200 obj
201 }
202}
203
204fn find_similar_tool(server: &str, tool: &str, known_tools: &[(&str, &str)]) -> Option<String> {
208 let full_name = format!("{server}.{tool}");
209 let mut best: Option<(usize, String)> = None;
210
211 for &(s, t) in known_tools {
212 let candidate_full = format!("{s}.{t}");
214 let dist = strsim::levenshtein(&full_name, &candidate_full);
215 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
216 best = Some((dist, format!("Did you mean '{t}' on server '{s}'?")));
217 }
218
219 if s == server {
221 let dist = strsim::levenshtein(tool, t);
222 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
223 best = Some((dist, format!("Did you mean '{t}'?")));
224 }
225 }
226 }
227
228 best.map(|(_, suggestion)| suggestion)
229}
230
231fn find_similar_server(name: &str, known_tools: &[(&str, &str)]) -> Option<String> {
233 let mut seen = std::collections::HashSet::new();
234 let mut best: Option<(usize, String)> = None;
235
236 for &(s, _) in known_tools {
237 if !seen.insert(s) {
238 continue;
239 }
240 let dist = strsim::levenshtein(name, s);
241 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
242 best = Some((dist, format!("Did you mean server '{s}'?")));
243 }
244 }
245
246 best.map(|(_, suggestion)| suggestion)
247}
248
249const _: fn() = || {
251 fn assert_bounds<T: Send + Sync + 'static>() {}
252 assert_bounds::<DispatchError>();
253};
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn display_server_not_found() {
261 let err = DispatchError::ServerNotFound("myserver".into());
262 assert_eq!(err.to_string(), "server not found: myserver");
263 }
264
265 #[test]
266 fn display_tool_not_found() {
267 let err = DispatchError::ToolNotFound {
268 server: "srv".into(),
269 tool: "hammer".into(),
270 };
271 assert_eq!(err.to_string(), "tool not found: 'hammer' on server 'srv'");
272 }
273
274 #[test]
275 fn display_timeout() {
276 let err = DispatchError::Timeout {
277 server: "slow".into(),
278 timeout_ms: 5000,
279 };
280 assert_eq!(err.to_string(), "timeout after 5000ms on server 'slow'");
281 }
282
283 #[test]
284 fn display_circuit_open() {
285 let err = DispatchError::CircuitOpen("broken".into());
286 assert_eq!(err.to_string(), "circuit breaker open for server: broken");
287 }
288
289 #[test]
290 fn display_group_policy_denied() {
291 let err = DispatchError::GroupPolicyDenied {
292 reason: "cross-server access denied".into(),
293 };
294 assert_eq!(
295 err.to_string(),
296 "group policy denied: cross-server access denied"
297 );
298 }
299
300 #[test]
301 fn display_upstream() {
302 let err = DispatchError::Upstream {
303 server: "remote".into(),
304 message: "connection refused".into(),
305 };
306 assert_eq!(
307 err.to_string(),
308 "upstream error from 'remote': connection refused"
309 );
310 }
311
312 #[test]
313 fn display_rate_limit() {
314 let err = DispatchError::RateLimit("too many tool calls".into());
315 assert_eq!(err.to_string(), "rate limit exceeded: too many tool calls");
316 }
317
318 #[test]
319 fn display_internal() {
320 let err = DispatchError::Internal(anyhow::anyhow!("something broke"));
321 assert_eq!(err.to_string(), "something broke");
322 }
323
324 #[test]
325 fn code_exhaustive() {
326 let cases: Vec<(DispatchError, &str)> = vec![
327 (
328 DispatchError::ServerNotFound("x".into()),
329 "SERVER_NOT_FOUND",
330 ),
331 (
332 DispatchError::ToolNotFound {
333 server: "s".into(),
334 tool: "t".into(),
335 },
336 "TOOL_NOT_FOUND",
337 ),
338 (
339 DispatchError::Timeout {
340 server: "s".into(),
341 timeout_ms: 1000,
342 },
343 "TIMEOUT",
344 ),
345 (DispatchError::CircuitOpen("x".into()), "CIRCUIT_OPEN"),
346 (
347 DispatchError::GroupPolicyDenied { reason: "r".into() },
348 "GROUP_POLICY_DENIED",
349 ),
350 (
351 DispatchError::Upstream {
352 server: "s".into(),
353 message: "m".into(),
354 },
355 "UPSTREAM_ERROR",
356 ),
357 (
358 DispatchError::TransportDead {
359 server: "s".into(),
360 reason: "pipe broken".into(),
361 },
362 "TRANSPORT_DEAD",
363 ),
364 (
365 DispatchError::ToolError {
366 server: "s".into(),
367 tool: "t".into(),
368 message: "m".into(),
369 },
370 "TOOL_ERROR",
371 ),
372 (DispatchError::RateLimit("x".into()), "RATE_LIMIT"),
373 (DispatchError::Internal(anyhow::anyhow!("x")), "INTERNAL"),
374 ];
375 for (err, expected_code) in &cases {
376 assert_eq!(err.code(), *expected_code, "wrong code for {err}");
377 }
378 }
379
380 #[test]
381 fn retryable_true_cases() {
382 assert!(DispatchError::Timeout {
383 server: "s".into(),
384 timeout_ms: 1000
385 }
386 .retryable());
387 assert!(DispatchError::CircuitOpen("s".into()).retryable());
388 assert!(DispatchError::RateLimit("x".into()).retryable());
389 assert!(DispatchError::Upstream {
390 server: "s".into(),
391 message: "m".into()
392 }
393 .retryable());
394 }
395
396 #[test]
397 fn retryable_false_cases() {
398 assert!(!DispatchError::ServerNotFound("x".into()).retryable());
399 assert!(!DispatchError::ToolNotFound {
400 server: "s".into(),
401 tool: "t".into()
402 }
403 .retryable());
404 assert!(!DispatchError::ToolError {
405 server: "s".into(),
406 tool: "t".into(),
407 message: "m".into()
408 }
409 .retryable());
410 assert!(!DispatchError::GroupPolicyDenied { reason: "r".into() }.retryable());
411 assert!(!DispatchError::Internal(anyhow::anyhow!("x")).retryable());
412 }
413
414 #[test]
417 fn trips_cb_true_for_server_faults() {
418 assert!(DispatchError::Timeout {
419 server: "s".into(),
420 timeout_ms: 5000
421 }
422 .trips_circuit_breaker());
423 assert!(DispatchError::Upstream {
424 server: "s".into(),
425 message: "connection refused".into()
426 }
427 .trips_circuit_breaker());
428 assert!(DispatchError::Internal(anyhow::anyhow!("unexpected")).trips_circuit_breaker());
429 }
430
431 #[test]
432 fn trips_cb_false_for_tool_error() {
433 assert!(!DispatchError::ToolError {
434 server: "arbiter".into(),
435 tool: "scan".into(),
436 message: "Invalid params: missing field 'base_url'".into()
437 }
438 .trips_circuit_breaker());
439 }
440
441 #[test]
442 fn trips_cb_false_for_client_errors() {
443 assert!(!DispatchError::ServerNotFound("x".into()).trips_circuit_breaker());
444 assert!(!DispatchError::ToolNotFound {
445 server: "s".into(),
446 tool: "t".into()
447 }
448 .trips_circuit_breaker());
449 assert!(!DispatchError::GroupPolicyDenied { reason: "r".into() }.trips_circuit_breaker());
450 assert!(!DispatchError::RateLimit("x".into()).trips_circuit_breaker());
451 assert!(!DispatchError::CircuitOpen("x".into()).trips_circuit_breaker());
452 }
453
454 #[test]
455 fn send_sync_static() {
456 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
457 assert_send_sync_static::<DispatchError>();
458 }
459
460 #[test]
461 fn from_anyhow_error() {
462 let anyhow_err = anyhow::anyhow!("test anyhow");
463 let dispatch_err: DispatchError = anyhow_err.into();
464 assert!(matches!(dispatch_err, DispatchError::Internal(_)));
465 assert_eq!(dispatch_err.code(), "INTERNAL");
466 }
467
468 #[test]
469 fn internal_is_display_transparent() {
470 let inner = anyhow::anyhow!("root cause");
471 let err = DispatchError::Internal(inner);
472 assert_eq!(err.to_string(), "root cause");
474 }
475
476 #[test]
479 fn structured_error_server_not_found() {
480 let err = DispatchError::ServerNotFound("narsil".into());
481 let json = err.to_structured_error(None);
482 assert_eq!(json["error"], true);
483 assert_eq!(json["code"], "SERVER_NOT_FOUND");
484 assert_eq!(json["retryable"], false);
485 assert!(json["message"].as_str().unwrap().contains("narsil"));
486 }
487
488 #[test]
489 fn structured_error_tool_not_found_with_suggestion() {
490 let err = DispatchError::ToolNotFound {
491 server: "narsil".into(),
492 tool: "fnd_symbols".into(),
493 };
494 let tools = vec![
495 ("narsil", "find_symbols"),
496 ("narsil", "parse"),
497 ("github", "list_repos"),
498 ];
499 let json = err.to_structured_error(Some(&tools));
500 assert_eq!(json["code"], "TOOL_NOT_FOUND");
501 let fix = json["suggested_fix"].as_str().unwrap();
502 assert!(
503 fix.contains("find_symbols"),
504 "expected suggestion, got: {fix}"
505 );
506 }
507
508 #[test]
509 fn structured_error_tool_not_found_no_match() {
510 let err = DispatchError::ToolNotFound {
511 server: "narsil".into(),
512 tool: "completely_different".into(),
513 };
514 let tools = vec![("narsil", "find_symbols"), ("narsil", "parse")];
515 let json = err.to_structured_error(Some(&tools));
516 assert!(json.get("suggested_fix").is_none());
517 }
518
519 #[test]
520 fn structured_error_server_not_found_with_suggestion() {
521 let err = DispatchError::ServerNotFound("narsill".into());
522 let tools = vec![("narsil", "find_symbols"), ("github", "list_repos")];
523 let json = err.to_structured_error(Some(&tools));
524 let fix = json["suggested_fix"].as_str().unwrap();
525 assert!(
526 fix.contains("narsil"),
527 "expected server suggestion, got: {fix}"
528 );
529 }
530
531 #[test]
532 fn structured_error_timeout_has_retry_suggestion() {
533 let err = DispatchError::Timeout {
534 server: "slow".into(),
535 timeout_ms: 5000,
536 };
537 let json = err.to_structured_error(None);
538 assert_eq!(json["retryable"], true);
539 assert!(json["suggested_fix"].as_str().is_some());
540 }
541
542 #[test]
543 fn structured_error_circuit_open_has_retry_suggestion() {
544 let err = DispatchError::CircuitOpen("broken".into());
545 let json = err.to_structured_error(None);
546 assert_eq!(json["retryable"], true);
547 assert!(json["suggested_fix"].as_str().unwrap().contains("Retry"));
548 }
549
550 #[test]
551 fn display_tool_error() {
552 let err = DispatchError::ToolError {
553 server: "arbiter".into(),
554 tool: "scan_target".into(),
555 message: "tool returned error: Invalid params: missing field 'base_url'".into(),
556 };
557 assert_eq!(
558 err.to_string(),
559 "tool error on 'arbiter' calling 'scan_target': tool returned error: Invalid params: missing field 'base_url'"
560 );
561 }
562
563 #[test]
564 fn structured_error_tool_error_has_schema_suggestion() {
565 let err = DispatchError::ToolError {
566 server: "arbiter".into(),
567 tool: "scan".into(),
568 message: "Invalid params: missing field 'base_url'".into(),
569 };
570 let json = err.to_structured_error(None);
571 assert_eq!(json["code"], "TOOL_ERROR");
572 assert_eq!(json["retryable"], false);
573 let fix = json["suggested_fix"].as_str().unwrap();
574 assert!(
575 fix.contains("input_schema"),
576 "expected schema hint, got: {fix}"
577 );
578 }
579
580 #[test]
581 fn structured_error_internal_no_suggestion() {
582 let err = DispatchError::Internal(anyhow::anyhow!("unexpected"));
583 let json = err.to_structured_error(None);
584 assert_eq!(json["code"], "INTERNAL");
585 assert_eq!(json["retryable"], false);
586 assert!(json.get("suggested_fix").is_none());
587 }
588
589 #[test]
590 fn fuzzy_match_close_tool_name() {
591 let result = super::find_similar_tool(
593 "narsil",
594 "fnd_symbols",
595 &[("narsil", "find_symbols"), ("narsil", "parse")],
596 );
597 assert!(result.is_some());
598 assert!(result.unwrap().contains("find_symbols"));
599 }
600
601 #[test]
602 fn fuzzy_match_no_match_beyond_threshold() {
603 let result = super::find_similar_tool(
604 "narsil",
605 "zzzzz",
606 &[("narsil", "find_symbols"), ("narsil", "parse")],
607 );
608 assert!(result.is_none());
609 }
610
611 #[test]
612 fn fuzzy_match_server_name() {
613 let result = super::find_similar_server(
614 "narsill",
615 &[("narsil", "find_symbols"), ("github", "list_repos")],
616 );
617 assert!(result.is_some());
618 assert!(result.unwrap().contains("narsil"));
619 }
620
621 #[test]
624 fn display_transport_dead() {
625 let err = DispatchError::TransportDead {
626 server: "arbiter".into(),
627 reason: "channel closed".into(),
628 };
629 assert_eq!(
630 err.to_string(),
631 "transport dead for server 'arbiter': channel closed"
632 );
633 }
634
635 #[test]
636 fn transport_dead_code() {
637 let err = DispatchError::TransportDead {
638 server: "s".into(),
639 reason: "r".into(),
640 };
641 assert_eq!(err.code(), "TRANSPORT_DEAD");
642 }
643
644 #[test]
645 fn transport_dead_trips_circuit_breaker() {
646 assert!(DispatchError::TransportDead {
647 server: "s".into(),
648 reason: "pipe broken".into(),
649 }
650 .trips_circuit_breaker());
651 }
652
653 #[test]
654 fn transport_dead_not_retryable() {
655 assert!(!DispatchError::TransportDead {
656 server: "s".into(),
657 reason: "pipe broken".into(),
658 }
659 .retryable());
660 }
661
662 #[test]
663 fn structured_error_transport_dead() {
664 let err = DispatchError::TransportDead {
665 server: "arbiter".into(),
666 reason: "channel closed".into(),
667 };
668 let json = err.to_structured_error(None);
669 assert_eq!(json["code"], "TRANSPORT_DEAD");
670 assert_eq!(json["retryable"], false);
671 let fix = json["suggested_fix"].as_str().unwrap();
672 assert!(
673 fix.contains("transport is dead"),
674 "expected transport dead suggestion, got: {fix}"
675 );
676 }
677}