Skip to main content

apollo_http_client/
lib.rs

1//! HTTP client for Apollo platform services.
2//!
3//! [`HttpClient`] is a Tower [`Service`]: HTTP/1.1 and HTTP/2 over TCP and Unix
4//! domain sockets. Configure via [`HttpClientConfig`].
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use apollo_http_client::{HttpClient, HttpClientConfig};
10//! use http_body_util::Full;
11//! use bytes::Bytes;
12//! use tower::ServiceExt;
13//!
14//! # #[tokio::main]
15//! # async fn main() {
16//! let client = HttpClient::new(&HttpClientConfig::default()).expect("valid config");
17//!
18//! let req = http::Request::builder()
19//!     .method("POST")
20//!     .uri("https://api.example.com/")
21//!     .body(Full::new(Bytes::from_static(b"payload")))
22//!     .unwrap();
23//!
24//! let _resp = client.oneshot(req).await.expect("request ok");
25//! # }
26//! ```
27//!
28//! # Protocol selection
29//!
30//! HTTP/1.1 is the default. Set `protocol: http2` when the server requires
31//! HTTP/2, such as gRPC endpoints. Set `protocol: alpn` to negotiate the
32//! version via the TLS handshake. Plain HTTP and `unix://` connections have
33//! no TLS and always use HTTP/1.1.
34//!
35//! Unlike reqwest, which negotiates HTTP/1.1 or HTTP/2 automatically under
36//! HTTPS, this client defaults to HTTP/1.1 and requires an explicit
37//! [`Protocol`] setting for HTTP/2 or negotiation.
38//!
39//! # TLS
40//!
41//! Configure trust anchors and client identity under `tls`. The example below
42//! trusts a private CA bundle in addition to the OS native store and presents
43//! a client certificate for mutual TLS:
44//!
45//! ```yaml
46//! tls:
47//!   # PEM bundle of CA certificates to trust. Multiple certificates may be
48//!   # concatenated. Loaded from disk via apollo-configuration file expansion.
49//!   certificate_authorities: ${file:./certs/internal-ca.pem}
50//!
51//!   # Trust the OS native certificate store in addition to the supplied CAs.
52//!   # Set false to trust only the supplied CAs (air-gapped environments).
53//!   use_native_certificate_store: true
54//!
55//!   client_authentication:
56//!     # PEM chain: leaf certificate followed by intermediates.
57//!     certificate_chain: ${file:./certs/client.pem}
58//!     # PEM-encoded unencrypted private key (PKCS#1, PKCS#8, or SEC1).
59//!     # Encrypted keys are not supported.
60//!     key: ${file:./certs/client.key}
61//! ```
62//!
63//! Invalid PEM, empty bundles, encrypted keys, and configurations with no
64//! trust anchors all fail at [`HttpClient::new`] with a dedicated
65//! [`HttpClientError`] variant naming the offending field.
66//!
67//! To supply a pre-built [`rustls::ClientConfig`] instead, for example, a
68//! FIPS-compliant config or one backed by a custom certificate store, use
69//! [`HttpClient::builder`]:
70//!
71//! ```rust,no_run
72//! use std::sync::Arc;
73//! use apollo_http_client::{HttpClient, HttpClientConfig};
74//!
75//! # fn main() -> Result<(), apollo_http_client::HttpClientError> {
76//! let tls: Arc<rustls::ClientConfig> = todo!("build your ClientConfig");
77//! let client = HttpClient::builder(HttpClientConfig::default())
78//!     .with_tls_config(tls)
79//!     .build()?;
80//! # Ok(())
81//! # }
82//! ```
83//!
84//! # Unix domain sockets
85//!
86//! On Unix targets, `unix://` URIs dispatch over Unix domain sockets. Build the
87//! request URI with [`unix_uri`]:
88//!
89//! ```rust,no_run
90//! # #[cfg(not(unix))]
91//! # fn main() {}
92//! # #[cfg(unix)]
93//! # #[tokio::main]
94//! # async fn main() {
95//! use apollo_http_client::{HttpClient, HttpClientConfig, unix_uri};
96//! use http_body_util::Empty;
97//! use bytes::Bytes;
98//! use tower::ServiceExt;
99//!
100//! let client = HttpClient::new(&HttpClientConfig::default()).expect("valid config");
101//!
102//! let uri = unix_uri("/var/run/app.sock")
103//!     .expect("valid path")
104//!     .path_and_query("/health")
105//!     .build()
106//!     .expect("uri builds");
107//!
108//! let req = http::Request::builder().uri(uri).body(Empty::<Bytes>::new()).unwrap();
109//!
110//! let _resp = client.oneshot(req).await.expect("request ok");
111//! # }
112//! ```
113//!
114//! `HttpClientConfig::protocol` selects HTTP/1.1 or HTTP/2 over the socket the
115//! same way it does over TCP. ALPN has no TLS to negotiate over and falls back
116//! to HTTP/1.1. Proxy configuration applies only to TCP.
117//!
118//! On non-Unix targets, [`HttpClient::new`] still builds; sending a `unix://`
119//! request returns [`HttpClientError::UnsupportedScheme`].
120//!
121//! # Metrics
122//!
123//! Every request emits the following OpenTelemetry metrics:
124//!
125//! | Metric | Description | Key attributes |
126//! |---|---|---|
127//! | `http.client.active_requests` | In-flight request count | method, server.address, server.port |
128//! | `http.client.request.duration` | Request latency | method, server.address, server.port, protocol.version, status or error.type |
129//! | `http.client.request.body.size` | Bytes sent (opt-in) | method, server.address, server.port, protocol.version, status |
130//! | `http.client.response.body.size` | Bytes received (opt-in) | method, server.address, server.port, status |
131//! | `http.client.open_connections` | Open connection gauge | server.address, server.port, protocol.version, http.connection.state |
132//! | `http.client.connection.duration` | Connection lifetime | server.address, server.port, protocol.version |
133//!
134//! For `unix://` requests, `server.address` carries the socket path.
135//!
136//! When a proxy is configured, connection-state metrics (`http.client.open_connections`,
137//! `http.client.connection.duration`) also include `network.peer.address` and
138//! `network.peer.port`, set to the proxy host and port.
139//!
140//! All metrics also include `url.scheme` when `telemetry.metrics.url_scheme` is enabled.
141//!
142//! # Span attributes
143//!
144//! Follows the [OTel HTTP client span semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/).
145//!
146//! **Always recorded:**
147//!
148//! - `http.request.method` — `_OTHER` for non-standard methods, with the original verb
149//!   preserved in `http.request.method_original`
150//! - `url.full`
151//! - `server.address`
152//! - `server.port` — absent for `unix://`
153//! - `http.response.status_code`
154//! - `network.protocol.version`
155//! - `error.type` — 4xx/5xx/transport
156//!
157//! **Opt-in** (disabled by default, configure under `telemetry.spans`):
158//!
159//! | Attribute | Setting |
160//! |-----------|---------|
161//! | `url.scheme` | `url_scheme` |
162//! | `http.request.body.size` | `request_body_size` |
163//! | `http.response.body.size` | `response_body_size` |
164//! | `user_agent.original` | `user_agent` |
165//! | `network.transport` | `network_transport` (always `"tcp"`) |
166//! | `network.local.address` | `network_local_address` (static value, set at startup) |
167//! | `network.local.port` | `network_local_port` (static value, set at startup) |
168//! | `http.request.header.<name>` | `request_headers` |
169//! | `http.response.header.<name>` | `response_headers` |
170//!
171//! **Not supported:** `url.template`, `http.request.size`, `http.response.size`,
172//! `network.peer.address`, `network.peer.port`, `user_agent.synthetic.type`.
173//!
174//! [`Service`]: tower::Service
175
176#![forbid(unsafe_code)]
177#![warn(missing_docs)]
178
179mod builder;
180mod client;
181mod config;
182mod error;
183mod metrics;
184mod protocol;
185mod proxy;
186mod spans;
187mod tls;
188
189pub use apollo_configuration::types::HeaderName;
190pub use builder::HttpClientBuilder;
191pub use client::HttpClient;
192pub use config::{
193    ClientAuthentication, Http1Config, Http2Config, HttpClientConfig, MetricsConfig, Protocol,
194    ProxyConfig, ProxyUrl, SpansConfig, TcpConfig, TelemetryConfig, TlsConfig,
195};
196pub use error::HttpClientError;
197pub use protocol::HttpBody;
198#[cfg(unix)]
199pub use protocol::unix::unix_uri;
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn config_parses_from_yaml() {
207        let config: HttpClientConfig = apollo_configuration::parse_yaml(
208            r#"
209            connect_timeout: 5s
210            http1:
211              pool_max_idle_per_host: 20
212              pool_idle_timeout: 90s
213            http2:
214              connection_idle_timeout: 5m
215              keep_alive_interval: 30s
216              keep_alive_while_idle: true
217            tcp:
218              nodelay: false
219              keepalive: 30s
220            tls:
221              danger_accept_invalid_certs: false
222            "#,
223            &Default::default(),
224        )
225        .expect("should parse");
226
227        assert_eq!(*config.connect_timeout, std::time::Duration::from_secs(5));
228        assert_eq!(config.http1.pool_max_idle_per_host.get(), 20);
229        assert_eq!(
230            *config.http1.pool_idle_timeout,
231            std::time::Duration::from_secs(90)
232        );
233        assert_eq!(
234            *config.http2.connection_idle_timeout,
235            std::time::Duration::from_secs(300)
236        );
237        assert_eq!(
238            *config.http2.keep_alive_interval,
239            std::time::Duration::from_secs(30)
240        );
241        assert!(config.http2.keep_alive_while_idle);
242        assert!(!config.tcp.nodelay);
243        assert_eq!(*config.tcp.keepalive, std::time::Duration::from_secs(30));
244        assert!(!config.tls.danger_accept_invalid_certs);
245    }
246
247    #[test]
248    fn config_defaults_are_sensible() {
249        let config = HttpClientConfig::default();
250        assert_eq!(*config.connect_timeout, std::time::Duration::from_secs(10));
251        assert_eq!(config.http1.pool_max_idle_per_host.get(), 10);
252        assert_eq!(
253            *config.http1.pool_idle_timeout,
254            std::time::Duration::from_secs(90)
255        );
256        assert_eq!(
257            *config.http2.connection_idle_timeout,
258            std::time::Duration::from_secs(300)
259        );
260        assert_eq!(
261            *config.http2.keep_alive_interval,
262            std::time::Duration::from_secs(30)
263        );
264        assert!(config.http2.keep_alive_while_idle);
265        assert!(config.tcp.nodelay);
266        assert_eq!(*config.tcp.keepalive, std::time::Duration::from_secs(60));
267        assert!(!config.tls.danger_accept_invalid_certs);
268    }
269
270    #[test]
271    fn certificate_authorities_is_redacted_in_debug_output() {
272        let pem = "-----BEGIN CERTIFICATE-----\nSUPERSECRETPEMCONTENTS\n-----END CERTIFICATE-----";
273        let yaml = format!(
274            "tls:\n  certificate_authorities: |\n    {}\n",
275            pem.replace('\n', "\n    "),
276        );
277        let config: HttpClientConfig =
278            apollo_configuration::parse_yaml(&yaml, &Default::default()).expect("valid config");
279
280        let debug = format!("{:?}", config.tls.certificate_authorities);
281        assert!(
282            !debug.contains("SUPERSECRETPEMCONTENTS"),
283            "raw PEM leaked through Debug: {debug}"
284        );
285        assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
286    }
287
288    #[test]
289    fn client_authentication_fields_are_redacted_in_debug_output() {
290        let yaml = "\
291tls:
292  client_authentication:
293    certificate_chain: |
294      -----BEGIN CERTIFICATE-----
295      CLIENTCERTSECRET
296      -----END CERTIFICATE-----
297    key: |
298      -----BEGIN PRIVATE KEY-----
299      CLIENTKEYSECRET
300      -----END PRIVATE KEY-----
301";
302        let config: HttpClientConfig =
303            apollo_configuration::parse_yaml(yaml, &Default::default()).expect("valid config");
304
305        let auth = config
306            .tls
307            .client_authentication
308            .as_ref()
309            .expect("client_authentication should parse");
310        let debug = format!("{:?}", auth);
311        assert!(
312            !debug.contains("CLIENTCERTSECRET"),
313            "raw cert leaked through Debug: {debug}"
314        );
315        assert!(
316            !debug.contains("CLIENTKEYSECRET"),
317            "raw key leaked through Debug: {debug}"
318        );
319        assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
320    }
321
322    #[test]
323    fn json_schema_snapshot() {
324        insta::assert_json_snapshot!(
325            apollo_configuration::export_json_schema::<HttpClientConfig>()
326        );
327    }
328
329    #[test]
330    fn proxy_config_rejects_unknown_fields() {
331        let err = apollo_configuration::parse_yaml::<HttpClientConfig>(
332            r#"
333            proxy:
334              url: http://proxy.example.com:3128
335              typo_field: value
336            "#,
337            &Default::default(),
338        )
339        .expect_err("unknown fields under `proxy` should be rejected");
340        let rendered = format!("{err:?}");
341        assert!(
342            rendered.contains("typo_field"),
343            "error should mention the unknown field, got: {rendered}"
344        );
345    }
346
347    #[test]
348    fn proxy_config_rejects_invalid_url() {
349        apollo_configuration::parse_yaml::<HttpClientConfig>(
350            r#"
351            proxy:
352              url: "not-a-url"
353            "#,
354            &Default::default(),
355        )
356        .expect_err("malformed URL should fail to parse");
357    }
358
359    #[test]
360    fn proxy_config_rejects_unsupported_scheme() {
361        let err = apollo_configuration::parse_yaml::<HttpClientConfig>(
362            r#"
363            proxy:
364              url: "socks5://proxy.corp.example.com:1080"
365            "#,
366            &Default::default(),
367        )
368        .expect_err("non-http(s) proxy scheme should fail validation");
369        let rendered = format!("{err:?}");
370        assert!(
371            rendered.contains("socks5") || rendered.contains("http or https"),
372            "error should explain the unsupported scheme, got: {rendered}"
373        );
374    }
375
376    #[test]
377    fn proxy_config_accepts_credentials_without_exposing_password() {
378        let config: HttpClientConfig = apollo_configuration::parse_yaml(
379            r#"
380            proxy:
381              url: "http://alice:s3cr3t@proxy.corp.example.com:3128"
382            "#,
383            &Default::default(),
384        )
385        .expect("valid config");
386
387        let proxy = config.proxy.as_ref().expect("proxy set");
388        let debug = format!("{:?}", proxy.url);
389        assert!(
390            !debug.contains("s3cr3t"),
391            "password must not appear in Debug output: {debug}"
392        );
393        assert!(
394            debug.contains("alice"),
395            "username is visible in Debug output: {debug}"
396        );
397    }
398}