Skip to main content

apollo_http_client/
client.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::{Arc, Mutex};
5use std::task::{Context, Poll};
6
7use apollo_opentelemetry::metrics::Clock;
8use bytes::Bytes;
9use http_body::Body;
10use http_body_util::BodyExt as _;
11use http_body_util::combinators::UnsyncBoxBody;
12use opentelemetry::{Context as OtelContext, KeyValue};
13use opentelemetry_semantic_conventions::attribute as semconv;
14use tower::util::BoxCloneSyncService;
15use tower::{BoxError, Service};
16
17use crate::builder::HttpClientBuilder;
18use crate::config::HttpClientConfig;
19use crate::error::HttpClientError;
20use crate::metrics::{HttpMetrics, RequestMetrics};
21use crate::protocol::{HostSender, HttpBody, Origin, ProtocolVersionCell};
22use crate::spans::HttpSpans;
23use apollo_http_shared::body::{BodySpanAttr, CountingBody};
24
25/// Mutable state shared across [`HttpClient`] clones.
26pub(crate) struct HttpClientState {
27    pub(crate) pools: HashMap<Origin, HostSender>,
28    // `Sync` is required so `HttpClientState` (and transitively the
29    // `BoxCloneSyncService` wrapping the dispatch service) satisfies
30    // `Sync` — which is what makes `HttpClient: Sync`.
31    pub(crate) make_sender: Box<dyn Fn(Origin) -> HostSender + Send + Sync>,
32}
33
34impl HttpClientState {
35    /// Returns the [`HostSender`] for `key`, creating and caching it on first use.
36    fn get_or_create(&mut self, origin: Origin) -> HostSender {
37        self.pools
38            .entry(origin)
39            .or_insert_with_key(|key| (self.make_sender)(key.clone()))
40            .clone()
41    }
42}
43
44fn extract_pool_key(uri: &http::Uri) -> Result<Origin, HttpClientError> {
45    match (uri.scheme().cloned(), uri.authority().cloned()) {
46        (Some(scheme), Some(authority)) => Ok(Origin { scheme, authority }),
47        _ => {
48            // Produce an http::Error by parsing an invalid (empty) URI — the
49            // error type is opaque and has no public constructor, so this is
50            // the only way to obtain one for a missing scheme/authority.
51            let err: http::uri::InvalidUri = http::Uri::try_from("").unwrap_err();
52            Err(HttpClientError::InvalidUri { source: err.into() })
53        }
54    }
55}
56
57/// Non-Unix stub for the `unix://` slot in [`HttpClientState::make_sender`].
58/// Every dispatched request fails with [`HttpClientError::UnsupportedScheme`].
59#[cfg(not(unix))]
60pub(crate) fn unsupported_scheme_sender() -> HostSender {
61    Arc::new(|req, _| {
62        let scheme = req.uri().scheme_str().unwrap_or("").to_owned();
63        Box::pin(async move { Err(HttpClientError::UnsupportedScheme { scheme }) })
64    })
65}
66
67/// Guards held until the response body is consumed, ensuring spans and metrics cover
68/// the full response transfer per OTel HTTP semantic conventions.
69struct ClientGuards {
70    _span_cx: OtelContext,
71    _req_metrics: RequestMetrics,
72}
73
74/// The inner service that handles connection pool dispatch, metrics, and span attribute recording.
75#[derive(Clone)]
76pub(crate) struct HttpClientDispatch {
77    pub(crate) state: Arc<Mutex<HttpClientState>>,
78    pub(crate) metrics: HttpMetrics,
79    pub(crate) spans: HttpSpans,
80    pub(crate) configured_protocol_version: http::Version,
81}
82
83type HttpClientDispatchFuture = Pin<
84    Box<
85        dyn Future<Output = Result<http::Response<UnsyncBoxBody<Bytes, BoxError>>, HttpClientError>>
86            + Send,
87    >,
88>;
89
90impl Service<http::Request<HttpBody>> for HttpClientDispatch {
91    type Response = http::Response<UnsyncBoxBody<Bytes, BoxError>>;
92    type Error = HttpClientError;
93    type Future = HttpClientDispatchFuture;
94
95    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
96        Poll::Ready(Ok(()))
97    }
98
99    fn call(&mut self, req: http::Request<HttpBody>) -> Self::Future {
100        let state = self.state.clone();
101        let metrics = self.metrics.clone();
102        let spans = self.spans.clone();
103        let protocol_version = ProtocolVersionCell::new(self.configured_protocol_version);
104
105        Box::pin(async move {
106            // TracedLayer wraps this future with LazySpanFuture, which starts the span and
107            // attaches it before polling this block. OtelContext::current() returns the span,
108            // and inject_current_context propagates it into the outgoing request headers.
109            let span_cx = OtelContext::current();
110            let key = extract_pool_key(req.uri())?;
111            let per_host = state.lock().unwrap().get_or_create(key.clone());
112            let method = req.method().clone();
113
114            let (mut req_metrics, req_body_size) =
115                metrics.begin_request(&method, &key, protocol_version.clone());
116
117            let req_span_attr = spans.request_body_size().then(|| {
118                let attr = BodySpanAttr::new(semconv::HTTP_REQUEST_BODY_SIZE);
119                attr.bind(span_cx.clone());
120                attr
121            });
122            let req = req.map(|body| {
123                CountingBody::new(
124                    body,
125                    req_body_size.clone(),
126                    (),
127                    protocol_version.clone(),
128                    req_span_attr,
129                )
130                .boxed()
131            });
132
133            match per_host(req, protocol_version.clone()).await {
134                Ok(resp) => {
135                    let status = resp.status();
136                    req_metrics.on_success(status);
137                    req_body_size.set(KeyValue::new(
138                        semconv::HTTP_RESPONSE_STATUS_CODE,
139                        i64::from(status.as_u16()),
140                    ));
141                    spans.on_response(protocol_version.get(), &key, status, resp.headers());
142
143                    let resp_body_size = metrics.begin_response(&method, &key, status);
144
145                    let resp_span_attr = spans.response_body_size().then(|| {
146                        let attr = BodySpanAttr::new(semconv::HTTP_RESPONSE_BODY_SIZE);
147                        attr.bind(span_cx.clone());
148                        attr
149                    });
150                    let guards = ClientGuards {
151                        _span_cx: span_cx,
152                        _req_metrics: req_metrics,
153                    };
154                    let response = resp.map(|body| {
155                        CountingBody::new(
156                            body,
157                            resp_body_size,
158                            guards,
159                            protocol_version.clone(),
160                            resp_span_attr,
161                        )
162                        .boxed_unsync()
163                    });
164                    Ok(response)
165                }
166                Err(e) => {
167                    req_metrics.on_error();
168                    req_body_size.set(KeyValue::new(semconv::ERROR_TYPE, "_OTHER"));
169                    spans.on_error(&key, &e.to_string());
170                    Err(e)
171                }
172            }
173        })
174    }
175}
176
177/// A Tower [`Service`] that sends HTTP requests to upstream services.
178///
179/// The transport is picked from the request URI: `http://` and `https://` go
180/// through TCP, `unix://` through a Unix domain socket (Unix targets only).
181/// The HTTP version inside that transport is configured via [`HttpClientConfig`].
182///
183/// `HttpClient` is cheap to clone — all clones share the same underlying connections
184/// and configuration.
185///
186/// # Example
187/// ```rust,no_run
188/// use apollo_http_client::{HttpClient, HttpClientConfig};
189/// use http_body_util::Empty;
190/// use bytes::Bytes;
191/// use tower::ServiceExt;
192///
193/// # #[tokio::main]
194/// # async fn main() {
195/// let config = HttpClientConfig::default();
196/// let client = HttpClient::new(&config).expect("valid config");
197///
198/// let req = http::Request::builder()
199///     .method("GET")
200///     .uri("https://api.example.com/")
201///     .body(Empty::<Bytes>::new())
202///     .unwrap();
203///
204/// let _resp = client.oneshot(req).await.expect("request ok");
205/// # }
206/// ```
207///
208/// [`Service`]: tower::Service
209pub struct HttpClient {
210    inner: BoxCloneSyncService<
211        http::Request<HttpBody>,
212        http::Response<UnsyncBoxBody<Bytes, BoxError>>,
213        HttpClientError,
214    >,
215}
216
217impl Clone for HttpClient {
218    fn clone(&self) -> Self {
219        Self {
220            inner: self.inner.clone(),
221        }
222    }
223}
224
225impl HttpClient {
226    /// Creates a new [`HttpClient`].
227    ///
228    /// Returns an error if the TLS connector cannot be constructed.
229    pub fn new(config: &HttpClientConfig) -> Result<Self, HttpClientError> {
230        HttpClientBuilder::new(config.clone()).build()
231    }
232
233    /// Creates a new [`HttpClient`] with a custom clock. For testing only.
234    #[doc(hidden)]
235    pub fn with_clock(config: &HttpClientConfig, clock: Clock) -> Result<Self, HttpClientError> {
236        HttpClientBuilder::new(config.clone()).build_with_clock(clock)
237    }
238
239    /// Returns a builder for constructing an [`HttpClient`] with additional options.
240    ///
241    /// # Example
242    /// ```rust,no_run
243    /// use std::sync::Arc;
244    /// use apollo_http_client::{HttpClient, HttpClientConfig};
245    ///
246    /// # fn main() -> Result<(), apollo_http_client::HttpClientError> {
247    /// let tls: Arc<rustls::ClientConfig> = todo!("build your ClientConfig");
248    /// let client = HttpClient::builder(HttpClientConfig::default())
249    ///     .with_tls_config(tls)
250    ///     .build()?;
251    /// # Ok(())
252    /// # }
253    /// ```
254    pub fn builder(config: HttpClientConfig) -> HttpClientBuilder {
255        HttpClientBuilder::new(config)
256    }
257
258    pub(crate) fn from_service(
259        inner: BoxCloneSyncService<
260            http::Request<HttpBody>,
261            http::Response<UnsyncBoxBody<Bytes, BoxError>>,
262            HttpClientError,
263        >,
264    ) -> Self {
265        Self { inner }
266    }
267}
268
269impl<B> Service<http::Request<B>> for HttpClient
270where
271    B: Body<Data = Bytes> + Send + Sync + 'static,
272    B::Error: Into<BoxError> + Send + Sync + 'static,
273{
274    type Response = http::Response<UnsyncBoxBody<Bytes, BoxError>>;
275    type Error = HttpClientError;
276    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
277
278    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
279        self.inner.poll_ready(cx)
280    }
281
282    /// Sends an HTTP request to the URI specified in the request.
283    ///
284    /// The URI must be absolute, including scheme and host. `http://`, `https://`, and (on Unix
285    /// targets) `unix://` route to the matching transport. A path-only URI like
286    /// `"/v1/data"` returns [`HttpClientError::Request`](crate::HttpClientError::Request).
287    ///
288    /// On non-Unix targets, `unix://` returns
289    /// [`HttpClientError::UnsupportedScheme`](crate::HttpClientError::UnsupportedScheme).
290    fn call(&mut self, req: http::Request<B>) -> Self::Future {
291        let req = req.map(|body| body.map_err(Into::into).boxed());
292        self.inner.call(req)
293    }
294}
295
296/// Compile-time assertion that [`HttpClient`] is `Send + Sync` so consumers can
297/// hold it inside `Arc<_>` or hand it to APIs that require both auto-traits
298/// (e.g. AWS Smithy's `HttpConnector`).
299const _: fn() = || {
300    fn assert_send_sync<T: Send + Sync>() {}
301    assert_send_sync::<HttpClient>();
302};
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use apollo_opentelemetry_test::{TelemetryContext, assert_metrics_snapshot};
308    use bytes::Bytes;
309    use http_body_util::Full;
310    use tower::ServiceExt as _;
311
312    #[test]
313    fn extract_pool_key_valid_uri() {
314        let uri: http::Uri = "https://example.com/path".parse().unwrap();
315        let Origin { scheme, authority } = extract_pool_key(&uri).unwrap();
316        assert_eq!(scheme, http::uri::Scheme::HTTPS);
317        assert_eq!(authority.host(), "example.com");
318    }
319
320    #[test]
321    fn extract_pool_key_relative_uri_returns_invalid_uri_error() {
322        let uri: http::Uri = "/path/only".parse().unwrap();
323        let err = extract_pool_key(&uri).unwrap_err();
324        assert!(matches!(err, HttpClientError::InvalidUri { .. }));
325    }
326
327    #[test]
328    fn new_with_default_config_succeeds() {
329        let config = HttpClientConfig::default();
330        assert!(HttpClient::new(&config).is_ok());
331    }
332
333    #[test]
334    fn new_returns_invalid_config_when_proxy_url_skips_validation() {
335        // Construct a `HttpClientConfig` without going through `parse_yaml`, so
336        // validation is bypassed. The proxy URL parses (every absolute URL does)
337        // but has no host — exactly the case the validator catches.
338        use crate::config::{ProxyConfig, ProxyUrl};
339        let url: apollo_configuration::types::Url =
340            "file:///not-a-network-url".parse().expect("parse");
341        let config = HttpClientConfig {
342            proxy: Some(ProxyConfig {
343                url: ProxyUrl::new(url),
344            }),
345            ..HttpClientConfig::default()
346        };
347
348        let err = HttpClient::new(&config)
349            .err()
350            .expect("hostless proxy URL must be rejected");
351        assert!(
352            matches!(err, HttpClientError::InvalidConfig { .. }),
353            "expected InvalidConfig, got {err:?}"
354        );
355    }
356
357    #[tokio::test]
358    async fn records_error_metric_on_failed_request() {
359        let ctx = TelemetryContext::new();
360
361        let config: HttpClientConfig =
362            apollo_configuration::parse_yaml("connect_timeout: 1s", &Default::default())
363                .expect("valid config");
364
365        let (clock, _mock) = Clock::mock();
366        let client = HttpClient::with_clock(&config, clock).expect("valid config");
367        let req = http::Request::builder()
368            .method(http::Method::GET)
369            .uri("http://127.0.0.1:1")
370            .body(
371                Full::new(Bytes::new())
372                    .map_err(|never: std::convert::Infallible| match never {})
373                    .boxed(),
374            )
375            .unwrap();
376
377        let _ = client.oneshot(req).await;
378
379        assert_metrics_snapshot!(ctx, @r#"
380        - name: http.client.active_requests
381          description: Number of HTTP requests currently in flight
382          unit: "{request}"
383          data:
384            type: Sum
385            data_points:
386              - attributes:
387                  http.request.method: GET
388                  server.address: 127.0.0.1
389                  server.port: "1"
390                value: 0
391            is_monotonic: false
392            temporality: Cumulative
393        - name: http.client.request.duration
394          description: Duration of HTTP client requests
395          unit: s
396          data:
397            type: Histogram
398            data_points:
399              - attributes:
400                  error.type: _OTHER
401                  http.request.method: GET
402                  network.protocol.version: "1.1"
403                  server.address: 127.0.0.1
404                  server.port: "1"
405                count: 1
406                sum: 0
407                min: 0
408                max: 0
409                bounds:
410                  - 0.005
411                  - 0.01
412                  - 0.025
413                  - 0.05
414                  - 0.075
415                  - 0.1
416                  - 0.25
417                  - 0.5
418                  - 0.75
419                  - 1
420                  - 2.5
421                  - 5
422                  - 7.5
423                  - 10
424                bucket_counts:
425                  - 1
426                  - 0
427                  - 0
428                  - 0
429                  - 0
430                  - 0
431                  - 0
432                  - 0
433                  - 0
434                  - 0
435                  - 0
436                  - 0
437                  - 0
438                  - 0
439                  - 0
440            temporality: Cumulative
441        "#);
442    }
443}