apollo-http-client 0.3.0

HTTP client for Apollo platform
Documentation
use apollo_configuration::ErrorCollector;
use apollo_configuration::Validate;
use apollo_configuration::configuration;
use apollo_configuration::types::{Duration, HeaderName, Url};
use apollo_redaction::Redacted;
use apollo_redaction::strategy::UrlPasswordRedactor;
use std::num::NonZeroUsize;
use std::time::Duration as StdDuration;

/// A proxy URL with the password component redacted in `Debug` and `Display` output.
pub type ProxyUrl = Redacted<Url, UrlPasswordRedactor>;

/// HTTP protocol version to use for connections.
#[non_exhaustive]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Protocol {
    /// HTTP/1.1 only (default).
    #[default]
    Http1,
    /// HTTP/2 only.
    ///
    /// Over HTTPS, ALPN is configured automatically — the server selects HTTP/2 during
    /// the TLS handshake if it supports it. Over plain HTTP and `unix://`, prior-knowledge
    /// HTTP/2 is used: the client speaks HTTP/2 directly without an Upgrade round trip.
    /// Use this only when you know the server supports HTTP/2 on that transport.
    Http2,
    /// Negotiate HTTP/1.1 or HTTP/2 automatically.
    ///
    /// Over HTTPS, ALPN is used: the server selects HTTP/1.1 or HTTP/2 during the TLS
    /// handshake. Over plain HTTP and `unix://`, there is no TLS handshake, so ALPN
    /// cannot negotiate a protocol and the connection always falls back to HTTP/1.1 —
    /// use `http2` explicitly for prior-knowledge HTTP/2 on those transports.
    Alpn,
}

impl Validate for Protocol {}

/// HTTP/2 connection settings.
///
/// These apply when `protocol` is `http2`, and to HTTP/2 connections
/// negotiated under `protocol: alpn`.
#[non_exhaustive]
#[configuration]
pub struct Http2Config {
    /// How long an idle HTTP/2 connection is kept before being closed. The
    /// connection is considered idle when no requests are in flight.
    #[config(default = Duration::from(StdDuration::from_secs(300)))]
    pub connection_idle_timeout: Duration,

    /// Interval at which HTTP/2 PING frames are sent to detect dead
    /// connections and prevent proxies from closing idle connections.
    #[config(default = Duration::from(StdDuration::from_secs(30)))]
    pub keep_alive_interval: Duration,

    /// How long to wait for a PING acknowledgement before closing the
    /// connection.
    #[config(default = Duration::from(StdDuration::from_secs(20)))]
    pub keep_alive_timeout: Duration,

    /// Whether to send keep-alive pings when there are no active streams.
    #[config(default = true)]
    pub keep_alive_while_idle: bool,
}

/// TCP socket settings applied to every outbound connection.
///
/// These apply regardless of HTTP protocol version (HTTP/1.1, HTTP/2, or
/// negotiated under `protocol: alpn`).
#[non_exhaustive]
#[configuration]
pub struct TcpConfig {
    /// Whether to disable Nagle's algorithm (`TCP_NODELAY`).
    ///
    /// When `true` (default), small payloads are sent immediately, reducing
    /// tail latency on small request/response bodies. Set to `false` to enable
    /// Nagle's algorithm, in which the kernel may briefly buffer small writes
    /// to coalesce them into fewer segments.
    #[config(default = true)]
    pub nodelay: bool,

    /// Idle period before the OS starts sending TCP keep-alive probes.
    ///
    /// Defaults to 60 s. Probes detect dead connections before a new request
    /// is dispatched on them and prevent intermediate proxies (load balancers,
    /// NATs) from silently dropping long-lived idle connections.
    #[config(default = Duration::from(StdDuration::from_secs(60)))]
    pub keepalive: Duration,
}

/// HTTP/1.1 connection pool settings.
///
/// These apply when `protocol` is `http1`, and to HTTP/1.1 connections
/// negotiated under `protocol: alpn`. They have no effect when `protocol`
/// is `http2` — HTTP/2 uses a single persistent connection per host.
#[non_exhaustive]
#[configuration]
pub struct Http1Config {
    /// Maximum idle connections kept open per host.
    #[config(default = NonZeroUsize::new(10).unwrap())]
    pub pool_max_idle_per_host: NonZeroUsize,

    /// How long idle connections are kept alive before being closed.
    #[config(default = Duration::from(StdDuration::from_secs(90)))]
    pub pool_idle_timeout: Duration,
}

/// Metrics opt-in settings.
///
/// Controls which optional OTel HTTP client metrics are recorded and which opt-in attributes
/// are included. All are disabled by default, as required by the OTel HTTP semantic conventions.
#[non_exhaustive]
#[configuration]
pub struct MetricsConfig {
    /// Enable recording of `http.client.request.body.size`.
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub request_body_size: bool,

    /// Enable recording of `http.client.response.body.size`.
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub response_body_size: bool,

    /// Include the `url.scheme` attribute on all HTTP client metrics.
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub url_scheme: bool,
}

/// Spans opt-in settings.
#[non_exhaustive]
#[configuration]
pub struct SpansConfig {
    /// Capture `url.scheme` as a span attribute.
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub url_scheme: bool,

    /// Capture `http.request.body.size` as a span attribute (actual bytes sent).
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub request_body_size: bool,

    /// Capture `http.response.body.size` as a span attribute (actual bytes received).
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub response_body_size: bool,

    /// Capture `user_agent.original` from the outgoing `User-Agent` request header.
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub user_agent: bool,

    /// Capture `network.transport` as a span attribute. Always `"tcp"` for HTTP/1.1 and HTTP/2.
    ///
    /// Opt-In per the OTel HTTP semantic conventions.
    pub network_transport: bool,

    /// Static `network.local.address` span attribute: the outbound IP address of this service
    /// (e.g. `"192.168.1.10"`). Opt-In per the OTel HTTP semantic conventions.
    pub network_local_address: Option<String>,

    /// Static `network.local.port` span attribute: the source port this service binds to, if fixed.
    /// Opt-In per the OTel HTTP semantic conventions.
    pub network_local_port: Option<u16>,

    /// HTTP request header names to capture as span attributes (case-insensitive).
    ///
    /// Each header `x-foo` becomes `http.request.header.x-foo` with a string-array value.
    /// Opt-In per the OTel HTTP semantic conventions.
    #[config(skip_validate)]
    pub request_headers: Vec<HeaderName>,

    /// HTTP response header names to capture as span attributes (case-insensitive).
    ///
    /// Each header `x-foo` becomes `http.response.header.x-foo` with a string-array value.
    /// Opt-In per the OTel HTTP semantic conventions.
    #[config(skip_validate)]
    pub response_headers: Vec<HeaderName>,
}

/// Telemetry settings.
#[non_exhaustive]
#[configuration]
pub struct TelemetryConfig {
    /// Metrics opt-in settings.
    pub metrics: MetricsConfig,

    /// Spans opt-in settings.
    pub spans: SpansConfig,
}

/// Proxy server configuration.
///
/// Routes outbound `http://` and `https://` requests through a proxy server.
/// `unix://` requests bypass the proxy and reach the socket directly.
///
/// ```yaml
/// proxy:
///   url: "http://user:${PROXY_PASSWORD}@proxy.corp.example.com:3128"
/// ```
#[non_exhaustive]
#[configuration]
pub struct ProxyConfig {
    /// Proxy server URL.
    ///
    /// Supports Basic authentication via embedded credentials:
    /// `http://user:password@proxy.corp.example.com:3128`
    ///
    /// Use env-var expansion to avoid hardcoding addresses or credentials:
    /// `url: "${EGRESS_PROXY_URL}"`
    #[config(required, validate = validate_proxy_url)]
    pub url: ProxyUrl,
}

fn validate_proxy_url(url: &ProxyUrl, mut errors: ErrorCollector<'_>) {
    let url = url.unredact();
    if url.host_str().is_none_or(str::is_empty) {
        errors.report_simple("proxy URL must include a host");
    }
    match url.scheme() {
        "http" | "https" => {}
        other => errors.report_simple(format!(
            "unsupported proxy URL scheme `{other}`; must be http or https"
        )),
    }
}

/// TLS settings for outbound connections.
#[non_exhaustive]
#[configuration]
pub struct TlsConfig {
    /// PEM-encoded CA certificate bundle to trust. Multiple certificates may
    /// be concatenated in a single PEM blob. When `use_native_certificate_store`
    /// is also enabled (the default), these CAs are added to the OS native
    /// store; set `use_native_certificate_store: false` to trust only the
    /// supplied bundle.
    ///
    /// Typically loaded from disk with `${file:./certs/ca.pem}`.
    pub certificate_authorities: Option<Redacted<String>>,

    /// Whether the OS native certificate store is trusted. When `true` (the
    /// default), any CAs supplied via `certificate_authorities` are layered
    /// on top. Set to `false` to trust only the supplied bundle — useful in
    /// air-gapped environments.
    #[config(default = true)]
    pub use_native_certificate_store: bool,

    /// Client certificate and key to present during mutual TLS handshakes.
    /// Omit to disable client authentication.
    pub client_authentication: Option<ClientAuthentication>,

    /// Skip TLS certificate verification. **Never use in production.** When
    /// enabled, the client accepts any certificate regardless of issuer or
    /// validity, making all outbound HTTPS connections vulnerable to
    /// machine-in-the-middle interception.
    pub danger_accept_invalid_certs: bool,
}

/// Client identity used for mutual TLS authentication.
///
/// The certificate is offered to servers during the TLS handshake; servers
/// that do not require client authentication ignore it. Encrypted private
/// keys are not supported.
#[non_exhaustive]
#[configuration]
pub struct ClientAuthentication {
    /// PEM-encoded client certificate followed by any intermediate
    /// certificates needed to complete the chain to a CA the server trusts.
    ///
    /// Typically loaded from disk with `${file:./certs/client.pem}`.
    #[config(required)]
    pub certificate_chain: Redacted<String>,

    /// PEM-encoded unencrypted private key matching the leaf certificate in
    /// `certificate_chain`. PKCS#1, PKCS#8, and SEC1 formats are accepted.
    ///
    /// Typically loaded from disk with `${file:./certs/client.key}`.
    #[config(required)]
    pub key: Redacted<String>,
}

/// HTTP client configuration.
///
/// Controls the protocol, connection pool, timeouts, and opt-in OTel telemetry.
#[non_exhaustive]
#[configuration]
pub struct HttpClientConfig {
    /// Connection timeout covering both the TCP dial and TLS handshake.
    #[config(default = Duration::from(StdDuration::from_secs(10)))]
    pub connect_timeout: Duration,

    /// HTTP/1.1 connection pool settings.
    pub http1: Http1Config,

    /// HTTP/2 connection settings.
    pub http2: Http2Config,

    /// HTTP protocol version to use.
    pub protocol: Protocol,

    /// TCP socket settings applied to every outbound connection.
    pub tcp: TcpConfig,

    /// Telemetry opt-in settings.
    pub telemetry: TelemetryConfig,

    /// TLS settings for outbound connections.
    pub tls: TlsConfig,

    /// Proxy server configuration. When set, outbound `http://` and `https://`
    /// requests are routed through the specified proxy; `unix://` requests
    /// always reach the socket directly.
    ///
    /// When absent (the default), TCP connections are made directly to the target.
    pub proxy: Option<ProxyConfig>,
}