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 at the transport or RPC level.
50    ///
51    /// This indicates a potential server health issue (connection refused,
52    /// broken pipe, JSON-RPC protocol error). The server may be down or
53    /// malfunctioning.
54    #[error("upstream error from '{server}': {message}")]
55    Upstream {
56        /// The server that returned the error.
57        server: String,
58        /// The error message from the upstream server.
59        message: String,
60    },
61
62    /// A tool returned an application-level error (MCP `isError: true`).
63    ///
64    /// The downstream server is healthy — the tool processed the request but
65    /// returned an error (e.g., bad parameters, missing prerequisite state).
66    /// This does NOT indicate a server health issue.
67    #[error("tool error on '{server}' calling '{tool}': {message}")]
68    ToolError {
69        /// The server that hosted the tool.
70        server: String,
71        /// The tool that returned the error.
72        tool: String,
73        /// The error message from the tool.
74        message: String,
75    },
76
77    /// A rate limit was exceeded.
78    #[error("rate limit exceeded: {0}")]
79    RateLimit(String),
80
81    /// An internal error (catch-all for unexpected failures).
82    #[error(transparent)]
83    Internal(#[from] anyhow::Error),
84}
85
86impl DispatchError {
87    /// Returns a static error code string for programmatic matching.
88    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    /// Whether this error indicates the server is unhealthy and should count
103    /// toward the circuit breaker failure threshold.
104    ///
105    /// Returns `true` for errors suggesting the server is down or unresponsive
106    /// (`Timeout`, `Upstream` transport/RPC failures, `Internal`).
107    /// Returns `false` for errors where the server responded coherently but the
108    /// request was invalid (`ToolError`, `ToolNotFound`, etc).
109    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    /// Returns whether the operation that produced this error may succeed if retried.
124    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    /// Convert to a structured JSON error response for LLM consumption.
139    ///
140    /// Returns a JSON object with `error`, `code`, `message`, `retryable`,
141    /// and optionally `suggested_fix` (populated by fuzzy matching when
142    /// `known_tools` is provided for `ToolNotFound` errors).
143    ///
144    /// # Arguments
145    /// * `known_tools` - Optional list of `(server, tool)` pairs for fuzzy matching.
146    ///   Only used for `ToolNotFound` errors.
147    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
187/// Find the closest matching tool name using Levenshtein distance.
188///
189/// Returns a suggestion string if a tool within edit distance 3 is found.
190fn 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        // Try matching the full "server.tool" form
196        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        // Also try matching just the tool name on the same server
203        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
214/// Find the closest matching server name using Levenshtein distance.
215fn 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
232// Compile-time assertion: DispatchError must be Send + Sync + 'static
233const _: 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    // --- trips_circuit_breaker tests ---
391
392    #[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        // #[error(transparent)] means Display delegates to the inner error
449        assert_eq!(err.to_string(), "root cause");
450    }
451
452    // --- Structured error tests (Phase 5B) ---
453
454    #[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        // "fnd" is edit distance 1 from "find"
568        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}