Skip to main content

forge_error/
lib.rs

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