Skip to main content

codex_http_client/
client_builder.rs

1//! HTTP client construction that makes outbound proxy policy explicit.
2//!
3//! Product traffic should normally enter through [`HttpClientFactory`] for a fixed destination or
4//! [`crate::RouteAwareClientPool`] when request and redirect URLs can vary. The direct and
5//! transport-default terminal methods exist only for narrow exceptional or legacy compatibility
6//! paths.
7
8use http::HeaderMap;
9
10use crate::BuildCustomCaTransportError;
11use crate::BuildRouteAwareHttpClientError;
12use crate::ClientRouteClass;
13use crate::HttpClient;
14use crate::HttpClientFactory;
15use crate::client::RequestLogging;
16use crate::custom_ca::build_reqwest_client_with_custom_ca;
17use crate::with_chatgpt_cloudflare_cookie_store;
18
19/// Configures an [`HttpClient`] without exposing the underlying HTTP implementation.
20///
21/// Product traffic should prefer [`HttpClientFactory::build_client`] or finish this builder with
22/// [`Self::build_respecting_outbound_proxy_policy`]. The other terminal methods deliberately
23/// bypass the factory and are restricted to documented exceptional or legacy compatibility paths.
24#[derive(Clone)]
25pub struct HttpClientBuilder {
26    default_headers: Option<HeaderMap>,
27    follow_redirects: bool,
28    chatgpt_cloudflare_cookie_store: bool,
29    request_logging: RequestLogging,
30}
31
32impl HttpClientFactory {
33    /// Builds an HTTP client for one fixed destination using the configured proxy policy.
34    ///
35    /// This is the preferred construction path for product traffic that uses a fixed destination.
36    /// Use [`crate::RouteAwareClientPool`] instead when request or redirect URLs can vary.
37    pub fn build_client(
38        &self,
39        request_url: &str,
40        route_class: ClientRouteClass,
41    ) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
42        HttpClientBuilder::new().build_respecting_outbound_proxy_policy(
43            self,
44            request_url,
45            route_class,
46        )
47    }
48
49    /// Builds a policy-aware client without request URL or response-header diagnostics.
50    ///
51    /// This has the same routing guidance as [`Self::build_client`].
52    pub fn build_client_without_request_logging(
53        &self,
54        request_url: &str,
55        route_class: ClientRouteClass,
56    ) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
57        HttpClientBuilder::new()
58            .without_request_logging()
59            .build_respecting_outbound_proxy_policy(self, request_url, route_class)
60    }
61}
62
63impl HttpClientBuilder {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    pub fn default_headers(mut self, headers: HeaderMap) -> Self {
69        self.default_headers = Some(headers);
70        self
71    }
72
73    pub fn without_redirects(mut self) -> Self {
74        self.follow_redirects = false;
75        self
76    }
77
78    pub fn with_chatgpt_cloudflare_cookie_store(mut self) -> Self {
79        self.chatgpt_cloudflare_cookie_store = true;
80        self
81    }
82
83    /// Suppresses request URL and response-header diagnostics.
84    pub fn without_request_logging(mut self) -> Self {
85        self.request_logging = RequestLogging::Disabled;
86        self
87    }
88
89    /// Builds a client that honors the [`HttpClientFactory`] outbound proxy policy.
90    ///
91    /// This is the preferred terminal method for product traffic. The request URL is used to
92    /// resolve a concrete direct or proxy route when the factory is configured with
93    /// [`crate::OutboundProxyPolicy::RespectSystemProxy`].
94    pub fn build_respecting_outbound_proxy_policy(
95        self,
96        http_client_factory: &HttpClientFactory,
97        request_url: &str,
98        route_class: ClientRouteClass,
99    ) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
100        let (builder, request_logging) = self.into_reqwest_parts();
101        let inner = http_client_factory.build_reqwest_client(builder, request_url, route_class)?;
102        Ok(HttpClient::from_parts(inner, request_logging))
103    }
104
105    /// Builds a client using the transport's default proxy behavior.
106    ///
107    /// # Legacy compatibility only
108    ///
109    /// This bypasses [`HttpClientFactory`] and therefore does not honor its configured outbound
110    /// proxy policy. New product traffic must use [`Self::build_respecting_outbound_proxy_policy`]
111    /// or [`HttpClientFactory::build_client`].
112    #[deprecated(
113        note = "legacy compatibility only; use HttpClientFactory::build_client or build_respecting_outbound_proxy_policy"
114    )]
115    pub fn build_with_transport_default_proxy(
116        self,
117    ) -> Result<HttpClient, BuildCustomCaTransportError> {
118        self.build_with_proxy_routing(ProxyRouting::TransportDefault)
119    }
120
121    /// Builds a client that connects directly without using a proxy.
122    ///
123    /// # Exceptional use only
124    ///
125    /// This bypasses [`HttpClientFactory`] and is appropriate only when bypassing proxy discovery
126    /// is itself required: for example, a hermetic local test fixture, a localhost callback, or
127    /// sandbox traffic whose egress routing is handled separately. Ordinary outbound product
128    /// traffic must use [`Self::build_respecting_outbound_proxy_policy`] or
129    /// [`HttpClientFactory::build_client`].
130    pub fn build_direct(self) -> Result<HttpClient, BuildCustomCaTransportError> {
131        self.build_with_proxy_routing(ProxyRouting::Direct)
132    }
133
134    /// Builds a transport-default client while preserving the legacy custom-CA fallback.
135    ///
136    /// # Legacy compatibility only
137    ///
138    /// This preserves call sites that historically logged a custom-CA error and continued with
139    /// system roots. New product traffic must propagate construction errors through
140    /// [`Self::build_respecting_outbound_proxy_policy`] or [`HttpClientFactory::build_client`].
141    #[deprecated(
142        note = "legacy custom-CA fallback only; use HttpClientFactory::build_client or build_respecting_outbound_proxy_policy"
143    )]
144    pub fn build_with_transport_default_proxy_and_custom_ca_fallback(self) -> HttpClient {
145        self.build_with_custom_ca_fallback(ProxyRouting::TransportDefault)
146    }
147
148    /// Builds a direct client while preserving the legacy custom-CA fallback.
149    ///
150    /// # Legacy compatibility only
151    ///
152    /// This combines the exceptional proxy bypass described by [`Self::build_direct`] with the
153    /// historical behavior of logging a custom-CA error and continuing with system roots.
154    #[deprecated(
155        note = "legacy custom-CA fallback only; use build_direct and propagate construction errors"
156    )]
157    pub fn build_direct_with_custom_ca_fallback(self) -> HttpClient {
158        self.build_with_custom_ca_fallback(ProxyRouting::Direct)
159    }
160
161    fn build_with_proxy_routing(
162        self,
163        proxy_routing: ProxyRouting,
164    ) -> Result<HttpClient, BuildCustomCaTransportError> {
165        let request_logging = self.request_logging;
166        build_reqwest_client_with_custom_ca(self.reqwest_builder(proxy_routing))
167            .map(|inner| HttpClient::from_parts(inner, request_logging))
168    }
169
170    fn build_with_custom_ca_fallback(self, proxy_routing: ProxyRouting) -> HttpClient {
171        self.build_with_custom_ca_fallback_using(proxy_routing, build_reqwest_client_with_custom_ca)
172    }
173
174    fn build_with_custom_ca_fallback_using(
175        self,
176        proxy_routing: ProxyRouting,
177        build_with_custom_ca: impl FnOnce(
178            reqwest::ClientBuilder,
179        )
180            -> Result<reqwest::Client, BuildCustomCaTransportError>,
181    ) -> HttpClient {
182        let request_logging = self.request_logging;
183        match build_with_custom_ca(self.clone().reqwest_builder(proxy_routing)) {
184            Ok(inner) => HttpClient::from_parts(inner, request_logging),
185            Err(error) => {
186                tracing::warn!(error = %error, "failed to build HTTP client with custom CA");
187                self.reqwest_builder(proxy_routing)
188                    .build()
189                    .map(|inner| HttpClient::from_parts(inner, request_logging))
190                    .unwrap_or_else(|fallback_error| {
191                        tracing::warn!(
192                            error = %fallback_error,
193                            "failed to build fallback HTTP client"
194                        );
195                        HttpClient::from_parts(reqwest::Client::new(), request_logging)
196                    })
197            }
198        }
199    }
200
201    fn into_reqwest_parts(self) -> (reqwest::ClientBuilder, RequestLogging) {
202        let request_logging = self.request_logging;
203        (self.base_reqwest_builder(), request_logging)
204    }
205
206    fn reqwest_builder(self, proxy_routing: ProxyRouting) -> reqwest::ClientBuilder {
207        let builder = self.base_reqwest_builder();
208        match proxy_routing {
209            ProxyRouting::TransportDefault => builder,
210            ProxyRouting::Direct => builder.no_proxy(),
211        }
212    }
213
214    fn base_reqwest_builder(self) -> reqwest::ClientBuilder {
215        let mut builder = reqwest::Client::builder();
216        if let Some(default_headers) = self.default_headers {
217            builder = builder.default_headers(default_headers);
218        }
219        if !self.follow_redirects {
220            builder = builder.redirect(reqwest::redirect::Policy::none());
221        }
222        if self.chatgpt_cloudflare_cookie_store {
223            builder = with_chatgpt_cloudflare_cookie_store(builder);
224        }
225        builder
226    }
227}
228
229impl Default for HttpClientBuilder {
230    fn default() -> Self {
231        Self {
232            default_headers: None,
233            follow_redirects: true,
234            chatgpt_cloudflare_cookie_store: false,
235            request_logging: RequestLogging::Enabled,
236        }
237    }
238}
239
240#[derive(Clone, Copy)]
241enum ProxyRouting {
242    TransportDefault,
243    Direct,
244}
245
246#[cfg(test)]
247#[path = "client_builder_tests.rs"]
248mod tests;