Files
alkhttp/src/client/http_client.rs
T
glm-5.3-flash 91483a74b4 docs: missing_docs sweep — 0 warnings + deny gate + publish-prep decisions (HY-02, HY-04, HY-11)
- document every public-API item across 18 files (openapi_spec model,
  HttpAuthScheme/HttpServiceConfig, HttpClientBuildError + SharedHttpClient
  accessors, RetryAfterMiddleware, GatewayDispatch, gateway error
  mapping, CallRequest/SchemaQuery/SubscribeStream, HttpAdapter +
  ALPNs + builders, decoy/healthz/state, WsSessions/WsPumps,
  from_openapi/from_jsonschema/from_mcp/from_wss/to_mcp, lib.rs module
  docs)
- enforcement: #![deny(missing_docs)] at crate root — stronger than CI
  rustdocflags (every build incl. cfg(test), where rustdoc misses the
  test-support module docs)
- HY-10 (opportunistic): all 8 docs.rs/alkhttp placeholder ADR links +
  the one relative ../docs link converted to plain text; the 10
  pre-existing private/redundant intra-doc-link warnings fixed —
  RUSTDOCFLAGS="-D warnings" cargo doc is fully clean
- HY-11 decision: docs/ + tasks/ excluded from the published package
  (contributor-facing design/process material; ADR references degrade
  to plain text uniformly). cargo publish --dry-run: 38 files, ~889 KiB,
  zero docs/ or tasks/ entries
- HY-04 decision: keep + document — frame_channel0_chunk's unwrap is
  on serializing the acyclic EventEnvelope (unreachable failure);
  # Panics on it and the adjacent WsClient senders state the contract

Verified: cargo test (299 + 5 TLS), --all-features (370 + suites),
--no-default-features (299), clippy --all-targets -D warnings
(default + all-features), fmt --check, cargo doc -D warnings clean,
cargo publish --dry-run --allow-dirty clean.

Tasks: review-001-missing-docs-sweep (final pending task; 42/42)
2026-08-30 08:25:18 +00:00

533 lines
20 KiB
Rust

//! Shared HTTP client: `reqwest_middleware::ClientWithMiddleware` with a
//! retry stack (an idempotency-gated `RetryGateMiddleware` in front of
//! RetryTransientMiddleware + the inlined RetryAfterMiddleware),
//! connection pooling, keep-alive, TLS, and rebuild-and-swap hot-reload.
//!
//! Credential injection happens per-request (from
//! `OperationContext.capabilities`), not at client construction — the
//! client is shared across all operations, the credentials are per-call.
//!
//! # Redirect policy (FWD-03)
//!
//! The default reqwest redirect policy (`limited(10)`) ships custom
//! credential headers across hosts: its cross-host scrub list covers only
//! `Authorization`/`Cookie`/`Proxy-Authorization`/`WWW-Authenticate`, so an
//! `HttpAuthScheme::ApiKey { header_name }` header (and any
//! `default_headers` entry) rides a 302 to an attacker-controlled host
//! intact. The shared client therefore installs an explicit same-host
//! redirect policy: a redirect is followed only when the target's scheme,
//! host and port all match the URL it came from. Otherwise the redirect
//! response is surfaced to the caller untouched and no request — with or
//! without credentials — is sent to another host.
//!
//! # Retry policy (FWD-04)
//!
//! Retries apply only to idempotent methods (GET/HEAD/PUT/DELETE/
//! OPTIONS); POST, PATCH, CONNECT and TRACE bypass the retry middleware
//! entirely (`RetryGateMiddleware`), so a non-idempotent request can
//! never be re-sent and can never duplicate upstream side effects. The
//! backoff is jittered and bounded by a wall-clock budget
//! ([`HttpClientConfig::max_total_retry_duration`]), not just an attempt
//! count.
//!
//! # Timeout / Retry-After caps (FWD-05)
//!
//! `HttpClientConfig::default()` carries a 30 s overall request timeout
//! (anchored to the gateway's 30 s deadline), a 10 s connect timeout and
//! a 30 s read timeout, so a stalled upstream cannot hold a caller open
//! indefinitely. `Retry-After` deadlines are clamped to
//! [`HttpClientConfig::retry_after_ceiling`] (default 300 s) — a hostile
//! backend cannot park the client on a 10-year deadline.
//!
//! # Blocking reads (FWD-09)
//!
//! The hot-reload path [`SharedHttpClient::reload`] reads CA bundle /
//! client-identity PEM files via `tokio::fs` (`spawn_blocking`-backed),
//! so a rebuild never blocks an async worker. One-shot construction
//! (`SharedHttpClient::new`) performs the same small reads synchronously
//! at assembly time only.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use arc_swap::ArcSwap;
use http::Extensions;
use reqwest::ClientBuilder;
use reqwest_middleware::ClientWithMiddleware;
use reqwest_retry::policies::ExponentialBackoff;
use reqwest_retry::RetryTransientMiddleware;
use reqwest_retry::{Jitter, RetryDecision, RetryPolicy};
use thiserror::Error;
use super::retry_after::RetryAfterMiddleware;
/// Maximum number of URLs tracked by the inlined `Retry-After`
/// middleware (LRU-bounded; see `retry_after.rs`).
const DEFAULT_RETRY_AFTER_CAPACITY: usize = 256;
/// Default overall request timeout. Anchored to the gateway's 30 s
/// deadline: a forwarded call must fail before its caller does.
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Default TCP connect timeout — fails fast on unreachable upstreams.
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Default idle-read timeout between body bytes.
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
/// Default wall-clock budget across all retry attempts of a single
/// request (in addition to the retry count).
const DEFAULT_MAX_TOTAL_RETRY_DURATION: Duration = Duration::from_secs(10);
/// Default ceiling applied to any `Retry-After` deadline handed us by an
/// upstream (seconds and HTTP-date forms alike).
const DEFAULT_RETRY_AFTER_CEILING: Duration = Duration::from_secs(300);
/// Default retry count: attempts beyond the first failure of an
/// idempotent request.
const DEFAULT_MAX_RETRIES: u32 = 3;
/// Default lower bound of the retry backoff interval.
const RETRY_BACKOFF_MIN_INTERVAL: Duration = Duration::from_millis(100);
/// Default upper bound of the retry backoff interval.
const RETRY_BACKOFF_MAX_INTERVAL: Duration = Duration::from_secs(2);
/// A mutual-TLS identity presented to upstreams: paths to the
/// PEM-encoded client certificate and its private key. Both files are
/// read at client-construction time (see `HttpClientBuildError` for
/// the failure shapes) and combined into a single reqwest
/// `Identity`.
#[derive(Debug, Clone)]
pub struct ClientCertConfig {
/// Path to the PEM-encoded client certificate chain.
pub cert_pem: PathBuf,
/// Path to the PEM-encoded (PKCS#8) private key for `cert_pem`.
pub key_pem: PathBuf,
}
/// Retry backoff shape for the shared outbound client. The public
/// surface is plain scalars (HY-06) — the concrete
/// `reqwest_retry::ExponentialBackoff` policy is built internally from
/// these at client-construction time, keeping the upstream concrete
/// type out of this crate's API.
#[derive(Debug, Clone)]
pub struct RetryConfig {
/// Maximum number of retries after the initial attempt
/// (idempotent-method requests only — see `RetryGateMiddleware`).
pub max_retries: u32,
/// Lower bound of the jittered exponential backoff interval.
pub initial_backoff: Duration,
/// Upper bound of the jittered exponential backoff interval.
pub max_retry_interval: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: RETRY_BACKOFF_MIN_INTERVAL,
max_retry_interval: RETRY_BACKOFF_MAX_INTERVAL,
}
}
}
/// Policy knobs for the shared outbound client
/// (`SharedHttpClient`). Defaults satisfy the review-001 request-policy
/// findings (FWD-03/04/05): same-host-only redirects, idempotent-only
/// retries with a wall-clock budget, 30 s request + 10 s connect
/// timeouts, and a 300 s `Retry-After` ceiling.
#[derive(Debug, Clone)]
pub struct HttpClientConfig {
/// Idle connections kept per host; `None` uses the reqwest default.
pub pool_max_idle_per_host: Option<usize>,
/// Overall per-request timeout (default 30 s, off with `None`).
pub request_timeout: Option<Duration>,
/// TCP connect timeout (default 10 s, off with `None`).
pub connect_timeout: Option<Duration>,
/// Idle timeout between body bytes (default 30 s, off with `None`).
pub read_timeout: Option<Duration>,
/// Retry backoff shape; only idempotent methods are ever retried
/// (see `RetryGateMiddleware`).
pub retry: RetryConfig,
/// Wall-clock budget all retry attempts of one request must fit in.
pub max_total_retry_duration: Duration,
/// Ceiling for `Retry-After` values parsed from upstream responses.
pub retry_after_ceiling: Duration,
/// Extra root certificates for upstream TLS verification.
pub ca_bundle: Option<PathBuf>,
/// Mutual-TLS identity presented to upstreams.
pub client_cert: Option<ClientCertConfig>,
}
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
pool_max_idle_per_host: None,
request_timeout: Some(DEFAULT_REQUEST_TIMEOUT),
connect_timeout: Some(DEFAULT_CONNECT_TIMEOUT),
read_timeout: Some(DEFAULT_READ_TIMEOUT),
retry: RetryConfig::default(),
max_total_retry_duration: DEFAULT_MAX_TOTAL_RETRY_DURATION,
retry_after_ceiling: DEFAULT_RETRY_AFTER_CEILING,
ca_bundle: None,
client_cert: None,
}
}
}
/// Why a [`SharedHttpClient`] could not be built: PEM file reads fail
/// with `CaBundleRead`/`ClientCertRead`, PEM parsing fails with
/// `CaBundleParse`/`ClientCertParse` (both carry the offending path),
/// and the underlying reqwest builder fails with `Build`.
#[derive(Debug, Error)]
pub enum HttpClientBuildError {
/// The configured `ca_bundle` path could not be read.
#[error("failed to read CA bundle from {path}: {source}")]
CaBundleRead {
/// The unreadable path, for the caller's diagnostics.
path: PathBuf,
/// The underlying I/O error.
#[source]
source: std::io::Error,
},
/// The CA bundle file exists but is not valid PEM.
#[error("failed to parse CA bundle at {path}: {source}")]
CaBundleParse {
/// The unparseable path, for the caller's diagnostics.
path: PathBuf,
/// The underlying reqwest parse error.
#[source]
source: reqwest::Error,
},
/// A client-certificate PEM path (`cert_pem` or `key_pem`) could
/// not be read.
#[error("failed to read client cert from {path}: {source}")]
ClientCertRead {
/// The unreadable path.
path: PathBuf,
/// The underlying I/O error.
#[source]
source: std::io::Error,
},
/// The client cert/key files exist but do not form a valid
/// reqwest `Identity`.
#[error("failed to parse client cert at {path}: {source}")]
ClientCertParse {
/// The path of the identity whose parse failed.
path: PathBuf,
/// The underlying reqwest parse error.
#[source]
source: reqwest::Error,
},
/// The reqwest client itself failed to build (e.g. TLS backend
/// initialization).
#[error("failed to build reqwest client: {0}")]
Build(reqwest::Error),
}
/// A hot-reloadable, middleware-stacked outbound HTTP client shared by
/// consumer adapters. Clone-cheap (`ArcSwap` inner); callers reach the
/// current stack through [`SharedHttpClient::client`].
pub struct SharedHttpClient {
inner: ArcSwap<SharedHttpInner>,
}
/// Joint holder for the client and its config so a reload swaps both in
/// one atomic `ArcSwap::store` — a reader can never observe the new
/// config paired with the previous client (FWD-12).
#[derive(Clone)]
struct SharedHttpInner {
client: Arc<ClientWithMiddleware>,
config: Arc<HttpClientConfig>,
}
impl std::fmt::Debug for SharedHttpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SharedHttpClient")
.field("config", &self.inner.load().config)
.finish_non_exhaustive()
}
}
impl SharedHttpClient {
/// Build a shared client. Blocks only on the (small) PEM reads when
/// `ca_bundle`/`client_cert` are configured — this is one-shot
/// construction wiring at assembly time, never a per-request or
/// hot-reload path; use [`SharedHttpClient::reload`] for async
/// rebuilds.
pub fn new(config: HttpClientConfig) -> Result<Self, HttpClientBuildError> {
let client = build_client_sync(&config)?;
Ok(Self {
inner: ArcSwap::from_pointee(SharedHttpInner {
client: Arc::new(client),
config: Arc::new(config),
}),
})
}
/// The current middleware-stacked client. Every call loads the
/// latest stack — after a [`reload`](Self::reload), new requests
/// ride the rebuilt client.
pub fn client(&self) -> Arc<ClientWithMiddleware> {
Arc::clone(&self.inner.load().client)
}
/// The config the current client was built from (swapped together
/// with the client; FWD-12).
pub fn config(&self) -> Arc<HttpClientConfig> {
Arc::clone(&self.inner.load().config)
}
/// Rebuild the underlying client and swap it in for new callers
/// (in-flight requests complete on the previous client). PEM reads
/// use `tokio::fs`, so this is safe to call from async contexts
/// without blocking a worker. Client and config swap together in a
/// single atomic store (FWD-12).
pub async fn reload(&self, config: HttpClientConfig) -> Result<(), HttpClientBuildError> {
let client = build_client(&config).await?;
self.inner.store(Arc::new(SharedHttpInner {
client: Arc::new(client),
config: Arc::new(config),
}));
Ok(())
}
}
fn same_host_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= MAX_REDIRECT_HOPS {
return attempt.error("too many redirects");
}
let previous = match attempt.previous().last() {
Some(url) => url.clone(),
None => return attempt.follow(),
};
let next = attempt.url();
let scheme_matches = previous.scheme() == next.scheme();
let host_matches = previous.host_str() == next.host_str();
let port_matches = previous.port_or_known_default() == next.port_or_known_default();
if scheme_matches && host_matches && port_matches {
attempt.follow()
} else {
attempt.stop()
}
})
}
const MAX_REDIRECT_HOPS: usize = 10;
fn is_idempotent(method: &reqwest::Method) -> bool {
matches!(
*method,
reqwest::Method::GET
| reqwest::Method::HEAD
| reqwest::Method::PUT
| reqwest::Method::DELETE
| reqwest::Method::OPTIONS
)
}
struct RetryGateMiddleware {
retry: Arc<RetryTransientMiddleware<TotalRetryBudget<ExponentialBackoff>>>,
}
impl RetryGateMiddleware {
fn new(retry: &RetryConfig, max_total_retry_duration: Duration) -> Self {
let policy = ExponentialBackoff::builder()
.retry_bounds(retry.initial_backoff, retry.max_retry_interval)
.jitter(Jitter::Bounded)
.base(2)
.build_with_max_retries(retry.max_retries);
Self {
retry: Arc::new(RetryTransientMiddleware::new_with_policy(
TotalRetryBudget {
budget: max_total_retry_duration,
inner: policy,
},
)),
}
}
}
#[async_trait::async_trait]
impl reqwest_middleware::Middleware for RetryGateMiddleware {
async fn handle(
&self,
req: reqwest::Request,
extensions: &mut Extensions,
next: reqwest_middleware::Next<'_>,
) -> reqwest_middleware::Result<reqwest::Response> {
if is_idempotent(req.method()) {
self.retry.handle(req, extensions, next).await
} else {
next.run(req, extensions).await
}
}
}
struct TotalRetryBudget<P> {
budget: Duration,
inner: P,
}
impl<P: RetryPolicy> RetryPolicy for TotalRetryBudget<P> {
fn should_retry(&self, request_start_time: SystemTime, n_past_retries: u32) -> RetryDecision {
let elapsed = SystemTime::now()
.duration_since(request_start_time)
.unwrap_or_default();
if elapsed >= self.budget {
return RetryDecision::DoNotRetry;
}
match self.inner.should_retry(request_start_time, n_past_retries) {
RetryDecision::DoNotRetry => RetryDecision::DoNotRetry,
RetryDecision::Retry { execute_after } => {
let hard_stop = request_start_time
.checked_add(self.budget)
.unwrap_or(execute_after);
RetryDecision::Retry {
execute_after: execute_after.min(hard_stop),
}
}
}
}
}
async fn build_client(
config: &HttpClientConfig,
) -> Result<ClientWithMiddleware, HttpClientBuildError> {
let ca_pem = match &config.ca_bundle {
Some(path) => Some(tokio::fs::read(path).await.map_err(|source| {
HttpClientBuildError::CaBundleRead {
path: path.clone(),
source,
}
})?),
None => None,
};
let client_pems = match &config.client_cert {
Some(cfg) => {
let cert_pem = tokio::fs::read(&cfg.cert_pem).await.map_err(|source| {
HttpClientBuildError::ClientCertRead {
path: cfg.cert_pem.clone(),
source,
}
})?;
let key_pem = tokio::fs::read(&cfg.key_pem).await.map_err(|source| {
HttpClientBuildError::ClientCertRead {
path: cfg.key_pem.clone(),
source,
}
})?;
Some((cert_pem, key_pem))
}
None => None,
};
build_client_with_pems(config, ca_pem, client_pems)
}
fn build_client_sync(
config: &HttpClientConfig,
) -> Result<ClientWithMiddleware, HttpClientBuildError> {
let ca_pem = match &config.ca_bundle {
Some(path) => {
Some(
std::fs::read(path).map_err(|source| HttpClientBuildError::CaBundleRead {
path: path.clone(),
source,
})?,
)
}
None => None,
};
let client_pems = match &config.client_cert {
Some(cfg) => {
let cert_pem = std::fs::read(&cfg.cert_pem).map_err(|source| {
HttpClientBuildError::ClientCertRead {
path: cfg.cert_pem.clone(),
source,
}
})?;
let key_pem = std::fs::read(&cfg.key_pem).map_err(|source| {
HttpClientBuildError::ClientCertRead {
path: cfg.key_pem.clone(),
source,
}
})?;
Some((cert_pem, key_pem))
}
None => None,
};
build_client_with_pems(config, ca_pem, client_pems)
}
fn build_client_with_pems(
config: &HttpClientConfig,
ca_pem: Option<Vec<u8>>,
client_pems: Option<(Vec<u8>, Vec<u8>)>,
) -> Result<ClientWithMiddleware, HttpClientBuildError> {
let mut builder = ClientBuilder::new();
builder = builder.redirect(same_host_redirect_policy());
if let Some(pool_max_idle) = config.pool_max_idle_per_host {
builder = builder.pool_max_idle_per_host(pool_max_idle);
}
if let Some(timeout) = config.request_timeout {
builder = builder.timeout(timeout);
}
if let Some(timeout) = config.connect_timeout {
builder = builder.connect_timeout(timeout);
}
if let Some(timeout) = config.read_timeout {
builder = builder.read_timeout(timeout);
}
if let Some(pem) = &ca_pem {
let certs = reqwest::Certificate::from_pem_bundle(pem).map_err(|source| {
HttpClientBuildError::CaBundleParse {
path: config
.ca_bundle
.clone()
.unwrap_or_else(|| PathBuf::from("<ca-bundle>")),
source,
}
})?;
for cert in certs {
builder = builder.add_root_certificate(cert);
}
}
if let Some((cert_pem, key_pem)) = &client_pems {
let identity = reqwest::Identity::from_pem(concat_pem(cert_pem, key_pem).as_slice())
.map_err(|source| HttpClientBuildError::ClientCertParse {
path: config
.client_cert
.as_ref()
.map(|cfg| cfg.cert_pem.clone())
.unwrap_or_else(|| PathBuf::from("<client-cert>")),
source,
})?;
builder = builder.identity(identity);
}
let reqwest_client = builder.build().map_err(HttpClientBuildError::Build)?;
let client = reqwest_middleware::ClientBuilder::new(reqwest_client)
.with(RetryGateMiddleware::new(
&config.retry,
config.max_total_retry_duration,
))
.with(RetryAfterMiddleware::with_capacity_and_ceiling(
DEFAULT_RETRY_AFTER_CAPACITY,
config.retry_after_ceiling,
))
.build();
Ok(client)
}
fn concat_pem(cert: &[u8], key: &[u8]) -> Vec<u8> {
let mut combined = Vec::with_capacity(cert.len() + key.len() + 1);
combined.extend_from_slice(cert);
if !cert.is_empty() && cert.last() != Some(&b'\n') {
combined.push(b'\n');
}
combined.extend_from_slice(key);
combined
}