Skip to main content

foundry_common/provider/
runtime_transport.rs

1//! Runtime transport that connects on first request, which can take either of an HTTP,
2//! WebSocket, or IPC transport. Retries are handled by a client layer (e.g.,
3//! `RetryBackoffLayer`) when used.
4
5use crate::{
6    DEFAULT_USER_AGENT, REQUEST_TIMEOUT,
7    provider::{
8        mpp::transport::{LazyMppHttpTransport, lazy_mpp_ws_connect},
9        redact_url,
10    },
11};
12use alloy_json_rpc::{RequestPacket, ResponsePacket};
13use alloy_pubsub::{PubSubConnect, PubSubFrontend};
14use alloy_rpc_types_engine::{Claims, JwtSecret};
15use alloy_transport::{
16    Authorization, BoxTransport, TransportError, TransportErrorKind, TransportFut,
17    utils::guess_local_url,
18};
19use alloy_transport_ipc::IpcConnect;
20use alloy_transport_ws::WsConnect;
21use regex::{Captures, Regex};
22use reqwest::header::{HeaderName, HeaderValue};
23use std::{
24    error::Error as StdError,
25    fmt,
26    path::PathBuf,
27    str::FromStr,
28    sync::{Arc, LazyLock},
29};
30use thiserror::Error;
31use tokio::sync::RwLock;
32use tower::Service;
33use url::Url;
34
35/// Known MPP-enabled RPC host suffixes.
36///
37/// Endpoints matching these patterns always use the MPP WebSocket transport,
38/// regardless of whether local MPP keys have been discovered.
39const KNOWN_MPP_HOSTS: &[&str] = &[".mpp.tempo.xyz", ".mpp.moderato.tempo.xyz"];
40
41static HTTP_URL_RE: LazyLock<Regex> =
42    LazyLock::new(|| Regex::new(r#"(?i)https?://[^\s<>"']+"#).expect("valid URL regex"));
43
44/// Returns `true` if `url` points to a known MPP-enabled RPC service.
45fn is_known_mpp_endpoint(url: &Url) -> bool {
46    url.host_str().is_some_and(|host| KNOWN_MPP_HOSTS.iter().any(|suffix| host.ends_with(suffix)))
47}
48
49/// An enum representing the different transports that can be used to connect to a runtime.
50/// Only meant to be used internally by [RuntimeTransport].
51#[derive(Clone, Debug)]
52pub enum InnerTransport {
53    /// HTTP transport with lazy MPP 402 handling.
54    ///
55    /// For known Tempo endpoints, the MPP layer additionally runs the
56    /// `wallet.tempo.xyz` device-code flow on a 402 when no local access key
57    /// is configured (see [`crate::tempo::ensure_access_key`]).
58    Http(LazyMppHttpTransport),
59    /// WebSocket transport
60    Ws(PubSubFrontend),
61    /// IPC transport
62    Ipc(PubSubFrontend),
63}
64
65/// Error type for the runtime transport.
66#[derive(Error, Debug)]
67pub enum RuntimeTransportError {
68    /// Internal transport error
69    #[error("Internal transport error: {0} with {1}")]
70    TransportError(TransportError, String),
71
72    /// Invalid URL scheme
73    #[error("URL scheme is not supported: {0}")]
74    BadScheme(String),
75
76    /// Invalid HTTP header
77    #[error("Invalid HTTP header: {0}")]
78    BadHeader(String),
79
80    /// Invalid file path
81    #[error("Invalid IPC file path: {0}")]
82    BadPath(String),
83
84    /// Invalid construction of Http provider
85    #[error(transparent)]
86    HttpConstructionError(#[from] reqwest::Error),
87
88    /// Invalid JWT
89    #[error("Invalid JWT: {0}")]
90    InvalidJwt(String),
91}
92
93/// Runtime transport that only connects on first request.
94///
95/// A runtime transport is a custom [`alloy_transport::Transport`] that only connects when the
96/// *first* request is made. When the first request is made, it will connect to the runtime using
97/// either an HTTP WebSocket, or IPC transport depending on the URL used.
98/// Retries for rate-limiting and timeout-related errors are handled by an external
99/// client layer (e.g., `RetryBackoffLayer`) when configured.
100#[derive(Clone, Debug)]
101pub struct RuntimeTransport {
102    /// The inner actual transport used.
103    inner: Arc<RwLock<Option<InnerTransport>>>,
104    /// The URL to connect to.
105    url: Url,
106    /// The headers to use for requests.
107    headers: Vec<String>,
108    /// The JWT to use for requests.
109    jwt: Option<String>,
110    /// The timeout for requests.
111    timeout: std::time::Duration,
112    /// Whether to accept invalid certificates.
113    accept_invalid_certs: bool,
114    /// Whether to disable automatic proxy detection.
115    no_proxy: bool,
116}
117
118/// A builder for [RuntimeTransport].
119#[derive(Debug)]
120pub struct RuntimeTransportBuilder {
121    url: Url,
122    headers: Vec<String>,
123    jwt: Option<String>,
124    timeout: std::time::Duration,
125    accept_invalid_certs: bool,
126    no_proxy: bool,
127}
128
129impl RuntimeTransportBuilder {
130    /// Create a new builder with the given URL.
131    pub const fn new(url: Url) -> Self {
132        Self {
133            url,
134            headers: vec![],
135            jwt: None,
136            timeout: REQUEST_TIMEOUT,
137            accept_invalid_certs: false,
138            no_proxy: false,
139        }
140    }
141
142    /// Set the URL for the transport.
143    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
144        self.headers = headers;
145        self
146    }
147
148    /// Set the JWT for the transport.
149    pub fn with_jwt(mut self, jwt: Option<String>) -> Self {
150        self.jwt = jwt;
151        self
152    }
153
154    /// Set the timeout for the transport.
155    pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
156        self.timeout = timeout;
157        self
158    }
159
160    /// Set whether to accept invalid certificates.
161    pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
162        self.accept_invalid_certs = accept_invalid_certs;
163        self
164    }
165
166    /// Set whether to disable automatic proxy detection.
167    ///
168    /// This can help in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox)
169    /// where system proxy detection via SCDynamicStore causes crashes.
170    pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
171        self.no_proxy = no_proxy;
172        self
173    }
174
175    /// Builds the [RuntimeTransport] and returns it in a disconnected state.
176    /// The runtime transport will then connect when the first request happens.
177    pub fn build(self) -> RuntimeTransport {
178        RuntimeTransport {
179            inner: Arc::new(RwLock::new(None)),
180            url: self.url,
181            headers: self.headers,
182            jwt: self.jwt,
183            timeout: self.timeout,
184            accept_invalid_certs: self.accept_invalid_certs,
185            no_proxy: self.no_proxy,
186        }
187    }
188}
189
190impl fmt::Display for RuntimeTransport {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        write!(f, "RuntimeTransport {}", redact_url(self.url.as_str()))
193    }
194}
195
196impl RuntimeTransport {
197    /// Connects the underlying transport, depending on the URL scheme.
198    pub async fn connect(&self) -> Result<InnerTransport, RuntimeTransportError> {
199        match self.url.scheme() {
200            "http" | "https" => self.connect_http(),
201            "ws" | "wss" => self.connect_ws().await,
202            "file" => self.connect_ipc().await,
203            _ => Err(RuntimeTransportError::BadScheme(self.url.scheme().to_string())),
204        }
205    }
206
207    fn reqwest_headers(&self) -> Result<reqwest::header::HeaderMap, RuntimeTransportError> {
208        let mut headers = reqwest::header::HeaderMap::new();
209
210        // If there's a JWT, add it to the headers if we can decode it.
211        if let Some(jwt) = self.jwt.clone() {
212            let auth =
213                build_auth(jwt).map_err(|e| RuntimeTransportError::InvalidJwt(e.to_string()))?;
214
215            let mut auth_value: HeaderValue =
216                HeaderValue::from_str(&auth.to_string()).expect("Header should be valid string");
217            auth_value.set_sensitive(true);
218
219            headers.insert(reqwest::header::AUTHORIZATION, auth_value);
220        };
221
222        // Add any custom headers.
223        for header in &self.headers {
224            let make_err = || RuntimeTransportError::BadHeader(header.clone());
225
226            let (key, val) = header.split_once(':').ok_or_else(make_err)?;
227
228            headers.insert(
229                HeaderName::from_str(key.trim()).map_err(|_| make_err())?,
230                HeaderValue::from_str(val.trim()).map_err(|_| make_err())?,
231            );
232        }
233
234        if !headers.contains_key(reqwest::header::USER_AGENT) {
235            headers.insert(
236                reqwest::header::USER_AGENT,
237                HeaderValue::from_str(DEFAULT_USER_AGENT)
238                    .expect("User-Agent should be valid string"),
239            );
240        }
241
242        // If MPP_API_KEY is set, attach it as x-api-key for gated MPP proxies.
243        // Does not override an explicit x-api-key header from the user.
244        if !headers.contains_key(HeaderName::from_static("x-api-key"))
245            && let Ok(api_key) = std::env::var("MPP_API_KEY")
246        {
247            let api_key = api_key.trim();
248            if !api_key.is_empty() {
249                let mut value = HeaderValue::from_str(api_key)
250                    .map_err(|_| RuntimeTransportError::BadHeader("MPP_API_KEY".to_string()))?;
251                value.set_sensitive(true);
252                headers.insert(HeaderName::from_static("x-api-key"), value);
253            }
254        }
255
256        Ok(headers)
257    }
258
259    fn reqwest_client_with_headers(
260        &self,
261        headers: reqwest::header::HeaderMap,
262    ) -> Result<reqwest::Client, RuntimeTransportError> {
263        let mut client_builder = reqwest::Client::builder()
264            .timeout(self.timeout)
265            .danger_accept_invalid_certs(self.accept_invalid_certs);
266
267        // Disable automatic proxy detection if requested. This helps in sandboxed environments
268        // (e.g., Cursor IDE sandbox, macOS App Sandbox) where system proxy detection via
269        // SCDynamicStore causes crashes. See: https://github.com/foundry-rs/foundry/issues/12733
270        if self.no_proxy || guess_local_url(self.url.as_str()) {
271            client_builder = client_builder.no_proxy();
272        }
273
274        client_builder = client_builder.default_headers(headers);
275
276        Ok(client_builder.build()?)
277    }
278
279    /// Creates a new reqwest client from this transport.
280    pub fn reqwest_client(&self) -> Result<reqwest::Client, RuntimeTransportError> {
281        self.reqwest_client_with_headers(self.reqwest_headers()?)
282    }
283
284    /// Connects to an HTTP transport with lazy MPP 402 handling.
285    fn connect_http(&self) -> Result<InnerTransport, RuntimeTransportError> {
286        let headers = self.reqwest_headers()?;
287        let client = self.reqwest_client_with_headers(headers.clone())?;
288        Ok(InnerTransport::Http(LazyMppHttpTransport::lazy(client, self.url.clone(), headers)))
289    }
290
291    /// Connects to a WS transport.
292    ///
293    /// Uses the canonical Alloy MPP WebSocket transport when the endpoint is a
294    /// known MPP service.
295    /// Otherwise falls back to alloy's plain [`WsConnect`] with zero overhead.
296    async fn connect_ws(&self) -> Result<InnerTransport, RuntimeTransportError> {
297        let auth = self.jwt.as_ref().and_then(|jwt| build_auth(jwt.clone()).ok());
298
299        let service = if is_known_mpp_endpoint(&self.url) {
300            let mut ws = lazy_mpp_ws_connect(&self.url);
301            if let Some(auth) = auth {
302                ws = ws.with_auth(auth);
303            }
304            ws.into_service().await.map_err(|e| {
305                RuntimeTransportError::TransportError(e, redact_url(self.url.as_str()))
306            })?
307        } else {
308            let mut ws = WsConnect::new(self.url.to_string());
309            if let Some(auth) = auth {
310                ws = ws.with_auth(auth);
311            }
312            ws.into_service().await.map_err(|e| {
313                RuntimeTransportError::TransportError(e, redact_url(self.url.as_str()))
314            })?
315        };
316
317        Ok(InnerTransport::Ws(service))
318    }
319
320    /// Connects to an IPC transport.
321    async fn connect_ipc(&self) -> Result<InnerTransport, RuntimeTransportError> {
322        let path = url_to_file_path(&self.url)
323            .map_err(|_| RuntimeTransportError::BadPath(self.url.to_string()))?;
324        let ipc_connector = IpcConnect::new(path.clone());
325        let ipc = ipc_connector.into_service().await.map_err(|e| {
326            RuntimeTransportError::TransportError(e, path.clone().display().to_string())
327        })?;
328        Ok(InnerTransport::Ipc(ipc))
329    }
330
331    /// Sends a request using the underlying transport.
332    /// If this is the first request, it will connect to the appropriate transport depending on the
333    /// URL scheme. Retries are performed by an external client layer (e.g., `RetryBackoffLayer`),
334    /// if such a layer is configured by the caller.
335    /// For sending the actual request, this action is delegated down to the
336    /// underlying transport through Tower's [tower::Service::call]. See tower's [tower::Service]
337    /// trait for more information.
338    pub fn request(&self, req: RequestPacket) -> TransportFut<'static> {
339        let this = self.clone();
340        Box::pin(async move {
341            let mut inner = this.inner.read().await;
342            if inner.is_none() {
343                drop(inner);
344                {
345                    let mut inner_mut = this.inner.write().await;
346                    if inner_mut.is_none() {
347                        *inner_mut =
348                            Some(this.connect().await.map_err(TransportErrorKind::custom)?);
349                    }
350                }
351                inner = this.inner.read().await;
352            }
353
354            // SAFETY: We just checked that the inner transport exists.
355            match inner.clone().expect("must've been initialized") {
356                InnerTransport::Http(mut http) => http
357                    .call(req)
358                    .await
359                    .map_err(|error| redact_http_transport_error(error, &this.url)),
360                InnerTransport::Ws(mut ws) => ws.call(req).await,
361                InnerTransport::Ipc(mut ipc) => ipc.call(req).await,
362            }
363        })
364    }
365
366    /// Convert this transport into a boxed trait object.
367    pub fn boxed(self) -> BoxTransport
368    where
369        Self: Sized + Clone + Send + Sync + 'static,
370    {
371        BoxTransport::new(self)
372    }
373}
374
375fn redact_http_transport_error(error: TransportError, endpoint: &Url) -> TransportError {
376    let alloy_json_rpc::RpcError::Transport(TransportErrorKind::Custom(source)) = &error else {
377        return error;
378    };
379    let safe_endpoint = redact_url(endpoint.as_str());
380
381    let mut message = String::new();
382    let mut error: Option<&(dyn StdError + 'static)> = Some(source.as_ref());
383    while let Some(source) = error {
384        if !message.is_empty() {
385            message.push_str(": ");
386        }
387        message.push_str(&source.to_string());
388        error = source.source();
389    }
390    let message = HTTP_URL_RE.replace_all(&message, |captures: &Captures<'_>| {
391        let candidate = &captures[0];
392        let Ok(url) = Url::parse(candidate) else { return candidate.to_owned() };
393        if url.host() == endpoint.host()
394            && url.port_or_known_default() == endpoint.port_or_known_default()
395        {
396            safe_endpoint.clone()
397        } else {
398            candidate.to_owned()
399        }
400    });
401    TransportErrorKind::custom_str(&message)
402}
403
404impl tower::Service<RequestPacket> for RuntimeTransport {
405    type Response = ResponsePacket;
406    type Error = TransportError;
407    type Future = TransportFut<'static>;
408
409    #[inline]
410    fn poll_ready(
411        &mut self,
412        _cx: &mut std::task::Context<'_>,
413    ) -> std::task::Poll<Result<(), Self::Error>> {
414        std::task::Poll::Ready(Ok(()))
415    }
416
417    #[inline]
418    fn call(&mut self, req: RequestPacket) -> Self::Future {
419        self.request(req)
420    }
421}
422
423impl tower::Service<RequestPacket> for &RuntimeTransport {
424    type Response = ResponsePacket;
425    type Error = TransportError;
426    type Future = TransportFut<'static>;
427
428    #[inline]
429    fn poll_ready(
430        &mut self,
431        _cx: &mut std::task::Context<'_>,
432    ) -> std::task::Poll<Result<(), Self::Error>> {
433        std::task::Poll::Ready(Ok(()))
434    }
435
436    #[inline]
437    fn call(&mut self, req: RequestPacket) -> Self::Future {
438        self.request(req)
439    }
440}
441
442fn build_auth(jwt: String) -> eyre::Result<Authorization> {
443    // Decode jwt from hex, then generate claims (iat with current timestamp)
444    let secret = JwtSecret::from_hex(jwt)?;
445    let claims = Claims::default();
446    let token = secret.encode(&claims)?;
447
448    let auth = Authorization::Bearer(token);
449
450    Ok(auth)
451}
452
453#[cfg(windows)]
454fn url_to_file_path(url: &Url) -> Result<PathBuf, ()> {
455    const PREFIX: &str = "file:///pipe/";
456
457    let url_str = url.as_str();
458
459    if let Some(pipe_name) = url_str.strip_prefix(PREFIX) {
460        let pipe_path = format!(r"\\.\pipe\{pipe_name}");
461        return Ok(PathBuf::from(pipe_path));
462    }
463
464    url.to_file_path()
465}
466
467#[cfg(not(windows))]
468fn url_to_file_path(url: &Url) -> Result<PathBuf, ()> {
469    url.to_file_path()
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use reqwest::header::HeaderMap;
476    use std::io;
477
478    #[derive(Debug, Error)]
479    #[error("request to https://example.com/private-api-key failed")]
480    struct ProviderError {
481        #[source]
482        source: io::Error,
483    }
484
485    #[test]
486    fn http_transport_errors_preserve_provider_guidance() {
487        let endpoint =
488            Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
489        let error = TransportErrorKind::custom(ProviderError {
490            source: io::Error::other(
491                "Authorize an access key with:\n  cast tempo login --no-browser",
492            ),
493        });
494
495        let report = redact_http_transport_error(error, &endpoint).to_string();
496
497        assert!(report.contains("https://example.com/"));
498        assert!(report.contains("cast tempo login --no-browser"));
499        assert!(!report.contains("password"));
500        assert!(!report.contains("private-api-key"));
501        assert!(!report.contains("secret"));
502    }
503
504    #[test]
505    fn http_transport_errors_redact_endpoint_paths() {
506        let endpoint =
507            Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
508        let error = TransportErrorKind::custom_str(concat!(
509            "request to https://example.com/private-api-key failed: connection refused\n\n",
510            "Authorize an access key with:\n  cast tempo login"
511        ));
512
513        let error = redact_http_transport_error(error, &endpoint);
514        let report = error.to_string();
515
516        assert!(report.contains("https://example.com/"));
517        assert!(!report.contains("password"));
518        assert!(!report.contains("private-api-key"));
519        assert!(!report.contains("secret"));
520        assert!(report.to_lowercase().contains("connection refused"));
521        assert!(report.contains("cast tempo login"));
522    }
523
524    #[test]
525    fn http_transport_errors_redact_normalized_endpoint_variants() {
526        let endpoint =
527            Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
528        let error = TransportErrorKind::custom_str(
529            "request to https://USER:normalized@example.com:443/different%2Fpath?key=other failed",
530        );
531
532        let report = redact_http_transport_error(error, &endpoint).to_string();
533
534        assert!(report.contains("https://example.com/"));
535        assert!(!report.contains("normalized"));
536        assert!(!report.contains("different"));
537        assert!(!report.contains("other"));
538    }
539
540    #[tokio::test]
541    async fn websocket_error_redacts_url_credentials() {
542        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
543        let address = listener.local_addr().unwrap();
544        drop(listener);
545        let url = Url::parse(&format!(
546            "ws://user:password@{address}/private-api-key?token=secret#fragment"
547        ))
548        .unwrap();
549        let transport = RuntimeTransportBuilder::new(url).build();
550
551        let error = transport.connect_ws().await.unwrap_err().to_string();
552
553        assert!(error.contains(&format!("ws://{address}/")));
554        assert!(!error.contains("user"));
555        assert!(!error.contains("password"));
556        assert!(!error.contains("private-api-key"));
557        assert!(!error.contains("secret"));
558    }
559
560    #[tokio::test]
561    async fn test_user_agent_header() {
562        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
563        let url = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
564
565        let http_handler = axum::routing::get(|actual_headers: HeaderMap| {
566            let user_agent = HeaderName::from_str("User-Agent").unwrap();
567            assert_eq!(actual_headers[user_agent], HeaderValue::from_str("test-agent").unwrap());
568
569            async { "" }
570        });
571
572        let server_task = tokio::spawn(async move {
573            axum::serve(listener, http_handler.into_make_service()).await.unwrap()
574        });
575
576        let transport = RuntimeTransportBuilder::new(url.clone())
577            .with_headers(vec!["User-Agent: test-agent".to_string()])
578            .build();
579        let inner = transport.connect_http().unwrap();
580
581        match inner {
582            InnerTransport::Http(http) => {
583                let _ = http.client().get(url).send().await.unwrap();
584
585                // assert inside http_handler
586            }
587            _ => unreachable!(),
588        }
589
590        server_task.abort();
591    }
592}