1use thiserror::Error;
7
8#[derive(Debug, Error)]
13#[non_exhaustive]
14pub enum DispatchError {
15 #[error("server not found: {0}")]
17 ServerNotFound(String),
18
19 #[error("tool not found: '{tool}' on server '{server}'")]
21 ToolNotFound {
22 server: String,
24 tool: String,
26 },
27
28 #[error("timeout after {timeout_ms}ms on server '{server}'")]
30 Timeout {
31 server: String,
33 timeout_ms: u64,
35 },
36
37 #[error("circuit breaker open for server: {0}")]
39 CircuitOpen(String),
40
41 #[error("group policy denied: {reason}")]
43 GroupPolicyDenied {
44 reason: String,
46 },
47
48 #[error("upstream error from '{server}': {message}")]
50 Upstream {
51 server: String,
53 message: String,
55 },
56
57 #[error("rate limit exceeded: {0}")]
59 RateLimit(String),
60
61 #[error(transparent)]
63 Internal(#[from] anyhow::Error),
64}
65
66impl DispatchError {
67 pub fn code(&self) -> &'static str {
69 match self {
70 Self::ServerNotFound(_) => "SERVER_NOT_FOUND",
71 Self::ToolNotFound { .. } => "TOOL_NOT_FOUND",
72 Self::Timeout { .. } => "TIMEOUT",
73 Self::CircuitOpen(_) => "CIRCUIT_OPEN",
74 Self::GroupPolicyDenied { .. } => "GROUP_POLICY_DENIED",
75 Self::Upstream { .. } => "UPSTREAM_ERROR",
76 Self::RateLimit(_) => "RATE_LIMIT",
77 Self::Internal(_) => "INTERNAL",
78 }
79 }
80
81 pub fn retryable(&self) -> bool {
83 match self {
84 Self::Timeout { .. } => true,
85 Self::CircuitOpen(_) => true,
86 Self::RateLimit(_) => true,
87 Self::Upstream { .. } => true,
88 Self::ServerNotFound(_) => false,
89 Self::ToolNotFound { .. } => false,
90 Self::GroupPolicyDenied { .. } => false,
91 Self::Internal(_) => false,
92 }
93 }
94
95 pub fn to_structured_error(&self, known_tools: Option<&[(&str, &str)]>) -> serde_json::Value {
105 let suggested_fix = match self {
106 Self::ToolNotFound { server, tool } => {
107 if let Some(tools) = known_tools {
108 find_similar_tool(server, tool, tools)
109 } else {
110 None
111 }
112 }
113 Self::ServerNotFound(name) => {
114 if let Some(tools) = known_tools {
115 find_similar_server(name, tools)
116 } else {
117 None
118 }
119 }
120 Self::CircuitOpen(_) => Some("Retry after a delay".to_string()),
121 Self::Timeout { .. } => Some("Retry with a simpler operation".to_string()),
122 Self::RateLimit(_) => Some("Reduce request frequency".to_string()),
123 _ => None,
124 };
125
126 let mut obj = serde_json::json!({
127 "error": true,
128 "code": self.code(),
129 "message": self.to_string(),
130 "retryable": self.retryable(),
131 });
132
133 if let Some(fix) = suggested_fix {
134 obj["suggested_fix"] = serde_json::Value::String(fix);
135 }
136
137 obj
138 }
139}
140
141fn find_similar_tool(server: &str, tool: &str, known_tools: &[(&str, &str)]) -> Option<String> {
145 let full_name = format!("{server}.{tool}");
146 let mut best: Option<(usize, String)> = None;
147
148 for &(s, t) in known_tools {
149 let candidate_full = format!("{s}.{t}");
151 let dist = strsim::levenshtein(&full_name, &candidate_full);
152 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
153 best = Some((dist, format!("Did you mean '{t}' on server '{s}'?")));
154 }
155
156 if s == server {
158 let dist = strsim::levenshtein(tool, t);
159 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
160 best = Some((dist, format!("Did you mean '{t}'?")));
161 }
162 }
163 }
164
165 best.map(|(_, suggestion)| suggestion)
166}
167
168fn find_similar_server(name: &str, known_tools: &[(&str, &str)]) -> Option<String> {
170 let mut seen = std::collections::HashSet::new();
171 let mut best: Option<(usize, String)> = None;
172
173 for &(s, _) in known_tools {
174 if !seen.insert(s) {
175 continue;
176 }
177 let dist = strsim::levenshtein(name, s);
178 if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
179 best = Some((dist, format!("Did you mean server '{s}'?")));
180 }
181 }
182
183 best.map(|(_, suggestion)| suggestion)
184}
185
186const _: fn() = || {
188 fn assert_bounds<T: Send + Sync + 'static>() {}
189 assert_bounds::<DispatchError>();
190};
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn display_server_not_found() {
198 let err = DispatchError::ServerNotFound("myserver".into());
199 assert_eq!(err.to_string(), "server not found: myserver");
200 }
201
202 #[test]
203 fn display_tool_not_found() {
204 let err = DispatchError::ToolNotFound {
205 server: "srv".into(),
206 tool: "hammer".into(),
207 };
208 assert_eq!(err.to_string(), "tool not found: 'hammer' on server 'srv'");
209 }
210
211 #[test]
212 fn display_timeout() {
213 let err = DispatchError::Timeout {
214 server: "slow".into(),
215 timeout_ms: 5000,
216 };
217 assert_eq!(err.to_string(), "timeout after 5000ms on server 'slow'");
218 }
219
220 #[test]
221 fn display_circuit_open() {
222 let err = DispatchError::CircuitOpen("broken".into());
223 assert_eq!(err.to_string(), "circuit breaker open for server: broken");
224 }
225
226 #[test]
227 fn display_group_policy_denied() {
228 let err = DispatchError::GroupPolicyDenied {
229 reason: "cross-server access denied".into(),
230 };
231 assert_eq!(
232 err.to_string(),
233 "group policy denied: cross-server access denied"
234 );
235 }
236
237 #[test]
238 fn display_upstream() {
239 let err = DispatchError::Upstream {
240 server: "remote".into(),
241 message: "connection refused".into(),
242 };
243 assert_eq!(
244 err.to_string(),
245 "upstream error from 'remote': connection refused"
246 );
247 }
248
249 #[test]
250 fn display_rate_limit() {
251 let err = DispatchError::RateLimit("too many tool calls".into());
252 assert_eq!(err.to_string(), "rate limit exceeded: too many tool calls");
253 }
254
255 #[test]
256 fn display_internal() {
257 let err = DispatchError::Internal(anyhow::anyhow!("something broke"));
258 assert_eq!(err.to_string(), "something broke");
259 }
260
261 #[test]
262 fn code_exhaustive() {
263 let cases: Vec<(DispatchError, &str)> = vec![
264 (
265 DispatchError::ServerNotFound("x".into()),
266 "SERVER_NOT_FOUND",
267 ),
268 (
269 DispatchError::ToolNotFound {
270 server: "s".into(),
271 tool: "t".into(),
272 },
273 "TOOL_NOT_FOUND",
274 ),
275 (
276 DispatchError::Timeout {
277 server: "s".into(),
278 timeout_ms: 1000,
279 },
280 "TIMEOUT",
281 ),
282 (DispatchError::CircuitOpen("x".into()), "CIRCUIT_OPEN"),
283 (
284 DispatchError::GroupPolicyDenied { reason: "r".into() },
285 "GROUP_POLICY_DENIED",
286 ),
287 (
288 DispatchError::Upstream {
289 server: "s".into(),
290 message: "m".into(),
291 },
292 "UPSTREAM_ERROR",
293 ),
294 (DispatchError::RateLimit("x".into()), "RATE_LIMIT"),
295 (DispatchError::Internal(anyhow::anyhow!("x")), "INTERNAL"),
296 ];
297 for (err, expected_code) in &cases {
298 assert_eq!(err.code(), *expected_code, "wrong code for {err}");
299 }
300 }
301
302 #[test]
303 fn retryable_true_cases() {
304 assert!(DispatchError::Timeout {
305 server: "s".into(),
306 timeout_ms: 1000
307 }
308 .retryable());
309 assert!(DispatchError::CircuitOpen("s".into()).retryable());
310 assert!(DispatchError::RateLimit("x".into()).retryable());
311 assert!(DispatchError::Upstream {
312 server: "s".into(),
313 message: "m".into()
314 }
315 .retryable());
316 }
317
318 #[test]
319 fn retryable_false_cases() {
320 assert!(!DispatchError::ServerNotFound("x".into()).retryable());
321 assert!(!DispatchError::ToolNotFound {
322 server: "s".into(),
323 tool: "t".into()
324 }
325 .retryable());
326 assert!(!DispatchError::GroupPolicyDenied { reason: "r".into() }.retryable());
327 assert!(!DispatchError::Internal(anyhow::anyhow!("x")).retryable());
328 }
329
330 #[test]
331 fn send_sync_static() {
332 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
333 assert_send_sync_static::<DispatchError>();
334 }
335
336 #[test]
337 fn from_anyhow_error() {
338 let anyhow_err = anyhow::anyhow!("test anyhow");
339 let dispatch_err: DispatchError = anyhow_err.into();
340 assert!(matches!(dispatch_err, DispatchError::Internal(_)));
341 assert_eq!(dispatch_err.code(), "INTERNAL");
342 }
343
344 #[test]
345 fn internal_is_display_transparent() {
346 let inner = anyhow::anyhow!("root cause");
347 let err = DispatchError::Internal(inner);
348 assert_eq!(err.to_string(), "root cause");
350 }
351
352 #[test]
355 fn structured_error_server_not_found() {
356 let err = DispatchError::ServerNotFound("narsil".into());
357 let json = err.to_structured_error(None);
358 assert_eq!(json["error"], true);
359 assert_eq!(json["code"], "SERVER_NOT_FOUND");
360 assert_eq!(json["retryable"], false);
361 assert!(json["message"].as_str().unwrap().contains("narsil"));
362 }
363
364 #[test]
365 fn structured_error_tool_not_found_with_suggestion() {
366 let err = DispatchError::ToolNotFound {
367 server: "narsil".into(),
368 tool: "fnd_symbols".into(),
369 };
370 let tools = vec![
371 ("narsil", "find_symbols"),
372 ("narsil", "parse"),
373 ("github", "list_repos"),
374 ];
375 let json = err.to_structured_error(Some(&tools));
376 assert_eq!(json["code"], "TOOL_NOT_FOUND");
377 let fix = json["suggested_fix"].as_str().unwrap();
378 assert!(
379 fix.contains("find_symbols"),
380 "expected suggestion, got: {fix}"
381 );
382 }
383
384 #[test]
385 fn structured_error_tool_not_found_no_match() {
386 let err = DispatchError::ToolNotFound {
387 server: "narsil".into(),
388 tool: "completely_different".into(),
389 };
390 let tools = vec![("narsil", "find_symbols"), ("narsil", "parse")];
391 let json = err.to_structured_error(Some(&tools));
392 assert!(json.get("suggested_fix").is_none());
393 }
394
395 #[test]
396 fn structured_error_server_not_found_with_suggestion() {
397 let err = DispatchError::ServerNotFound("narsill".into());
398 let tools = vec![("narsil", "find_symbols"), ("github", "list_repos")];
399 let json = err.to_structured_error(Some(&tools));
400 let fix = json["suggested_fix"].as_str().unwrap();
401 assert!(
402 fix.contains("narsil"),
403 "expected server suggestion, got: {fix}"
404 );
405 }
406
407 #[test]
408 fn structured_error_timeout_has_retry_suggestion() {
409 let err = DispatchError::Timeout {
410 server: "slow".into(),
411 timeout_ms: 5000,
412 };
413 let json = err.to_structured_error(None);
414 assert_eq!(json["retryable"], true);
415 assert!(json["suggested_fix"].as_str().is_some());
416 }
417
418 #[test]
419 fn structured_error_circuit_open_has_retry_suggestion() {
420 let err = DispatchError::CircuitOpen("broken".into());
421 let json = err.to_structured_error(None);
422 assert_eq!(json["retryable"], true);
423 assert!(json["suggested_fix"].as_str().unwrap().contains("Retry"));
424 }
425
426 #[test]
427 fn structured_error_internal_no_suggestion() {
428 let err = DispatchError::Internal(anyhow::anyhow!("unexpected"));
429 let json = err.to_structured_error(None);
430 assert_eq!(json["code"], "INTERNAL");
431 assert_eq!(json["retryable"], false);
432 assert!(json.get("suggested_fix").is_none());
433 }
434
435 #[test]
436 fn fuzzy_match_close_tool_name() {
437 let result = super::find_similar_tool(
439 "narsil",
440 "fnd_symbols",
441 &[("narsil", "find_symbols"), ("narsil", "parse")],
442 );
443 assert!(result.is_some());
444 assert!(result.unwrap().contains("find_symbols"));
445 }
446
447 #[test]
448 fn fuzzy_match_no_match_beyond_threshold() {
449 let result = super::find_similar_tool(
450 "narsil",
451 "zzzzz",
452 &[("narsil", "find_symbols"), ("narsil", "parse")],
453 );
454 assert!(result.is_none());
455 }
456
457 #[test]
458 fn fuzzy_match_server_name() {
459 let result = super::find_similar_server(
460 "narsill",
461 &[("narsil", "find_symbols"), ("github", "list_repos")],
462 );
463 assert!(result.is_some());
464 assert!(result.unwrap().contains("narsil"));
465 }
466}