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