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("tool error on '{server}' calling '{tool}': {message}")]
68 ToolError {
69 server: String,
71 tool: String,
73 message: String,
75 },
76
77 #[error("rate limit exceeded: {0}")]
79 RateLimit(String),
80
81 #[error(transparent)]
83 Internal(#[from] anyhow::Error),
84}
85
86impl DispatchError {
87 pub fn code(&self) -> &'static str {
89 match self {
90 Self::ServerNotFound(_) => "SERVER_NOT_FOUND",
91 Self::ToolNotFound { .. } => "TOOL_NOT_FOUND",
92 Self::Timeout { .. } => "TIMEOUT",
93 Self::CircuitOpen(_) => "CIRCUIT_OPEN",
94 Self::GroupPolicyDenied { .. } => "GROUP_POLICY_DENIED",
95 Self::Upstream { .. } => "UPSTREAM_ERROR",
96 Self::ToolError { .. } => "TOOL_ERROR",
97 Self::RateLimit(_) => "RATE_LIMIT",
98 Self::Internal(_) => "INTERNAL",
99 }
100 }
101
102 pub fn trips_circuit_breaker(&self) -> bool {
110 match self {
111 Self::Timeout { .. } => true,
112 Self::Upstream { .. } => true,
113 Self::Internal(_) => true,
114 Self::ToolError { .. } => false,
115 Self::ServerNotFound(_) => false,
116 Self::ToolNotFound { .. } => false,
117 Self::GroupPolicyDenied { .. } => false,
118 Self::RateLimit(_) => false,
119 Self::CircuitOpen(_) => false,
120 }
121 }
122
123 pub fn retryable(&self) -> bool {
125 match self {
126 Self::Timeout { .. } => true,
127 Self::CircuitOpen(_) => true,
128 Self::RateLimit(_) => true,
129 Self::Upstream { .. } => true,
130 Self::ToolError { .. } => false,
131 Self::ServerNotFound(_) => false,
132 Self::ToolNotFound { .. } => false,
133 Self::GroupPolicyDenied { .. } => false,
134 Self::Internal(_) => false,
135 }
136 }
137
138 pub fn to_structured_error(&self, known_tools: Option<&[(&str, &str)]>) -> serde_json::Value {
148 let suggested_fix = match self {
149 Self::ToolNotFound { server, tool } => {
150 if let Some(tools) = known_tools {
151 find_similar_tool(server, tool, tools)
152 } else {
153 None
154 }
155 }
156 Self::ServerNotFound(name) => {
157 if let Some(tools) = known_tools {
158 find_similar_server(name, tools)
159 } else {
160 None
161 }
162 }
163 Self::ToolError { .. } => {
164 Some("Check the tool's input_schema for correct parameter names".to_string())
165 }
166 Self::CircuitOpen(_) => Some("Retry after a delay".to_string()),
167 Self::Timeout { .. } => Some("Retry with a simpler operation".to_string()),
168 Self::RateLimit(_) => Some("Reduce request frequency".to_string()),
169 _ => None,
170 };
171
172 let mut obj = serde_json::json!({
173 "error": true,
174 "code": self.code(),
175 "message": self.to_string(),
176 "retryable": self.retryable(),
177 });
178
179 if let Some(fix) = suggested_fix {
180 obj["suggested_fix"] = serde_json::Value::String(fix);
181 }
182
183 obj
184 }
185}
186
187fn find_similar_tool(server: &str, tool: &str, known_tools: &[(&str, &str)]) -> Option<String> {
191 let full_name = format!("{server}.{tool}");
192 let mut best: Option<(usize, String)> = None;
193
194 for &(s, t) in known_tools {
195 let candidate_full = format!("{s}.{t}");
197 let dist = strsim::levenshtein(&full_name, &candidate_full);
198 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
199 best = Some((dist, format!("Did you mean '{t}' on server '{s}'?")));
200 }
201
202 if s == server {
204 let dist = strsim::levenshtein(tool, t);
205 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
206 best = Some((dist, format!("Did you mean '{t}'?")));
207 }
208 }
209 }
210
211 best.map(|(_, suggestion)| suggestion)
212}
213
214fn find_similar_server(name: &str, known_tools: &[(&str, &str)]) -> Option<String> {
216 let mut seen = std::collections::HashSet::new();
217 let mut best: Option<(usize, String)> = None;
218
219 for &(s, _) in known_tools {
220 if !seen.insert(s) {
221 continue;
222 }
223 let dist = strsim::levenshtein(name, s);
224 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
225 best = Some((dist, format!("Did you mean server '{s}'?")));
226 }
227 }
228
229 best.map(|(_, suggestion)| suggestion)
230}
231
232const _: fn() = || {
234 fn assert_bounds<T: Send + Sync + 'static>() {}
235 assert_bounds::<DispatchError>();
236};
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn display_server_not_found() {
244 let err = DispatchError::ServerNotFound("myserver".into());
245 assert_eq!(err.to_string(), "server not found: myserver");
246 }
247
248 #[test]
249 fn display_tool_not_found() {
250 let err = DispatchError::ToolNotFound {
251 server: "srv".into(),
252 tool: "hammer".into(),
253 };
254 assert_eq!(err.to_string(), "tool not found: 'hammer' on server 'srv'");
255 }
256
257 #[test]
258 fn display_timeout() {
259 let err = DispatchError::Timeout {
260 server: "slow".into(),
261 timeout_ms: 5000,
262 };
263 assert_eq!(err.to_string(), "timeout after 5000ms on server 'slow'");
264 }
265
266 #[test]
267 fn display_circuit_open() {
268 let err = DispatchError::CircuitOpen("broken".into());
269 assert_eq!(err.to_string(), "circuit breaker open for server: broken");
270 }
271
272 #[test]
273 fn display_group_policy_denied() {
274 let err = DispatchError::GroupPolicyDenied {
275 reason: "cross-server access denied".into(),
276 };
277 assert_eq!(
278 err.to_string(),
279 "group policy denied: cross-server access denied"
280 );
281 }
282
283 #[test]
284 fn display_upstream() {
285 let err = DispatchError::Upstream {
286 server: "remote".into(),
287 message: "connection refused".into(),
288 };
289 assert_eq!(
290 err.to_string(),
291 "upstream error from 'remote': connection refused"
292 );
293 }
294
295 #[test]
296 fn display_rate_limit() {
297 let err = DispatchError::RateLimit("too many tool calls".into());
298 assert_eq!(err.to_string(), "rate limit exceeded: too many tool calls");
299 }
300
301 #[test]
302 fn display_internal() {
303 let err = DispatchError::Internal(anyhow::anyhow!("something broke"));
304 assert_eq!(err.to_string(), "something broke");
305 }
306
307 #[test]
308 fn code_exhaustive() {
309 let cases: Vec<(DispatchError, &str)> = vec![
310 (
311 DispatchError::ServerNotFound("x".into()),
312 "SERVER_NOT_FOUND",
313 ),
314 (
315 DispatchError::ToolNotFound {
316 server: "s".into(),
317 tool: "t".into(),
318 },
319 "TOOL_NOT_FOUND",
320 ),
321 (
322 DispatchError::Timeout {
323 server: "s".into(),
324 timeout_ms: 1000,
325 },
326 "TIMEOUT",
327 ),
328 (DispatchError::CircuitOpen("x".into()), "CIRCUIT_OPEN"),
329 (
330 DispatchError::GroupPolicyDenied { reason: "r".into() },
331 "GROUP_POLICY_DENIED",
332 ),
333 (
334 DispatchError::Upstream {
335 server: "s".into(),
336 message: "m".into(),
337 },
338 "UPSTREAM_ERROR",
339 ),
340 (
341 DispatchError::ToolError {
342 server: "s".into(),
343 tool: "t".into(),
344 message: "m".into(),
345 },
346 "TOOL_ERROR",
347 ),
348 (DispatchError::RateLimit("x".into()), "RATE_LIMIT"),
349 (DispatchError::Internal(anyhow::anyhow!("x")), "INTERNAL"),
350 ];
351 for (err, expected_code) in &cases {
352 assert_eq!(err.code(), *expected_code, "wrong code for {err}");
353 }
354 }
355
356 #[test]
357 fn retryable_true_cases() {
358 assert!(DispatchError::Timeout {
359 server: "s".into(),
360 timeout_ms: 1000
361 }
362 .retryable());
363 assert!(DispatchError::CircuitOpen("s".into()).retryable());
364 assert!(DispatchError::RateLimit("x".into()).retryable());
365 assert!(DispatchError::Upstream {
366 server: "s".into(),
367 message: "m".into()
368 }
369 .retryable());
370 }
371
372 #[test]
373 fn retryable_false_cases() {
374 assert!(!DispatchError::ServerNotFound("x".into()).retryable());
375 assert!(!DispatchError::ToolNotFound {
376 server: "s".into(),
377 tool: "t".into()
378 }
379 .retryable());
380 assert!(!DispatchError::ToolError {
381 server: "s".into(),
382 tool: "t".into(),
383 message: "m".into()
384 }
385 .retryable());
386 assert!(!DispatchError::GroupPolicyDenied { reason: "r".into() }.retryable());
387 assert!(!DispatchError::Internal(anyhow::anyhow!("x")).retryable());
388 }
389
390 #[test]
393 fn trips_cb_true_for_server_faults() {
394 assert!(DispatchError::Timeout {
395 server: "s".into(),
396 timeout_ms: 5000
397 }
398 .trips_circuit_breaker());
399 assert!(DispatchError::Upstream {
400 server: "s".into(),
401 message: "connection refused".into()
402 }
403 .trips_circuit_breaker());
404 assert!(DispatchError::Internal(anyhow::anyhow!("unexpected")).trips_circuit_breaker());
405 }
406
407 #[test]
408 fn trips_cb_false_for_tool_error() {
409 assert!(!DispatchError::ToolError {
410 server: "arbiter".into(),
411 tool: "scan".into(),
412 message: "Invalid params: missing field 'base_url'".into()
413 }
414 .trips_circuit_breaker());
415 }
416
417 #[test]
418 fn trips_cb_false_for_client_errors() {
419 assert!(!DispatchError::ServerNotFound("x".into()).trips_circuit_breaker());
420 assert!(!DispatchError::ToolNotFound {
421 server: "s".into(),
422 tool: "t".into()
423 }
424 .trips_circuit_breaker());
425 assert!(!DispatchError::GroupPolicyDenied { reason: "r".into() }.trips_circuit_breaker());
426 assert!(!DispatchError::RateLimit("x".into()).trips_circuit_breaker());
427 assert!(!DispatchError::CircuitOpen("x".into()).trips_circuit_breaker());
428 }
429
430 #[test]
431 fn send_sync_static() {
432 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
433 assert_send_sync_static::<DispatchError>();
434 }
435
436 #[test]
437 fn from_anyhow_error() {
438 let anyhow_err = anyhow::anyhow!("test anyhow");
439 let dispatch_err: DispatchError = anyhow_err.into();
440 assert!(matches!(dispatch_err, DispatchError::Internal(_)));
441 assert_eq!(dispatch_err.code(), "INTERNAL");
442 }
443
444 #[test]
445 fn internal_is_display_transparent() {
446 let inner = anyhow::anyhow!("root cause");
447 let err = DispatchError::Internal(inner);
448 assert_eq!(err.to_string(), "root cause");
450 }
451
452 #[test]
455 fn structured_error_server_not_found() {
456 let err = DispatchError::ServerNotFound("narsil".into());
457 let json = err.to_structured_error(None);
458 assert_eq!(json["error"], true);
459 assert_eq!(json["code"], "SERVER_NOT_FOUND");
460 assert_eq!(json["retryable"], false);
461 assert!(json["message"].as_str().unwrap().contains("narsil"));
462 }
463
464 #[test]
465 fn structured_error_tool_not_found_with_suggestion() {
466 let err = DispatchError::ToolNotFound {
467 server: "narsil".into(),
468 tool: "fnd_symbols".into(),
469 };
470 let tools = vec![
471 ("narsil", "find_symbols"),
472 ("narsil", "parse"),
473 ("github", "list_repos"),
474 ];
475 let json = err.to_structured_error(Some(&tools));
476 assert_eq!(json["code"], "TOOL_NOT_FOUND");
477 let fix = json["suggested_fix"].as_str().unwrap();
478 assert!(
479 fix.contains("find_symbols"),
480 "expected suggestion, got: {fix}"
481 );
482 }
483
484 #[test]
485 fn structured_error_tool_not_found_no_match() {
486 let err = DispatchError::ToolNotFound {
487 server: "narsil".into(),
488 tool: "completely_different".into(),
489 };
490 let tools = vec![("narsil", "find_symbols"), ("narsil", "parse")];
491 let json = err.to_structured_error(Some(&tools));
492 assert!(json.get("suggested_fix").is_none());
493 }
494
495 #[test]
496 fn structured_error_server_not_found_with_suggestion() {
497 let err = DispatchError::ServerNotFound("narsill".into());
498 let tools = vec![("narsil", "find_symbols"), ("github", "list_repos")];
499 let json = err.to_structured_error(Some(&tools));
500 let fix = json["suggested_fix"].as_str().unwrap();
501 assert!(
502 fix.contains("narsil"),
503 "expected server suggestion, got: {fix}"
504 );
505 }
506
507 #[test]
508 fn structured_error_timeout_has_retry_suggestion() {
509 let err = DispatchError::Timeout {
510 server: "slow".into(),
511 timeout_ms: 5000,
512 };
513 let json = err.to_structured_error(None);
514 assert_eq!(json["retryable"], true);
515 assert!(json["suggested_fix"].as_str().is_some());
516 }
517
518 #[test]
519 fn structured_error_circuit_open_has_retry_suggestion() {
520 let err = DispatchError::CircuitOpen("broken".into());
521 let json = err.to_structured_error(None);
522 assert_eq!(json["retryable"], true);
523 assert!(json["suggested_fix"].as_str().unwrap().contains("Retry"));
524 }
525
526 #[test]
527 fn display_tool_error() {
528 let err = DispatchError::ToolError {
529 server: "arbiter".into(),
530 tool: "scan_target".into(),
531 message: "tool returned error: Invalid params: missing field 'base_url'".into(),
532 };
533 assert_eq!(
534 err.to_string(),
535 "tool error on 'arbiter' calling 'scan_target': tool returned error: Invalid params: missing field 'base_url'"
536 );
537 }
538
539 #[test]
540 fn structured_error_tool_error_has_schema_suggestion() {
541 let err = DispatchError::ToolError {
542 server: "arbiter".into(),
543 tool: "scan".into(),
544 message: "Invalid params: missing field 'base_url'".into(),
545 };
546 let json = err.to_structured_error(None);
547 assert_eq!(json["code"], "TOOL_ERROR");
548 assert_eq!(json["retryable"], false);
549 let fix = json["suggested_fix"].as_str().unwrap();
550 assert!(
551 fix.contains("input_schema"),
552 "expected schema hint, got: {fix}"
553 );
554 }
555
556 #[test]
557 fn structured_error_internal_no_suggestion() {
558 let err = DispatchError::Internal(anyhow::anyhow!("unexpected"));
559 let json = err.to_structured_error(None);
560 assert_eq!(json["code"], "INTERNAL");
561 assert_eq!(json["retryable"], false);
562 assert!(json.get("suggested_fix").is_none());
563 }
564
565 #[test]
566 fn fuzzy_match_close_tool_name() {
567 let result = super::find_similar_tool(
569 "narsil",
570 "fnd_symbols",
571 &[("narsil", "find_symbols"), ("narsil", "parse")],
572 );
573 assert!(result.is_some());
574 assert!(result.unwrap().contains("find_symbols"));
575 }
576
577 #[test]
578 fn fuzzy_match_no_match_beyond_threshold() {
579 let result = super::find_similar_tool(
580 "narsil",
581 "zzzzz",
582 &[("narsil", "find_symbols"), ("narsil", "parse")],
583 );
584 assert!(result.is_none());
585 }
586
587 #[test]
588 fn fuzzy_match_server_name() {
589 let result = super::find_similar_server(
590 "narsill",
591 &[("narsil", "find_symbols"), ("github", "list_repos")],
592 );
593 assert!(result.is_some());
594 assert!(result.unwrap().contains("narsil"));
595 }
596}