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}