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