Skip to main content

forge_error/
lib.rs

1#![warn(missing_docs)]
2//! Typed error types for Forge gateway dispatcher traits.
3//!
4//! Provides [`DispatchError`] — the canonical error type for all dispatcher
5//! trait methods (`ToolDispatcher`, `ResourceDispatcher`, `StashDispatcher`).
6
7use thiserror::Error;
8
9/// Canonical error type for Forge dispatcher operations.
10///
11/// All variants are `#[non_exhaustive]` to allow future additions without
12/// breaking downstream code.
13#[derive(Debug, Error)]
14#[non_exhaustive]
15pub enum DispatchError {
16    /// The requested server does not exist in the router.
17    #[error("server not found: {0}")]
18    ServerNotFound(String),
19
20    /// The requested tool does not exist on the specified server.
21    #[error("tool not found: '{tool}' on server '{server}'")]
22    ToolNotFound {
23        /// The server that was queried.
24        server: String,
25        /// The tool name that was not found.
26        tool: String,
27    },
28
29    /// The operation timed out.
30    #[error("timeout after {timeout_ms}ms on server '{server}'")]
31    Timeout {
32        /// The server that timed out.
33        server: String,
34        /// The timeout duration in milliseconds.
35        timeout_ms: u64,
36    },
37
38    /// The circuit breaker for this server is open.
39    #[error("circuit breaker open for server: {0}")]
40    CircuitOpen(String),
41
42    /// A group isolation policy denied the operation.
43    #[error("group policy denied: {reason}")]
44    GroupPolicyDenied {
45        /// Explanation of why the policy denied access.
46        reason: String,
47    },
48
49    /// An upstream MCP server returned an error.
50    #[error("upstream error from '{server}': {message}")]
51    Upstream {
52        /// The server that returned the error.
53        server: String,
54        /// The error message from the upstream server.
55        message: String,
56    },
57
58    /// A rate limit was exceeded.
59    #[error("rate limit exceeded: {0}")]
60    RateLimit(String),
61
62    /// An internal error (catch-all for unexpected failures).
63    #[error(transparent)]
64    Internal(#[from] anyhow::Error),
65}
66
67impl DispatchError {
68    /// Returns a static error code string for programmatic matching.
69    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    /// Returns whether the operation that produced this error may succeed if retried.
83    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    /// Convert to a structured JSON error response for LLM consumption.
97    ///
98    /// Returns a JSON object with `error`, `code`, `message`, `retryable`,
99    /// and optionally `suggested_fix` (populated by fuzzy matching when
100    /// `known_tools` is provided for `ToolNotFound` errors).
101    ///
102    /// # Arguments
103    /// * `known_tools` - Optional list of `(server, tool)` pairs for fuzzy matching.
104    ///   Only used for `ToolNotFound` errors.
105    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
142/// Find the closest matching tool name using Levenshtein distance.
143///
144/// Returns a suggestion string if a tool within edit distance 3 is found.
145fn 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        // Try matching the full "server.tool" form
151        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        // Also try matching just the tool name on the same server
158        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
169/// Find the closest matching server name using Levenshtein distance.
170fn 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
187// Compile-time assertion: DispatchError must be Send + Sync + 'static
188const _: 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        // #[error(transparent)] means Display delegates to the inner error
350        assert_eq!(err.to_string(), "root cause");
351    }
352
353    // --- Structured error tests (Phase 5B) ---
354
355    #[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        // "fnd" is edit distance 1 from "find"
439        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}