Files
codex/codex-rs/http-client/src/client_builder.rs
T
Abhinav 3bb0a530d1 Add system proxy fallback for login and startup requests (#46562)
## Why

Login and enterprise configuration bootstrap can fail when their endpoints are reachable only through the system proxy, before cloud configuration can enable `respect_system_proxy`.

## What changed

- Enable `features.system_proxy_fallback` by default, honor managed feature requirements, and prevent project configuration from changing it.
- Retry account discovery and cloud configuration GETs through the system proxy after connection failures or timeouts, bounding the initial request and body read to five seconds.
- Retry OAuth authorization-code exchange only on connection failures before any redirect, with a ten-second connection timeout. Reuse the successful client for the subsequent API-key exchange to avoid changing routes.
- Increase workspace discovery and cloud configuration timeouts to 15 and 20 seconds, respectively, to accommodate fallback.

## Testing

Add coverage for proxy-only browser login through the first completed turn, including cloud configuration enabling `respect_system_proxy` without a restart. Cover stalled GET bodies, preserved request headers, disabled fallback, managed requirements, and project configuration restrictions. Verify that OAuth HTTP errors, redirects followed by connection failures, and failures after sending the POST do not trigger proxy retries, while successful redirects remain supported.

GitOrigin-RevId: 854eeeac186bb4865218ce645019c1e4bff85425
2026-09-19 01:37:14 +00:00

340 lines
13 KiB
Rust

//! HTTP client construction that makes outbound proxy policy explicit.
//!
//! Product traffic should normally enter through [`HttpClientFactory`] for a fixed destination or
//! [`crate::RouteAwareClientPool`] when request and redirect URLs can vary. The direct and
//! transport-default terminal methods exist only for narrow exceptional or legacy compatibility
//! paths.
use http::HeaderMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use crate::BuildCustomCaTransportError;
use crate::BuildRouteAwareHttpClientError;
use crate::ClientRouteClass;
use crate::HttpClient;
use crate::HttpClientFactory;
use crate::OutboundProxyRoute;
use crate::chatgpt_cloudflare_cookies::ChatGptCookieStore;
use crate::client::RequestLogging;
use crate::custom_ca::build_reqwest_client_with_custom_ca;
use crate::with_chatgpt_cloudflare_cookie_store;
/// Configures an [`HttpClient`] without exposing the underlying HTTP implementation.
///
/// Product traffic should prefer [`HttpClientFactory::build_client`] or finish this builder with
/// [`Self::build_respecting_outbound_proxy_policy`]. The other terminal methods deliberately
/// bypass the factory and are restricted to documented exceptional or legacy compatibility paths.
#[derive(Clone)]
pub struct HttpClientBuilder {
default_headers: Option<HeaderMap>,
follow_redirects: bool,
redirect_observed: Option<Arc<AtomicBool>>,
connect_timeout: Option<Duration>,
chatgpt_cloudflare_cookie_store: bool,
chatgpt_cookie_store: Option<Arc<ChatGptCookieStore>>,
request_logging: RequestLogging,
tls_backend: TlsBackend,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum TlsBackend {
#[default]
TransportDefault,
Rustls,
}
impl HttpClientFactory {
/// Builds an HTTP client for one fixed destination using the configured proxy policy.
///
/// This is the preferred construction path for product traffic that uses a fixed destination.
/// Use [`crate::RouteAwareClientPool`] instead when request or redirect URLs can vary.
pub fn build_client(
&self,
request_url: &str,
route_class: ClientRouteClass,
) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
HttpClientBuilder::new().build_respecting_outbound_proxy_policy(
self,
request_url,
route_class,
)
}
/// Builds a policy-aware client without request URL or response-header diagnostics.
///
/// This has the same routing guidance as [`Self::build_client`].
pub fn build_client_without_request_logging(
&self,
request_url: &str,
route_class: ClientRouteClass,
) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
HttpClientBuilder::new()
.without_request_logging()
.build_respecting_outbound_proxy_policy(self, request_url, route_class)
}
}
impl HttpClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn default_headers(mut self, headers: HeaderMap) -> Self {
self.default_headers = Some(headers);
self
}
pub fn without_redirects(mut self) -> Self {
self.follow_redirects = false;
self
}
/// Marks the supplied flag when a redirect is encountered, preserving the default policy.
/// Use a fresh flag for each operation whose retry safety depends on its redirect history.
pub fn with_redirect_tracking(mut self, redirect_observed: Arc<AtomicBool>) -> Self {
self.redirect_observed = Some(redirect_observed);
self
}
pub(crate) fn follows_redirects(&self) -> bool {
self.follow_redirects
}
pub(crate) fn with_rustls_tls(mut self) -> Self {
self.tls_backend = TlsBackend::Rustls;
self
}
/// Limits only connection establishment, not the request as a whole.
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
pub fn with_chatgpt_cloudflare_cookie_store(mut self) -> Self {
self.chatgpt_cloudflare_cookie_store = true;
self
}
/// Uses the factory's configured ChatGPT cookies without changing proxy behavior.
pub fn with_chatgpt_cookies(mut self, http_client_factory: &HttpClientFactory) -> Self {
self.chatgpt_cloudflare_cookie_store = true;
self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store();
self
}
/// Suppresses request URL and response-header diagnostics.
pub fn without_request_logging(mut self) -> Self {
self.request_logging = RequestLogging::Disabled;
self
}
/// Builds a client that honors the [`HttpClientFactory`] outbound proxy policy.
///
/// This is the preferred terminal method for product traffic. The request URL is used to
/// resolve a concrete direct or proxy route when the factory is configured with
/// [`crate::OutboundProxyPolicy::RespectSystemProxy`].
pub fn build_respecting_outbound_proxy_policy(
mut self,
http_client_factory: &HttpClientFactory,
request_url: &str,
route_class: ClientRouteClass,
) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store();
let (builder, request_logging) = self.into_reqwest_parts();
let inner = http_client_factory.build_reqwest_client(builder, request_url, route_class)?;
Ok(HttpClient::from_parts(inner, request_logging))
}
/// Builds a client for a route that was already resolved by a route-aware caller.
pub(crate) fn build_for_resolved_route(
mut self,
http_client_factory: &HttpClientFactory,
route_class: ClientRouteClass,
route: &OutboundProxyRoute,
) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
self.chatgpt_cookie_store = http_client_factory.chatgpt_cookie_store();
let (builder, request_logging) = self.into_reqwest_parts();
let inner = http_client_factory.build_reqwest_client_for_resolved_route(
builder,
route_class,
route,
)?;
Ok(HttpClient::from_parts(inner, request_logging))
}
/// Builds a client using the transport's default proxy behavior.
///
/// # Legacy compatibility only
///
/// This bypasses [`HttpClientFactory`] and therefore does not honor its configured outbound
/// proxy policy. New product traffic must use [`Self::build_respecting_outbound_proxy_policy`]
/// or [`HttpClientFactory::build_client`].
#[deprecated(
note = "legacy compatibility only; use HttpClientFactory::build_client or build_respecting_outbound_proxy_policy"
)]
pub fn build_with_transport_default_proxy(
self,
) -> Result<HttpClient, BuildCustomCaTransportError> {
self.build_with_proxy_routing(ProxyRouting::TransportDefault)
}
/// Builds a client that connects directly without using a proxy.
///
/// # Exceptional use only
///
/// This bypasses [`HttpClientFactory`] and is appropriate only when bypassing proxy discovery
/// is itself required: for example, a hermetic local test fixture, a localhost callback, or
/// sandbox traffic whose egress routing is handled separately. Ordinary outbound product
/// traffic must use [`Self::build_respecting_outbound_proxy_policy`] or
/// [`HttpClientFactory::build_client`].
pub fn build_direct(self) -> Result<HttpClient, BuildCustomCaTransportError> {
self.build_with_proxy_routing(ProxyRouting::Direct)
}
/// Builds a transport-default client while preserving the legacy custom-CA fallback.
///
/// # Legacy compatibility only
///
/// This preserves call sites that historically logged a custom-CA error and continued with
/// system roots. New product traffic must propagate construction errors through
/// [`Self::build_respecting_outbound_proxy_policy`] or [`HttpClientFactory::build_client`].
#[deprecated(
note = "legacy custom-CA fallback only; use HttpClientFactory::build_client or build_respecting_outbound_proxy_policy"
)]
pub fn build_with_transport_default_proxy_and_custom_ca_fallback(self) -> HttpClient {
self.build_with_custom_ca_fallback(ProxyRouting::TransportDefault)
}
/// Builds a direct client while preserving the legacy custom-CA fallback.
///
/// # Legacy compatibility only
///
/// This combines the exceptional proxy bypass described by [`Self::build_direct`] with the
/// historical behavior of logging a custom-CA error and continuing with system roots.
#[deprecated(
note = "legacy custom-CA fallback only; use build_direct and propagate construction errors"
)]
pub fn build_direct_with_custom_ca_fallback(self) -> HttpClient {
self.build_with_custom_ca_fallback(ProxyRouting::Direct)
}
fn build_with_proxy_routing(
self,
proxy_routing: ProxyRouting,
) -> Result<HttpClient, BuildCustomCaTransportError> {
let request_logging = self.request_logging;
build_reqwest_client_with_custom_ca(self.reqwest_builder(proxy_routing))
.map(|inner| HttpClient::from_parts(inner, request_logging))
}
fn build_with_custom_ca_fallback(self, proxy_routing: ProxyRouting) -> HttpClient {
self.build_with_custom_ca_fallback_using(proxy_routing, build_reqwest_client_with_custom_ca)
}
fn build_with_custom_ca_fallback_using(
self,
proxy_routing: ProxyRouting,
build_with_custom_ca: impl FnOnce(
reqwest::ClientBuilder,
)
-> Result<reqwest::Client, BuildCustomCaTransportError>,
) -> HttpClient {
let request_logging = self.request_logging;
match build_with_custom_ca(self.clone().reqwest_builder(proxy_routing)) {
Ok(inner) => HttpClient::from_parts(inner, request_logging),
Err(error) => {
tracing::event!(
target: "codex_otel.log_only",
tracing::Level::WARN,
event.name = "codex.http_client.custom_ca_fallback",
"HTTP client fell back to system root certificates"
);
tracing::warn!(error = %error, "failed to build HTTP client with custom CA");
self.reqwest_builder(proxy_routing)
.build()
.map(|inner| HttpClient::from_parts(inner, request_logging))
.unwrap_or_else(|fallback_error| {
tracing::warn!(
error = %fallback_error,
"failed to build fallback HTTP client"
);
HttpClient::from_parts(reqwest::Client::new(), request_logging)
})
}
}
}
fn into_reqwest_parts(self) -> (reqwest::ClientBuilder, RequestLogging) {
let request_logging = self.request_logging;
(self.base_reqwest_builder(), request_logging)
}
fn reqwest_builder(self, proxy_routing: ProxyRouting) -> reqwest::ClientBuilder {
let builder = self.base_reqwest_builder();
match proxy_routing {
ProxyRouting::TransportDefault => builder,
ProxyRouting::Direct => builder.no_proxy(),
}
}
fn base_reqwest_builder(self) -> reqwest::ClientBuilder {
let mut builder = reqwest::Client::builder();
if self.tls_backend == TlsBackend::Rustls {
ensure_rustls_crypto_provider();
builder = builder.use_rustls_tls();
}
if let Some(default_headers) = self.default_headers {
builder = builder.default_headers(default_headers);
}
if !self.follow_redirects {
builder = builder.redirect(reqwest::redirect::Policy::none());
} else if let Some(redirect_observed) = self.redirect_observed {
builder = builder.redirect(reqwest::redirect::Policy::custom(move |attempt| {
redirect_observed.store(/*val*/ true, Ordering::Relaxed);
reqwest::redirect::Policy::default().redirect(attempt)
}));
}
if let Some(connect_timeout) = self.connect_timeout {
builder = builder.connect_timeout(connect_timeout);
}
if self.chatgpt_cloudflare_cookie_store {
builder = match self.chatgpt_cookie_store {
Some(store) => builder.cookie_provider(store),
None => with_chatgpt_cloudflare_cookie_store(builder),
};
}
builder
}
}
impl Default for HttpClientBuilder {
fn default() -> Self {
Self {
default_headers: None,
follow_redirects: true,
redirect_observed: None,
connect_timeout: None,
chatgpt_cloudflare_cookie_store: false,
chatgpt_cookie_store: None,
request_logging: RequestLogging::Enabled,
tls_backend: TlsBackend::TransportDefault,
}
}
}
#[derive(Clone, Copy)]
enum ProxyRouting {
TransportDefault,
Direct,
}
#[cfg(test)]
#[path = "client_builder_tests.rs"]
mod tests;