Skip to main content

xmtp_api_grpc/grpc_client/
native.rs

1use crate::error::GrpcBuilderError;
2use http::Request;
3use std::collections::HashMap;
4use std::sync::{LazyLock, Mutex};
5use std::time::Duration;
6use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
7use tonic::{body::Body, client::GrpcService};
8use tower::Service;
9use url::Url;
10
11use std::task::{Context, Poll};
12
13/// HTTP/2 / TCP keep-alive parameters for the gRPC channel.
14///
15/// The previous 16s/10s defaults optimized for fast dead-path detection (~26s worst
16/// case) but tore down otherwise-healthy long-lived connections whenever one PING ack
17/// straggled past the tight deadline — reconnect churn observed from server (herald-lite
18/// #70) and mobile clients alike, where every teardown forces a full stream re-subscribe
19/// against the backend (2026-08 production incident). The defaults now trade detection
20/// speed for stability: ~65s worst case (interval + timeout), with the 45s cadence
21/// chosen to stay inside common 60s middlebox idle timers. Where the XIP-83 Subscribe
22/// ping/pong or the stream watchdog is enabled, the application layer detects a dead
23/// stream on its own; for default consumers this transport keep-alive remains the only
24/// dead-path detector, just a slower and less trigger-happy one. A deployment can tune
25/// any of these via environment variables, without affecting other consumers:
26///
27/// | env var                             | field                        | default |
28/// |-------------------------------------|------------------------------|---------|
29/// | `XMTP_GRPC_KEEPALIVE_INTERVAL_SECS` | `http2_keep_alive_interval`  | 45      |
30/// | `XMTP_GRPC_KEEPALIVE_TIMEOUT_SECS`  | `keep_alive_timeout`         | 20      |
31/// | `XMTP_GRPC_TCP_KEEPALIVE_SECS`      | `tcp_keepalive` (0 disables) | 45      |
32/// | `XMTP_GRPC_KEEPALIVE_WHILE_IDLE`    | `keep_alive_while_idle`      | true    |
33///
34/// Read once per process (servers set these before start), so it is not part of the TLS
35/// endpoint cache key.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37struct KeepaliveConfig {
38    interval: Duration,
39    timeout: Duration,
40    tcp_keepalive: Option<Duration>,
41    while_idle: bool,
42}
43
44impl KeepaliveConfig {
45    const DEFAULT_INTERVAL: Duration = Duration::from_secs(45);
46    const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20);
47    const DEFAULT_TCP_KEEPALIVE: Duration = Duration::from_secs(45);
48
49    fn from_env() -> Self {
50        Self::from_lookup(|key| std::env::var(key).ok())
51    }
52
53    /// Build from a generic lookup so the parsing is unit-testable without touching the
54    /// process environment.
55    fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Self {
56        let secs = |key: &str| {
57            get(key)
58                .and_then(|raw| raw.trim().parse::<u64>().ok())
59                .map(Duration::from_secs)
60        };
61        Self {
62            interval: secs("XMTP_GRPC_KEEPALIVE_INTERVAL_SECS").unwrap_or(Self::DEFAULT_INTERVAL),
63            timeout: secs("XMTP_GRPC_KEEPALIVE_TIMEOUT_SECS").unwrap_or(Self::DEFAULT_TIMEOUT),
64            // An explicit `0` disables TCP keep-alive; unset falls back to the default.
65            tcp_keepalive: match secs("XMTP_GRPC_TCP_KEEPALIVE_SECS") {
66                Some(d) if d.is_zero() => None,
67                Some(d) => Some(d),
68                None => Some(Self::DEFAULT_TCP_KEEPALIVE),
69            },
70            while_idle: get("XMTP_GRPC_KEEPALIVE_WHILE_IDLE")
71                .and_then(|raw| parse_bool(raw.trim()))
72                .unwrap_or(true),
73        }
74    }
75}
76
77fn parse_bool(raw: &str) -> Option<bool> {
78    match raw.to_ascii_lowercase().as_str() {
79        "1" | "true" | "yes" | "on" => Some(true),
80        "0" | "false" | "no" | "off" => Some(false),
81        _ => None,
82    }
83}
84
85/// Resolved once per process; servers set the env before start.
86static KEEPALIVE: LazyLock<KeepaliveConfig> = LazyLock::new(KeepaliveConfig::from_env);
87
88#[derive(Clone, Debug)]
89pub struct NativeGrpcService {
90    inner: Channel,
91}
92
93fn is_url_secure(url: &Url) -> bool {
94    matches!(url.scheme(), "https" | "grpcs")
95}
96
97impl NativeGrpcService {
98    pub fn new(host: url::Url, limit: Option<u64>) -> Result<Self, GrpcBuilderError> {
99        let channel = match is_url_secure(&host) {
100            true => create_tls_channel(host.into(), limit.unwrap_or(5000))?,
101            false => apply_channel_options(
102                Channel::from_shared(String::from(host))?,
103                limit.unwrap_or(5000),
104            )
105            .connect_lazy(),
106        };
107
108        Ok(Self { inner: channel })
109    }
110}
111
112impl Service<Request<Body>> for NativeGrpcService {
113    type Response = <Channel as Service<Request<Body>>>::Response;
114    type Error = <Channel as GrpcService<Body>>::Error;
115    type Future = <Channel as GrpcService<Body>>::Future;
116
117    fn poll_ready(&mut self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
118        <Channel as Service<Request<Body>>>::poll_ready(&mut self.inner, ctx)
119    }
120
121    fn call(&mut self, request: Request<Body>) -> Self::Future {
122        <Channel as Service<Request<Body>>>::call(&mut self.inner, request)
123    }
124}
125
126pub(crate) fn apply_channel_options(endpoint: Endpoint, limit: u64) -> Endpoint {
127    let keepalive = *KEEPALIVE;
128    endpoint
129        // Purpose: This setting controls the size of the initial connection-level flow control window for HTTP/2, which is the underlying protocol for gRPC.
130        // Functionality: Flow control in HTTP/2 manages how much data can be in flight on the network. Setting the initial connection window size to (1 << 31) - 1 (the maximum possible value for a 32-bit integer, which is 2,147,483,647 bytes) essentially allows the client to receive a very large amount of data from the server before needing to acknowledge receipt and permit more data to be sent. This can be particularly useful in high-latency networks or when transferring large amounts of data.
131        // Impact: Increasing the window size can improve throughput by allowing more data to be in transit at a time, but it may also increase memory usage and can potentially lead to inefficient use of bandwidth if the network is unreliable.
132        .initial_connection_window_size(Some((1 << 31) - 1))
133        // Purpose: Configures whether the client should send keep-alive pings to the server when the connection is idle.
134        // Functionality: When set to true, this option ensures that periodic pings are sent on an idle connection to keep it alive and detect if the server is still responsive.
135        // Impact: This helps maintain active connections, particularly through NATs, load balancers, and other middleboxes that might drop idle connections. It helps ensure that the connection is promptly usable when new requests need to be sent.
136        .keep_alive_while_idle(keepalive.while_idle)
137        // Purpose: Sets the maximum amount of time the client will wait for a connection to be established.
138        // Functionality: If a connection cannot be established within the specified duration, the attempt is aborted and an error is returned.
139        // Impact: This setting prevents the client from waiting indefinitely for a connection to be established, which is crucial in scenarios where rapid failure detection is necessary to maintain responsiveness or to quickly fallback to alternative services or retry logic.
140        .connect_timeout(Duration::from_secs(10))
141        // Purpose: Configures the TCP keep-alive interval for the socket connection.
142        // Functionality: This setting tells the operating system to send TCP keep-alive probes periodically when no data has been transferred over the connection within the specified interval.
143        // Impact: Similar to the gRPC-level keep-alive, this helps keep the connection alive at the TCP layer and detect broken connections. It's particularly useful for detecting half-open connections and ensuring that resources are not wasted on unresponsive peers.
144        .tcp_keepalive(keepalive.tcp_keepalive)
145        // Purpose: Sets a maximum duration for the client to wait for a response to a request.
146        // Functionality: If a response is not received within the specified timeout, the request is canceled and an error is returned.
147        // Impact: This is critical for bounding the wait time for operations, which can enhance the predictability and reliability of client interactions by avoiding indefinitely hanging requests.
148        .timeout(Duration::from_secs(120))
149        // Purpose: Specifies how long the client will wait for a response to a keep-alive ping before considering the connection dead.
150        // Functionality: If a ping response is not received within this duration, the connection is presumed to be lost and is closed.
151        // Impact: This setting is crucial for quickly detecting unresponsive connections and freeing up resources associated with them. It ensures that the client has up-to-date information on the status of connections and can react accordingly.
152        //
153        // Values are sourced from `KeepaliveConfig` (env-overridable). See the config's
154        // doc comment for why the defaults are unaggressive: a missed PING ack here kills
155        // the whole connection — and with it every bidi stream, whose reconnects replay
156        // server-side catch-up waves — so this layer must tolerate transient stalls that
157        // the app-level heartbeat is already equipped to judge.
158        .keep_alive_timeout(keepalive.timeout)
159        .http2_keep_alive_interval(keepalive.interval)
160        .rate_limit(limit, Duration::from_secs(60))
161}
162
163/// Cache of fully-built TLS endpoints, keyed by `(host, rate_limit)`.
164///
165/// Building the endpoint runs `ClientTlsConfig::with_enabled_roots()`, which
166/// makes tonic call `rustls_native_certs::load_native_certs()` and parse the
167/// whole OS trust store into a rustls `ClientConfig` on *every* call. On macOS
168/// that read is serialized through Security.framework (~40ms each), so callers
169/// that create many clients (e.g. loading 100 identities, each building an
170/// api + sync channel) paid it hundreds of times.
171///
172/// The built `Endpoint` owns an `Arc<ClientConfig>` with the parsed roots, so
173/// caching it and calling `connect_lazy()` on a clone pays that cost once per
174/// host while still handing every client its own connection (`connect_lazy`
175/// builds a fresh `Channel`). The cache key includes `limit` because it feeds
176/// the endpoint's rate-limit option.
177static TLS_ENDPOINTS: LazyLock<Mutex<HashMap<(String, u64), Endpoint>>> =
178    LazyLock::new(|| Mutex::new(HashMap::new()));
179
180#[tracing::instrument(level = "trace", skip_all)]
181pub fn create_tls_channel(address: String, limit: u64) -> Result<Channel, GrpcBuilderError> {
182    // Hold the lock across the (one-time, per-key) build so concurrent callers
183    // for the same host load native certs exactly once instead of racing.
184    let mut endpoints = TLS_ENDPOINTS.lock().unwrap_or_else(|e| e.into_inner());
185    let endpoint = match endpoints.get(&(address.clone(), limit)) {
186        Some(endpoint) => endpoint.clone(),
187        None => {
188            let endpoint = apply_channel_options(Channel::from_shared(address.clone())?, limit)
189                .tls_config(ClientTlsConfig::new().with_enabled_roots())?;
190            endpoints.insert((address, limit), endpoint.clone());
191            endpoint
192        }
193    };
194    Ok(endpoint.connect_lazy())
195}
196
197#[cfg(test)]
198mod keepalive_tests {
199    use super::*;
200    use std::collections::HashMap;
201
202    fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
203        let map: HashMap<String, String> = pairs
204            .iter()
205            .map(|(k, v)| (k.to_string(), v.to_string()))
206            .collect();
207        move |key: &str| map.get(key).cloned()
208    }
209
210    #[test]
211    fn defaults_when_env_absent() {
212        let cfg = KeepaliveConfig::from_lookup(|_| None);
213        assert_eq!(cfg.interval, Duration::from_secs(45));
214        assert_eq!(cfg.timeout, Duration::from_secs(20));
215        assert_eq!(cfg.tcp_keepalive, Some(Duration::from_secs(45)));
216        assert!(cfg.while_idle);
217    }
218
219    #[test]
220    fn env_overrides_are_applied() {
221        let cfg = KeepaliveConfig::from_lookup(lookup(&[
222            ("XMTP_GRPC_KEEPALIVE_INTERVAL_SECS", "30"),
223            ("XMTP_GRPC_KEEPALIVE_TIMEOUT_SECS", "45"),
224            ("XMTP_GRPC_TCP_KEEPALIVE_SECS", "30"),
225            ("XMTP_GRPC_KEEPALIVE_WHILE_IDLE", "false"),
226        ]));
227        assert_eq!(cfg.interval, Duration::from_secs(30));
228        assert_eq!(cfg.timeout, Duration::from_secs(45));
229        assert_eq!(cfg.tcp_keepalive, Some(Duration::from_secs(30)));
230        assert!(!cfg.while_idle);
231    }
232
233    #[test]
234    fn zero_tcp_keepalive_disables_it() {
235        let cfg = KeepaliveConfig::from_lookup(lookup(&[("XMTP_GRPC_TCP_KEEPALIVE_SECS", "0")]));
236        assert_eq!(cfg.tcp_keepalive, None);
237    }
238
239    #[test]
240    fn invalid_values_fall_back_to_defaults() {
241        let cfg = KeepaliveConfig::from_lookup(lookup(&[
242            ("XMTP_GRPC_KEEPALIVE_INTERVAL_SECS", "not-a-number"),
243            ("XMTP_GRPC_KEEPALIVE_WHILE_IDLE", "maybe"),
244        ]));
245        assert_eq!(cfg.interval, Duration::from_secs(45));
246        assert!(cfg.while_idle);
247    }
248}