Skip to main content

tsoracle_client/
transport.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24//! Transport plumbing for the channel pool.
25//!
26//! `normalize_uri` enforces the scheme rule: bare `host:port` becomes
27//! `http://host:port` or `https://host:port` depending on whether a TLS
28//! transport is configured; explicit schemes are always preserved.
29//!
30//! "Explicit beats configured" governs *operator-supplied* endpoint
31//! strings — those passed to `ClientBuilder::endpoints`. The client applies
32//! a tighter rule (in `crate::leader_hint`) to *wire-supplied*
33//! `tsoracle-leader-hint-bin` trailers under `tls_config`: explicit
34//! `http://...` hints are dropped so a contacted peer cannot downgrade the
35//! transport. See `crate::leader_hint::rejects_plaintext_hint`.
36
37use std::future::Future;
38use std::pin::Pin;
39use std::time::Duration;
40use tonic::transport::Channel;
41
42use crate::RetryPolicy;
43use crate::error::ClientError;
44
45/// Boxed error returned by user-supplied connector closures.
46pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
47
48/// HTTP/2 keepalive ping interval. Hardcoded rather than exposed on
49/// [`RetryPolicy`] because no realistic deployment needs to tune it
50/// independently of the per-attempt deadline — 30 s is well below the
51/// idle-connection cull window of common L4 load balancers and NATs
52/// (AWS NLB: 350 s, AWS ALB: 60 s default, GCP: 600 s, conntrack:
53/// 432 000 s but earlier eviction under pressure). Callers needing a
54/// different value can use [`crate::ClientBuilder::channel_connector`]
55/// and build the `Endpoint` themselves.
56pub(crate) const HTTP2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
57
58/// Apply the four `Endpoint` knobs the [`RetryPolicy`] dictates:
59/// `connect_timeout`, `timeout` (both seeded from
60/// `per_attempt_deadline`), `keep_alive_while_idle(true)`, and
61/// `http2_keep_alive_interval`. Called from the built-in default and
62/// built-in TLS paths so a blackholed peer surfaces a tonic transport
63/// error within `per_attempt_deadline` instead of parking on the
64/// OS-default TCP timeout. User-supplied
65/// [`crate::ClientBuilder::channel_connector`] closures own their own
66/// `Endpoint` config and do not go through this helper.
67pub(crate) fn apply_endpoint_config(
68    endpoint: tonic::transport::Endpoint,
69    policy: &RetryPolicy,
70) -> tonic::transport::Endpoint {
71    endpoint
72        .connect_timeout(policy.per_attempt_deadline)
73        .timeout(policy.per_attempt_deadline)
74        .keep_alive_while_idle(true)
75        .http2_keep_alive_interval(HTTP2_KEEPALIVE_INTERVAL)
76}
77
78/// Stored channel-construction strategy, shared by the built-in TLS path
79/// and any user-supplied closure. Errors are normalized to `ClientError`
80/// before storage so the pool's execution path is a single `await?`.
81pub(crate) type ChannelConnector = dyn Fn(&str) -> Pin<Box<dyn Future<Output = Result<Channel, ClientError>> + Send>>
82    + Send
83    + Sync;
84
85/// Apply the scheme rule to an endpoint string.
86///
87/// - Explicit `http://...` and `https://...` are returned verbatim.
88/// - Bare `host:port` becomes `http://host:port` when `tls` is false,
89///   `https://host:port` when `tls` is true.
90///
91/// "Explicit beats configured" is universal: callers wanting plaintext on a
92/// per-endpoint basis even when a TLS transport is configured can pass
93/// `http://host:port` and the rule returns it untouched.
94pub(crate) fn normalize_uri(endpoint: &str, tls: bool) -> String {
95    if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
96        endpoint.to_string()
97    } else if tls {
98        format!("https://{endpoint}")
99    } else {
100        format!("http://{endpoint}")
101    }
102}
103
104/// Construct the built-in TLS-aware channel connector.
105///
106/// Bare endpoints are rewritten to `https://` via [`normalize_uri`].
107/// Explicit `http://...` endpoints supplied via
108/// `ClientBuilder::endpoints` are honored as plaintext even when this
109/// connector is in use ("explicit beats configured"). The TLS config is
110/// attached only when the resolved URI uses the `https` scheme.
111///
112/// Explicit `http://...` endpoints arriving via the
113/// `tsoracle-leader-hint-bin` trailer are filtered out one layer up in
114/// `crate::retry::issue_rpc` and never reach this connector — they would
115/// otherwise dial plaintext here, downgrading a TLS-configured client.
116#[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
117pub(crate) fn tls_connector(
118    cfg: tonic::transport::ClientTlsConfig,
119    policy: RetryPolicy,
120) -> std::sync::Arc<ChannelConnector> {
121    use tonic::transport::Endpoint;
122    std::sync::Arc::new(move |endpoint: &str| {
123        let uri = normalize_uri(endpoint, true);
124        let cfg = cfg.clone();
125        let policy = policy.clone();
126        let endpoint_owned = endpoint.to_string();
127        Box::pin(async move {
128            let ep: Endpoint = uri
129                .parse()
130                .map_err(|_| ClientError::InvalidEndpoint(endpoint_owned))?;
131            let ep = if ep.uri().scheme_str() == Some("https") {
132                ep.tls_config(cfg).map_err(ClientError::from)?
133            } else {
134                ep
135            };
136            let ep = apply_endpoint_config(ep, &policy);
137            let channel = ep.connect().await.map_err(ClientError::from)?;
138            Ok(channel)
139        })
140    })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn bare_endpoint_without_tls_yields_http() {
149        assert_eq!(normalize_uri("host:1", false), "http://host:1");
150    }
151
152    #[test]
153    fn bare_endpoint_with_tls_yields_https() {
154        assert_eq!(normalize_uri("host:1", true), "https://host:1");
155    }
156
157    #[test]
158    fn explicit_http_preserved_under_tls() {
159        assert_eq!(normalize_uri("http://host:1", true), "http://host:1");
160    }
161
162    #[test]
163    fn explicit_https_preserved_without_tls() {
164        assert_eq!(normalize_uri("https://host:1", false), "https://host:1");
165    }
166}