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}