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    /// The transport to the server has died permanently (pipe broken, channel closed).
63    /// Unlike Upstream (transient), this requires reconnection before further calls succeed.
64    #[error("transport dead for server '{server}': {reason}")]
65    TransportDead {
66        /// The server whose transport died.
67        server: String,
68        /// Description of the transport failure.
69        reason: String,
70    },
71
72    /// A tool returned an application-level error (MCP `isError: true`).
73    ///
74    /// The downstream server is healthy — the tool processed the request but
75    /// returned an error (e.g., bad parameters, missing prerequisite state).
76    /// This does NOT indicate a server health issue.
77    #[error("tool error on '{server}' calling '{tool}': {message}")]
78    ToolError {
79        /// The server that hosted the tool.
80        server: String,
81        /// The tool that returned the error.
82        tool: String,
83        /// The error message from the tool.
84        message: String,
85    },
86
87    /// A rate limit was exceeded.
88    #[error("rate limit exceeded: {0}")]
89    RateLimit(String),
90
91    /// An internal error (catch-all for unexpected failures).
92    #[error(transparent)]
93    Internal(#[from] anyhow::Error),
94}
95
96impl DispatchError {
97    /// Returns a static error code string for programmatic matching.
98    pub fn code(&self) -> &'static str {
99        match self {
100            Self::ServerNotFound(_) => "SERVER_NOT_FOUND",
101            Self::ToolNotFound { .. } => "TOOL_NOT_FOUND",
102            Self::Timeout { .. } => "TIMEOUT",
103            Self::CircuitOpen(_) => "CIRCUIT_OPEN",
104            Self::GroupPolicyDenied { .. } => "GROUP_POLICY_DENIED",
105            Self::Upstream { .. } => "UPSTREAM_ERROR",
106            Self::TransportDead { .. } => "TRANSPORT_DEAD",
107            Self::ToolError { .. } => "TOOL_ERROR",
108            Self::RateLimit(_) => "RATE_LIMIT",
109            Self::Internal(_) => "INTERNAL",
110        }
111    }
112
113    /// Whether this error indicates the server is unhealthy and should count
114    /// toward the circuit breaker failure threshold.
115    ///
116    /// Returns `true` for errors suggesting the server is down or unresponsive
117    /// (`Timeout`, `Upstream` transport/RPC failures, `Internal`).
118    /// Returns `false` for errors where the server responded coherently but the
119    /// request was invalid (`ToolError`, `ToolNotFound`, etc).
120    pub fn trips_circuit_breaker(&self) -> bool {
121        match self {
122            Self::Timeout { .. } => true,
123            Self::Upstream { .. } => true,
124            Self::TransportDead { .. } => true,
125            Self::Internal(_) => true,
126            Self::ToolError { .. } => false,
127            Self::ServerNotFound(_) => false,
128            Self::ToolNotFound { .. } => false,
129            Self::GroupPolicyDenied { .. } => false,
130            Self::RateLimit(_) => false,
131            Self::CircuitOpen(_) => false,
132        }
133    }
134
135    /// Returns whether the operation that produced this error may succeed if retried.
136    pub fn retryable(&self) -> bool {
137        match self {
138            Self::Timeout { .. } => true,
139            Self::CircuitOpen(_) => true,
140            Self::RateLimit(_) => true,
141            Self::Upstream { .. } => true,
142            Self::TransportDead { .. } => false,
143            Self::ToolError { .. } => false,
144            Self::ServerNotFound(_) => false,
145            Self::ToolNotFound { .. } => false,
146            Self::GroupPolicyDenied { .. } => false,
147            Self::Internal(_) => false,
148        }
149    }
150
151    /// Convert to a structured JSON error response for LLM consumption.
152    ///
153    /// Returns a JSON object with `error`, `code`, `message`, `retryable`,
154    /// and optionally `suggested_fix` (populated by fuzzy matching when
155    /// `known_tools` is provided for `ToolNotFound` errors).
156    ///
157    /// # Arguments
158    /// * `known_tools` - Optional list of `(server, tool)` pairs for fuzzy matching.
159    ///   Only used for `ToolNotFound` errors.
160    pub fn to_structured_error(&self, known_tools: Option<&[(&str, &str)]>) -> serde_json::Value {
161        let suggested_fix = match self {
162            Self::ToolNotFound { server, tool } => {
163                if let Some(tools) = known_tools {
164                    find_similar_tool(server, tool, tools)
165                } else {
166                    None
167                }
168            }
169            Self::ServerNotFound(name) => {
170                if let Some(tools) = known_tools {
171                    find_similar_server(name, tools)
172                } else {
173                    None
174                }
175            }
176            Self::ToolError { .. } => {
177                Some("Check the tool's input_schema for correct parameter names".to_string())
178            }
179            Self::CircuitOpen(_) => Some("Retry after a delay".to_string()),
180            Self::Timeout { .. } => Some("Retry with a simpler operation".to_string()),
181            Self::RateLimit(_) => Some("Reduce request frequency".to_string()),
182            Self::TransportDead { .. } => Some(
183                "Server transport is dead. Gateway may auto-reconnect, or restart the gateway."
184                    .to_string(),
185            ),
186            _ => None,
187        };
188
189        let mut obj = serde_json::json!({
190            "error": true,
191            "code": self.code(),
192            "message": self.to_string(),
193            "retryable": self.retryable(),
194        });
195
196        if let Some(fix) = suggested_fix {
197            obj["suggested_fix"] = serde_json::Value::String(fix);
198        }
199
200        obj
201    }
202}
203
204/// Find the closest matching tool name using Levenshtein distance.
205///
206/// Returns a suggestion string if a tool within edit distance 3 is found.
207fn find_similar_tool(server: &str, tool: &str, known_tools: &[(&str, &str)]) -> Option<String> {
208    let full_name = format!("{server}.{tool}");
209    let mut best: Option<(usize, String)> = None;
210
211    for &(s, t) in known_tools {
212        // Try matching the full "server.tool" form
213        let candidate_full = format!("{s}.{t}");
214        let dist = strsim::levenshtein(&full_name, &candidate_full);
215        if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
216            best = Some((dist, format!("Did you mean '{t}' on server '{s}'?")));
217        }
218
219        // Also try matching just the tool name on the same server
220        if s == server {
221            let dist = strsim::levenshtein(tool, t);
222            if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
223                best = Some((dist, format!("Did you mean '{t}'?")));
224            }
225        }
226    }
227
228    best.map(|(_, suggestion)| suggestion)
229}
230
231/// Find the closest matching server name using Levenshtein distance.
232fn find_similar_server(name: &str, known_tools: &[(&str, &str)]) -> Option<String> {
233    let mut seen = std::collections::HashSet::new();
234    let mut best: Option<(usize, String)> = None;
235
236    for &(s, _) in known_tools {
237        if !seen.insert(s) {
238            continue;
239        }
240        let dist = strsim::levenshtein(name, s);
241        if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
242            best = Some((dist, format!("Did you mean server '{s}'?")));
243        }
244    }
245
246    best.map(|(_, suggestion)| suggestion)
247}
248
249// Compile-time assertion: DispatchError must be Send + Sync + 'static
250const _: fn() = || {
251    fn assert_bounds<T: Send + Sync + 'static>() {}
252    assert_bounds::<DispatchError>();
253};
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn display_server_not_found() {
261        let err = DispatchError::ServerNotFound("myserver".into());
262        assert_eq!(err.to_string(), "server not found: myserver");
263    }
264
265    #[test]
266    fn display_tool_not_found() {
267        let err = DispatchError::ToolNotFound {
268            server: "srv".into(),
269            tool: "hammer".into(),
270        };
271        assert_eq!(err.to_string(), "tool not found: 'hammer' on server 'srv'");
272    }
273
274    #[test]
275    fn display_timeout() {
276        let err = DispatchError::Timeout {
277            server: "slow".into(),
278            timeout_ms: 5000,
279        };
280        assert_eq!(err.to_string(), "timeout after 5000ms on server 'slow'");
281    }
282
283    #[test]
284    fn display_circuit_open() {
285        let err = DispatchError::CircuitOpen("broken".into());
286        assert_eq!(err.to_string(), "circuit breaker open for server: broken");
287    }
288
289    #[test]
290    fn display_group_policy_denied() {
291        let err = DispatchError::GroupPolicyDenied {
292            reason: "cross-server access denied".into(),
293        };
294        assert_eq!(
295            err.to_string(),
296            "group policy denied: cross-server access denied"
297        );
298    }
299
300    #[test]
301    fn display_upstream() {
302        let err = DispatchError::Upstream {
303            server: "remote".into(),
304            message: "connection refused".into(),
305        };
306        assert_eq!(
307            err.to_string(),
308            "upstream error from 'remote': connection refused"
309        );
310    }
311
312    #[test]
313    fn display_rate_limit() {
314        let err = DispatchError::RateLimit("too many tool calls".into());
315        assert_eq!(err.to_string(), "rate limit exceeded: too many tool calls");
316    }
317
318    #[test]
319    fn display_internal() {
320        let err = DispatchError::Internal(anyhow::anyhow!("something broke"));
321        assert_eq!(err.to_string(), "something broke");
322    }
323
324    #[test]
325    fn code_exhaustive() {
326        let cases: Vec<(DispatchError, &str)> = vec![
327            (
328                DispatchError::ServerNotFound("x".into()),
329                "SERVER_NOT_FOUND",
330            ),
331            (
332                DispatchError::ToolNotFound {
333                    server: "s".into(),
334                    tool: "t".into(),
335                },
336                "TOOL_NOT_FOUND",
337            ),
338            (
339                DispatchError::Timeout {
340                    server: "s".into(),
341                    timeout_ms: 1000,
342                },
343                "TIMEOUT",
344            ),
345            (DispatchError::CircuitOpen("x".into()), "CIRCUIT_OPEN"),
346            (
347                DispatchError::GroupPolicyDenied { reason: "r".into() },
348                "GROUP_POLICY_DENIED",
349            ),
350            (
351                DispatchError::Upstream {
352                    server: "s".into(),
353                    message: "m".into(),
354                },
355                "UPSTREAM_ERROR",
356            ),
357            (
358                DispatchError::TransportDead {
359                    server: "s".into(),
360                    reason: "pipe broken".into(),
361                },
362                "TRANSPORT_DEAD",
363            ),
364            (
365                DispatchError::ToolError {
366                    server: "s".into(),
367                    tool: "t".into(),
368                    message: "m".into(),
369                },
370                "TOOL_ERROR",
371            ),
372            (DispatchError::RateLimit("x".into()), "RATE_LIMIT"),
373            (DispatchError::Internal(anyhow::anyhow!("x")), "INTERNAL"),
374        ];
375        for (err, expected_code) in &cases {
376            assert_eq!(err.code(), *expected_code, "wrong code for {err}");
377        }
378    }
379
380    #[test]
381    fn retryable_true_cases() {
382        assert!(DispatchError::Timeout {
383            server: "s".into(),
384            timeout_ms: 1000
385        }
386        .retryable());
387        assert!(DispatchError::CircuitOpen("s".into()).retryable());
388        assert!(DispatchError::RateLimit("x".into()).retryable());
389        assert!(DispatchError::Upstream {
390            server: "s".into(),
391            message: "m".into()
392        }
393        .retryable());
394    }
395
396    #[test]
397    fn retryable_false_cases() {
398        assert!(!DispatchError::ServerNotFound("x".into()).retryable());
399        assert!(!DispatchError::ToolNotFound {
400            server: "s".into(),
401            tool: "t".into()
402        }
403        .retryable());
404        assert!(!DispatchError::ToolError {
405            server: "s".into(),
406            tool: "t".into(),
407            message: "m".into()
408        }
409        .retryable());
410        assert!(!DispatchError::GroupPolicyDenied { reason: "r".into() }.retryable());
411        assert!(!DispatchError::Internal(anyhow::anyhow!("x")).retryable());
412    }
413
414    // --- trips_circuit_breaker tests ---
415
416    #[test]
417    fn trips_cb_true_for_server_faults() {
418        assert!(DispatchError::Timeout {
419            server: "s".into(),
420            timeout_ms: 5000
421        }
422        .trips_circuit_breaker());
423        assert!(DispatchError::Upstream {
424            server: "s".into(),
425            message: "connection refused".into()
426        }
427        .trips_circuit_breaker());
428        assert!(DispatchError::Internal(anyhow::anyhow!("unexpected")).trips_circuit_breaker());
429    }
430
431    #[test]
432    fn trips_cb_false_for_tool_error() {
433        assert!(!DispatchError::ToolError {
434            server: "arbiter".into(),
435            tool: "scan".into(),
436            message: "Invalid params: missing field 'base_url'".into()
437        }
438        .trips_circuit_breaker());
439    }
440
441    #[test]
442    fn trips_cb_false_for_client_errors() {
443        assert!(!DispatchError::ServerNotFound("x".into()).trips_circuit_breaker());
444        assert!(!DispatchError::ToolNotFound {
445            server: "s".into(),
446            tool: "t".into()
447        }
448        .trips_circuit_breaker());
449        assert!(!DispatchError::GroupPolicyDenied { reason: "r".into() }.trips_circuit_breaker());
450        assert!(!DispatchError::RateLimit("x".into()).trips_circuit_breaker());
451        assert!(!DispatchError::CircuitOpen("x".into()).trips_circuit_breaker());
452    }
453
454    #[test]
455    fn send_sync_static() {
456        fn assert_send_sync_static<T: Send + Sync + 'static>() {}
457        assert_send_sync_static::<DispatchError>();
458    }
459
460    #[test]
461    fn from_anyhow_error() {
462        let anyhow_err = anyhow::anyhow!("test anyhow");
463        let dispatch_err: DispatchError = anyhow_err.into();
464        assert!(matches!(dispatch_err, DispatchError::Internal(_)));
465        assert_eq!(dispatch_err.code(), "INTERNAL");
466    }
467
468    #[test]
469    fn internal_is_display_transparent() {
470        let inner = anyhow::anyhow!("root cause");
471        let err = DispatchError::Internal(inner);
472        // #[error(transparent)] means Display delegates to the inner error
473        assert_eq!(err.to_string(), "root cause");
474    }
475
476    // --- Structured error tests (Phase 5B) ---
477
478    #[test]
479    fn structured_error_server_not_found() {
480        let err = DispatchError::ServerNotFound("narsil".into());
481        let json = err.to_structured_error(None);
482        assert_eq!(json["error"], true);
483        assert_eq!(json["code"], "SERVER_NOT_FOUND");
484        assert_eq!(json["retryable"], false);
485        assert!(json["message"].as_str().unwrap().contains("narsil"));
486    }
487
488    #[test]
489    fn structured_error_tool_not_found_with_suggestion() {
490        let err = DispatchError::ToolNotFound {
491            server: "narsil".into(),
492            tool: "fnd_symbols".into(),
493        };
494        let tools = vec![
495            ("narsil", "find_symbols"),
496            ("narsil", "parse"),
497            ("github", "list_repos"),
498        ];
499        let json = err.to_structured_error(Some(&tools));
500        assert_eq!(json["code"], "TOOL_NOT_FOUND");
501        let fix = json["suggested_fix"].as_str().unwrap();
502        assert!(
503            fix.contains("find_symbols"),
504            "expected suggestion, got: {fix}"
505        );
506    }
507
508    #[test]
509    fn structured_error_tool_not_found_no_match() {
510        let err = DispatchError::ToolNotFound {
511            server: "narsil".into(),
512            tool: "completely_different".into(),
513        };
514        let tools = vec![("narsil", "find_symbols"), ("narsil", "parse")];
515        let json = err.to_structured_error(Some(&tools));
516        assert!(json.get("suggested_fix").is_none());
517    }
518
519    #[test]
520    fn structured_error_server_not_found_with_suggestion() {
521        let err = DispatchError::ServerNotFound("narsill".into());
522        let tools = vec![("narsil", "find_symbols"), ("github", "list_repos")];
523        let json = err.to_structured_error(Some(&tools));
524        let fix = json["suggested_fix"].as_str().unwrap();
525        assert!(
526            fix.contains("narsil"),
527            "expected server suggestion, got: {fix}"
528        );
529    }
530
531    #[test]
532    fn structured_error_timeout_has_retry_suggestion() {
533        let err = DispatchError::Timeout {
534            server: "slow".into(),
535            timeout_ms: 5000,
536        };
537        let json = err.to_structured_error(None);
538        assert_eq!(json["retryable"], true);
539        assert!(json["suggested_fix"].as_str().is_some());
540    }
541
542    #[test]
543    fn structured_error_circuit_open_has_retry_suggestion() {
544        let err = DispatchError::CircuitOpen("broken".into());
545        let json = err.to_structured_error(None);
546        assert_eq!(json["retryable"], true);
547        assert!(json["suggested_fix"].as_str().unwrap().contains("Retry"));
548    }
549
550    #[test]
551    fn display_tool_error() {
552        let err = DispatchError::ToolError {
553            server: "arbiter".into(),
554            tool: "scan_target".into(),
555            message: "tool returned error: Invalid params: missing field 'base_url'".into(),
556        };
557        assert_eq!(
558            err.to_string(),
559            "tool error on 'arbiter' calling 'scan_target': tool returned error: Invalid params: missing field 'base_url'"
560        );
561    }
562
563    #[test]
564    fn structured_error_tool_error_has_schema_suggestion() {
565        let err = DispatchError::ToolError {
566            server: "arbiter".into(),
567            tool: "scan".into(),
568            message: "Invalid params: missing field 'base_url'".into(),
569        };
570        let json = err.to_structured_error(None);
571        assert_eq!(json["code"], "TOOL_ERROR");
572        assert_eq!(json["retryable"], false);
573        let fix = json["suggested_fix"].as_str().unwrap();
574        assert!(
575            fix.contains("input_schema"),
576            "expected schema hint, got: {fix}"
577        );
578    }
579
580    #[test]
581    fn structured_error_internal_no_suggestion() {
582        let err = DispatchError::Internal(anyhow::anyhow!("unexpected"));
583        let json = err.to_structured_error(None);
584        assert_eq!(json["code"], "INTERNAL");
585        assert_eq!(json["retryable"], false);
586        assert!(json.get("suggested_fix").is_none());
587    }
588
589    #[test]
590    fn fuzzy_match_close_tool_name() {
591        // "fnd" is edit distance 1 from "find"
592        let result = super::find_similar_tool(
593            "narsil",
594            "fnd_symbols",
595            &[("narsil", "find_symbols"), ("narsil", "parse")],
596        );
597        assert!(result.is_some());
598        assert!(result.unwrap().contains("find_symbols"));
599    }
600
601    #[test]
602    fn fuzzy_match_no_match_beyond_threshold() {
603        let result = super::find_similar_tool(
604            "narsil",
605            "zzzzz",
606            &[("narsil", "find_symbols"), ("narsil", "parse")],
607        );
608        assert!(result.is_none());
609    }
610
611    #[test]
612    fn fuzzy_match_server_name() {
613        let result = super::find_similar_server(
614            "narsill",
615            &[("narsil", "find_symbols"), ("github", "list_repos")],
616        );
617        assert!(result.is_some());
618        assert!(result.unwrap().contains("narsil"));
619    }
620
621    // --- TransportDead tests ---
622
623    #[test]
624    fn display_transport_dead() {
625        let err = DispatchError::TransportDead {
626            server: "arbiter".into(),
627            reason: "channel closed".into(),
628        };
629        assert_eq!(
630            err.to_string(),
631            "transport dead for server 'arbiter': channel closed"
632        );
633    }
634
635    #[test]
636    fn transport_dead_code() {
637        let err = DispatchError::TransportDead {
638            server: "s".into(),
639            reason: "r".into(),
640        };
641        assert_eq!(err.code(), "TRANSPORT_DEAD");
642    }
643
644    #[test]
645    fn transport_dead_trips_circuit_breaker() {
646        assert!(DispatchError::TransportDead {
647            server: "s".into(),
648            reason: "pipe broken".into(),
649        }
650        .trips_circuit_breaker());
651    }
652
653    #[test]
654    fn transport_dead_not_retryable() {
655        assert!(!DispatchError::TransportDead {
656            server: "s".into(),
657            reason: "pipe broken".into(),
658        }
659        .retryable());
660    }
661
662    #[test]
663    fn structured_error_transport_dead() {
664        let err = DispatchError::TransportDead {
665            server: "arbiter".into(),
666            reason: "channel closed".into(),
667        };
668        let json = err.to_structured_error(None);
669        assert_eq!(json["code"], "TRANSPORT_DEAD");
670        assert_eq!(json["retryable"], false);
671        let fix = json["suggested_fix"].as_str().unwrap();
672        assert!(
673            fix.contains("transport is dead"),
674            "expected transport dead suggestion, got: {fix}"
675        );
676    }
677}