Skip to main content

mcp_proxy/
retry.rs

1//! Retry middleware for per-backend request retries with exponential backoff.
2//!
3//! Uses tower-resilience's [`RetryLayer`] with a response-based predicate to
4//! retry MCP error responses with transient error codes (internal errors,
5//! timeouts). Tool-not-found and other client errors are not retried.
6//!
7//! # Retry Budget
8//!
9//! When `budget_percent` is configured, a token bucket budget limits retries.
10//! The budget is sized as a percentage of expected request volume, preventing
11//! retry storms during widespread backend failures. A `min_retries_per_sec`
12//! floor ensures low-traffic backends can still retry.
13//!
14//! # Configuration
15//!
16//! ```toml
17//! [[backends]]
18//! name = "flaky-api"
19//! transport = "http"
20//! url = "http://localhost:8080"
21//!
22//! [backends.retry]
23//! max_retries = 3
24//! initial_backoff_ms = 100
25//! max_backoff_ms = 5000
26//! budget_percent = 20.0    # max 20% of requests can be retries
27//! min_retries_per_sec = 10 # floor for low-traffic backends
28//! ```
29
30use std::time::Duration;
31
32use tower_mcp::router::RouterResponse;
33use tower_resilience::retry::{RetryBudgetBuilder, RetryLayer};
34
35use crate::config::RetryConfig;
36
37/// Returns true if the MCP error code indicates a transient/retriable error.
38fn is_retriable_error(code: i32) -> bool {
39    // JSON-RPC internal error (-32603) and server errors (-32000 to -32099)
40    // are potentially transient. Method not found, invalid params, etc. are not.
41    code == -32603 || (-32099..=-32000).contains(&code)
42}
43
44/// Returns true if a `RouterResponse` contains a retriable MCP error.
45fn is_retriable_response(resp: &RouterResponse) -> bool {
46    match &resp.inner {
47        Err(err) => is_retriable_error(err.code),
48        Ok(_) => false,
49    }
50}
51
52/// Build a tower-resilience [`RetryLayer`] from our [`RetryConfig`].
53///
54/// The layer uses a response-based predicate (since MCP services use
55/// `Error = Infallible` and encode errors inside `RouterResponse`).
56pub fn build_retry_layer(
57    config: &RetryConfig,
58    backend_name: &str,
59) -> RetryLayer<tower_mcp::router::RouterRequest, RouterResponse, std::convert::Infallible> {
60    let mut builder = RetryLayer::builder()
61        // max_attempts includes the initial attempt, so max_retries + 1
62        .max_attempts((config.max_retries + 1) as usize)
63        .exponential_backoff(Duration::from_millis(config.initial_backoff_ms))
64        .retry_on_response(is_retriable_response)
65        .name(format!("retry-{backend_name}"));
66
67    // Configure budget if percent-based limiting is enabled
68    if let Some(percent) = config.budget_percent {
69        // Map budget_percent to a token bucket: scale tokens to approximate
70        // the percentage model. We use min_retries_per_sec as the refill rate
71        // and size the bucket relative to expected request volume.
72        //
73        // For a 20% budget at 100 req/s, we'd want ~20 retries/s capacity.
74        // The token bucket's initial_tokens acts as burst capacity.
75        let min_per_sec = config.min_retries_per_sec as f64;
76        let max_tokens = ((percent / 100.0) * 1000.0).max(min_per_sec * 10.0) as usize;
77
78        let budget = RetryBudgetBuilder::new()
79            .token_bucket()
80            .tokens_per_second(min_per_sec)
81            .max_tokens(max_tokens.max(1))
82            .initial_tokens(max_tokens.max(1))
83            .build()
84            .expect("nonnegative retry refill and nonzero capacity with matching initial tokens");
85
86        builder = builder.budget(budget);
87    }
88
89    builder.build()
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::config::RetryConfig;
96    use crate::test_util::{ErrorMockService, MockService, call_service};
97    use tower::Layer;
98    use tower_mcp_types::protocol::McpRequest;
99
100    fn make_config(max_retries: u32) -> RetryConfig {
101        RetryConfig {
102            max_retries,
103            initial_backoff_ms: 1, // fast for tests
104            max_backoff_ms: 10,
105            budget_percent: None,
106            min_retries_per_sec: 10,
107        }
108    }
109
110    fn make_config_with_budget(max_retries: u32, budget_percent: f64) -> RetryConfig {
111        RetryConfig {
112            max_retries,
113            initial_backoff_ms: 1,
114            max_backoff_ms: 10,
115            budget_percent: Some(budget_percent),
116            min_retries_per_sec: 0,
117        }
118    }
119
120    #[tokio::test]
121    async fn test_retries_internal_error() {
122        // ErrorMockService always returns a -32603 error
123        let svc = ErrorMockService;
124        let layer = build_retry_layer(&make_config(3), "test");
125        let mut svc = layer.layer(svc);
126
127        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
128        // Should still get an error (all attempts fail), but it should have retried
129        assert!(resp.inner.is_err());
130    }
131
132    #[tokio::test]
133    async fn test_does_not_retry_success() {
134        let svc = MockService::with_tools(&["tool1"]);
135        let layer = build_retry_layer(&make_config(3), "test");
136        let mut svc = layer.layer(svc);
137
138        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
139        assert!(resp.inner.is_ok());
140    }
141
142    #[tokio::test]
143    async fn test_response_predicate_matches_transient_errors() {
144        // Verify the predicate function directly
145        use tower_mcp::protocol::RequestId;
146        use tower_mcp_types::JsonRpcError;
147
148        let transient = RouterResponse {
149            id: RequestId::Number(1),
150            inner: Err(JsonRpcError {
151                code: -32603,
152                message: "internal error".to_string(),
153                data: None,
154            }),
155        };
156        assert!(is_retriable_response(&transient));
157
158        let server_err = RouterResponse {
159            id: RequestId::Number(1),
160            inner: Err(JsonRpcError {
161                code: -32000,
162                message: "server error".to_string(),
163                data: None,
164            }),
165        };
166        assert!(is_retriable_response(&server_err));
167
168        // Client error -- should NOT retry
169        let client_err = RouterResponse {
170            id: RequestId::Number(1),
171            inner: Err(JsonRpcError {
172                code: -32601,
173                message: "method not found".to_string(),
174                data: None,
175            }),
176        };
177        assert!(!is_retriable_response(&client_err));
178
179        // Success -- should NOT retry
180        let success = RouterResponse {
181            id: RequestId::Number(1),
182            inner: Ok(tower_mcp_types::protocol::McpResponse::ListTools(
183                tower_mcp_types::protocol::ListToolsResult {
184                    tools: vec![],
185                    next_cursor: None,
186                    ttl_ms: None,
187                    cache_scope: None,
188                    meta: None,
189                },
190            )),
191        };
192        assert!(!is_retriable_response(&success));
193    }
194
195    #[tokio::test]
196    async fn test_budget_limits_retries() {
197        // With a very small budget, retries should be limited
198        let config = make_config_with_budget(10, 1.0); // tiny budget
199        let layer = build_retry_layer(&config, "test");
200
201        let svc = ErrorMockService;
202        let mut svc = layer.layer(svc);
203
204        // Should still eventually return (budget exhaustion returns the response)
205        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
206        assert!(resp.inner.is_err());
207    }
208
209    #[tokio::test]
210    async fn test_no_budget_allows_all_retries() {
211        let config = make_config(2); // No budget, 2 retries
212        let _layer = build_retry_layer(&config, "test");
213        // Just verify it builds without a budget
214        assert!(config.budget_percent.is_none());
215    }
216}