Skip to main content

forge_client/
reconnect.rs

1//! Auto-reconnecting client decorator for transport resilience.
2//!
3//! Wraps an [`McpClient`] and automatically reconnects when a
4//! [`TransportDead`](forge_error::DispatchError::TransportDead) error is detected.
5//!
6//! When multiple concurrent calls detect a dead transport simultaneously,
7//! only one reconnection attempt proceeds (CAS on `reconnecting`). Other
8//! callers wait on a [`tokio::sync::Notify`] for the reconnection to complete,
9//! then retry with the fresh client. This prevents reconnection-induced
10//! failures from cascading into the circuit breaker.
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14use std::time::Duration;
15
16use forge_sandbox::{ResourceDispatcher, ToolDispatcher};
17use serde_json::Value;
18use tokio::sync::{Mutex, Notify, RwLock};
19
20use crate::{McpClient, TransportConfig};
21
22/// A client wrapper that auto-reconnects on transport death.
23///
24/// Implements both [`ToolDispatcher`] and [`ResourceDispatcher`] by delegating
25/// to an inner [`McpClient`]. When a `TransportDead` error is detected, the
26/// client is transparently replaced with a fresh connection.
27///
28/// Uses a `RwLock` for the inner client so that in-flight read operations
29/// (tool calls, resource reads) can proceed concurrently, while reconnection
30/// briefly takes a write lock.
31///
32/// A [`Notify`] wakes all waiting callers once reconnection completes, so
33/// concurrent calls that hit a dead transport wait for the reconnection
34/// rather than failing immediately.
35pub struct ReconnectingClient {
36    name: String,
37    transport_config: TransportConfig,
38    inner: RwLock<Arc<McpClient>>,
39    reconnecting: AtomicBool,
40    /// Notified when a reconnection attempt completes (success or failure).
41    reconnect_done: Notify,
42    max_backoff: Duration,
43    current_backoff: Mutex<Duration>,
44}
45
46impl ReconnectingClient {
47    /// Create a new reconnecting wrapper around an existing client.
48    pub fn new(
49        name: String,
50        transport_config: TransportConfig,
51        client: Arc<McpClient>,
52        max_backoff: Duration,
53    ) -> Self {
54        Self {
55            name,
56            transport_config,
57            inner: RwLock::new(client),
58            reconnecting: AtomicBool::new(false),
59            reconnect_done: Notify::new(),
60            max_backoff,
61            current_backoff: Mutex::new(Duration::from_secs(1)),
62        }
63    }
64
65    /// Attempt to reconnect, returning true on success.
66    ///
67    /// Uses CAS on `reconnecting` to ensure only one reconnection attempt
68    /// proceeds at a time. Other callers wait on [`reconnect_done`] for the
69    /// reconnection to complete, then check the result.
70    async fn try_reconnect(&self) -> bool {
71        // CAS: only one task reconnects at a time
72        if self
73            .reconnecting
74            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
75            .is_err()
76        {
77            // Another task is already reconnecting — wait for it to finish.
78            // Use a bounded wait to avoid hanging forever if the reconnector panics.
79            tracing::debug!(server = %self.name, "waiting for concurrent reconnection to complete");
80            let wait_result =
81                tokio::time::timeout(Duration::from_secs(30), self.reconnect_done.notified()).await;
82            if wait_result.is_err() {
83                tracing::warn!(
84                    server = %self.name,
85                    "timed out waiting for concurrent reconnection"
86                );
87            }
88            // Check whether the reconnection succeeded by testing the flag.
89            // If it's still set, the reconnection failed or is stuck.
90            return !self.reconnecting.load(Ordering::SeqCst);
91        }
92
93        // Apply backoff delay
94        let backoff = {
95            let guard = self.current_backoff.lock().await;
96            *guard
97        };
98        tracing::info!(
99            server = %self.name,
100            backoff_ms = backoff.as_millis(),
101            "attempting reconnection after backoff"
102        );
103        tokio::time::sleep(backoff).await;
104
105        // Try to create a new connection
106        let success = match McpClient::connect(self.name.clone(), &self.transport_config).await {
107            Ok(new_client) => {
108                tracing::info!(server = %self.name, "reconnection successful");
109                // Swap the client under write lock
110                {
111                    let mut inner = self.inner.write().await;
112                    *inner = Arc::new(new_client);
113                }
114                // Reset backoff on success
115                {
116                    let mut guard = self.current_backoff.lock().await;
117                    *guard = Duration::from_secs(1);
118                }
119                true
120            }
121            Err(e) => {
122                tracing::warn!(
123                    server = %self.name,
124                    error = %e,
125                    "reconnection failed"
126                );
127                // Increase backoff (exponential, capped)
128                {
129                    let mut guard = self.current_backoff.lock().await;
130                    *guard = (*guard * 2).min(self.max_backoff);
131                }
132                false
133            }
134        };
135
136        self.reconnecting.store(false, Ordering::SeqCst);
137        // Wake ALL waiting callers so they can retry with the new client
138        self.reconnect_done.notify_waiters();
139        success
140    }
141
142    /// Get a clone of the current inner client.
143    async fn current_client(&self) -> Arc<McpClient> {
144        self.inner.read().await.clone()
145    }
146}
147
148#[async_trait::async_trait]
149impl ToolDispatcher for ReconnectingClient {
150    async fn call_tool(
151        &self,
152        server: &str,
153        tool: &str,
154        args: Value,
155    ) -> Result<Value, forge_error::DispatchError> {
156        let client = self.current_client().await;
157        let result = client.call_tool(server, tool, args.clone()).await;
158
159        match result {
160            Err(forge_error::DispatchError::TransportDead { .. }) => {
161                tracing::warn!(
162                    server = %self.name,
163                    tool = %tool,
164                    "transport dead, attempting reconnection"
165                );
166                if self.try_reconnect().await {
167                    // Retry once with the new client
168                    let new_client = self.current_client().await;
169                    new_client.call_tool(server, tool, args).await
170                } else {
171                    Err(forge_error::DispatchError::TransportDead {
172                        server: self.name.clone(),
173                        reason: "reconnection failed".into(),
174                    })
175                }
176            }
177            other => other,
178        }
179    }
180}
181
182#[async_trait::async_trait]
183impl ResourceDispatcher for ReconnectingClient {
184    async fn read_resource(
185        &self,
186        server: &str,
187        uri: &str,
188    ) -> Result<Value, forge_error::DispatchError> {
189        let client = self.current_client().await;
190        let result = ResourceDispatcher::read_resource(client.as_ref(), server, uri).await;
191
192        match result {
193            Err(forge_error::DispatchError::TransportDead { .. }) => {
194                tracing::warn!(
195                    server = %self.name,
196                    uri = %uri,
197                    "transport dead, attempting reconnection"
198                );
199                if self.try_reconnect().await {
200                    let new_client = self.current_client().await;
201                    ResourceDispatcher::read_resource(new_client.as_ref(), server, uri).await
202                } else {
203                    Err(forge_error::DispatchError::TransportDead {
204                        server: self.name.clone(),
205                        reason: "reconnection failed".into(),
206                    })
207                }
208            }
209            other => other,
210        }
211    }
212}