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}