HTTP Client路由与中间件
本文承接 RateLimit与配额状态 和 Responses重试策略。前文已经说明模型请求如何消费响应和重试预算;本文向下追踪这些请求真正离开进程之前经过的共享传输层。
问题边界很窄:当一个请求的目标 URL、代理配置或重定向目标发生变化时,Codex 如何保证“解析的路线就是实际发送的路线”?同时,认证头、Cookie、追踪上下文和自定义 CA 又在哪一层进入请求?本文不讨论上层 retry、SSE 事件分类或 WebSocket 消息协议;这些策略由更高层的 client 和 API crate 消费本层的结果。
读者读完后应能完成一条源码复述:HttpClientFactory 先确定策略,RouteAwareClientPool 按完整 URL 解析路线、按解析结果复用 client,HttpClient/RequestBuilder 执行请求,重定向则回到 pool 重新解析;如果请求跨 origin,敏感头会在构造下一跳时被移除。还应能解释为什么连接超时和整个请求超时不是同一个预算,以及为什么 CODEX_CA_CERTIFICATE 同时影响 HTTP 和安全 WebSocket。
1. 先看传输边界
codex-http-client 是 workspace 里直接拥有 reqwest 的低层 crate。它输出两套相互衔接的接口:固定目标用 HttpClient,目标或重定向会变化时用 RouteAwareClientPool;更高层的 HttpTransport 再把它们转换成字节响应或流响应。
图中“策略”和“客户端”不是同一对象。策略决定是否由 Codex 自己解析系统代理;客户端则绑定一个已经确定的传输行为。这个分离让同一个 factory 可以被登录、API、WebSocket 等不同 owner 复用,而不会让每个调用方各自读取 feature 或环境变量。
源码位置:codex-rs/http-client/src/outbound_proxy.rs :: OutboundProxyPolicy、HttpClientFactory
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutboundProxyPolicy {
/// Preserve reqwest's built-in proxy behavior.
ReqwestDefault,
/// Resolve system/PAC/WPAD settings, then environment settings, then direct routing.
RespectSystemProxy,
}
#[derive(Clone)]
pub struct HttpClientFactory {
outbound_proxy_policy: OutboundProxyPolicy,
chatgpt_cookie_store: Option<Arc<ChatGptCookieStore>>,
}
impl HttpClientFactory {
pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self {
Self {
outbound_proxy_policy,
chatgpt_cookie_store: None,
}
}
pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
self.outbound_proxy_policy
}
}ReqwestDefault 保留传输库自己的代理行为;RespectSystemProxy 才会走 Codex 的系统/PAC/WPAD、环境变量、直连回退链。factory 不保存某个 URL 的路线,它只保存“如何决定路线”的策略和可选的 ChatGPT cookie store。因此它适合长生命周期地放在 session 或组件上,而不是每个请求临时创建。
2. 客户端构建
固定目标的调用方通过 HttpClientFactory::build_client 进入 HttpClientBuilder。Builder 负责默认 Header、连接超时、重定向开关、日志开关和 custom CA;factory 负责把这些 builder 配置交给已解析的 proxy route。
源码位置:codex-rs/http-client/src/client_builder.rs :: HttpClientFactory::build_client、HttpClientBuilder::build_respecting_outbound_proxy_policy
impl HttpClientFactory {
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,
)
}
}
impl HttpClientBuilder {
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))
}
}这里有一个容易忽略的所有权边界:HttpClient 持有已经构建好的 reqwest::Client,而不是持有 factory 的可变引用。之后同一个 HttpClient 的请求不会重新选择代理;如果 URL 可能变,调用方必须提升到 pool,否则重定向会在错误的 route 上继续执行。
3. 路由解析有顺序
RespectSystemProxy 的解析顺序是可观察的行为:平台系统设置优先,环境代理作为回退,最后是直连。ws/wss 会先转换成 HTTP 等价 scheme,让系统代理 API 用同一套解析逻辑处理 HTTP 和 WebSocket。
源码位置:codex-rs/http-client/src/outbound_proxy.rs :: HttpClientFactory::resolve_proxy_route_async
pub async fn resolve_proxy_route_async(
&self,
request_url: String,
) -> io::Result<OutboundProxyRoute> {
if matches!(
self.outbound_proxy_policy,
OutboundProxyPolicy::ReqwestDefault
) {
return Ok(OutboundProxyRoute::TransportDefault);
}
if let Some(route) = self.cached_proxy_route(&request_url) {
return Ok(route);
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
return Ok(self.resolve_proxy_route(&request_url));
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
let permit = ASYNC_SYSTEM_PROXY_RESOLUTION_PERMIT
.acquire()
.await
.map_err(io::Error::other)?;
let factory = self.clone();
tokio::task::spawn_blocking(move || {
// Keep the permit with the blocking task while PAC/WinHTTP lookup is running.
let _permit = permit;
factory.resolve_proxy_route(&request_url)
})
.await
.map_err(io::Error::other)
}
}缓存命中发生在系统解析之前;macOS 和 Windows 的阻塞式系统查询则被放进 spawn_blocking,并用全局 permit 限制并发。取消等待方不会让另一个 PAC/WinHTTP 查询立刻越过仍在运行的阻塞任务,这是资源清理和并发边界的一部分,而不是性能细节。
4. URL路由与缓存
pool 的关键不是“缓存一个 client”,而是用解析结果作为缓存键。不同目标 URL 可能被 PAC 送到不同 proxy;如果只按 host 或 origin 复用,就会把一个目标的路线错误地带给另一个目标。
源码位置:codex-rs/http-client/src/route_aware_client_pool.rs :: RouteAwareClientPool、send_with_resolver
const MAX_CACHED_ROUTES: usize = 16;
#[derive(Clone)]
pub struct RouteAwareClientPool {
http_client_factory: HttpClientFactory,
route_class: ClientRouteClass,
client_builder: HttpClientBuilder,
custom_ca_fallback: CustomCaFallback,
clients: Arc<Mutex<HashMap<OutboundProxyRoute, HttpClient>>>,
}
async fn send_with_resolver<F, Fut>(
&self,
mut request: reqwest::Request,
resolve_route: F,
) -> Result<reqwest::Response, RouteAwareRequestError>
where
F: Fn(String) -> Fut,
Fut: Future<Output = io::Result<OutboundProxyRoute>>,
{
let timeout_deadline = request
.timeout()
.copied()
.map(|timeout| tokio::time::Instant::now() + timeout);
let mut redirects = 0;
let mut previous_route = None;
loop {
let current_url = request.url().clone();
let (current_route, client) = match timeout_deadline {
Some(deadline) => tokio::time::timeout_at(
deadline,
self.client_for_url_with_resolver(current_url.as_str(), &resolve_route),
)
.await
.map_err(|_| RouteAwareRequestError::Timeout)??,
None => self
.client_for_url_with_resolver(current_url.as_str(), &resolve_route)
.await?,
};
if previous_route
.as_ref()
.is_some_and(|previous| previous != ¤t_route)
{
request.headers_mut().remove(PROXY_AUTHORIZATION);
}
previous_route = Some(current_route);
// The remaining request budget is carried across route resolution and redirects.
if let Some(deadline) = timeout_deadline {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.ok_or(RouteAwareRequestError::Timeout)?;
if remaining.is_zero() {
return Err(RouteAwareRequestError::Timeout);
}
*request.timeout_mut() = Some(remaining);
}
let method = request.method().clone();
let headers = request.headers().clone();
let version = request.version();
let timeout = request.timeout().copied();
let replay = request.try_clone();
let execute_request = async {
if follows_redirects_manually {
client.execute_without_request_logging(request).await
} else {
client.execute(request).await
}
};
let response = match match timeout_deadline {
Some(timeout_deadline) => {
tokio::time::timeout_at(timeout_deadline, execute_request)
.await
.map_err(|_| RouteAwareRequestError::Timeout)?
}
None => execute_request.await,
} {
Ok(response) => response,
Err(error) => {
if follows_redirects_manually {
client.log_error_summary(&request_method, &request_url, &error);
}
return Err(error.into());
}
};
let status = response.status();
if !follows_redirects_manually || !is_redirect(status) {
return Ok(response);
}
let Some(next_url) = redirect_url(&response) else {
return Ok(response);
};
let Some(mut next_request) = redirect_request(
status,
method,
headers,
version,
timeout,
replay,
next_url,
) else {
return Ok(response);
};
let next_request_url = next_request.url().clone();
if !matches!(next_request_url.scheme(), "http" | "https") {
return Err(RouteAwareRequestError::UnsupportedRedirectScheme(
next_request_url.scheme().to_string(),
));
}
if redirects >= MAX_REDIRECTS {
return Err(RouteAwareRequestError::TooManyRedirects);
}
remove_sensitive_headers(next_request.headers_mut(), ¤t_url, &next_request_url);
insert_referer(next_request.headers_mut(), ¤t_url, &next_request_url);
request = next_request;
redirects += 1;
}
}这段循环同时表达了三个保证。第一,路线以当前完整 URL 解析;第二,超时 deadline 包含路线解析、client 构造、发送和所有重定向跳转;第三,下一跳会重新进入循环,而不是复用旧 client。缓存只解决连接复用,不改变路线决策。
5. 重定向重新计算
当策略是 RespectSystemProxy 时,reqwest 的内部自动重定向会被关闭,pool 自己观察 3xx 响应并构造下一请求。这样每个 hop 都能获得自己的路线。状态码还决定方法和 body 是否保留:POST 的 301/302 会改成 GET,303 除 HEAD 外也改成 GET,而 307/308 要求 body 可重放。
源码位置:codex-rs/http-client/src/route_aware_redirect.rs :: redirect_request
pub(super) fn redirect_request(
status: StatusCode,
mut method: Method,
mut headers: HeaderMap,
version: http::Version,
timeout: Option<Duration>,
replay: Option<reqwest::Request>,
next_url: reqwest::Url,
) -> Option<reqwest::Request> {
let drop_body = match status {
StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND if method == Method::POST => {
method = Method::GET;
true
}
StatusCode::SEE_OTHER => {
if method != Method::HEAD {
method = Method::GET;
}
true
}
StatusCode::MOVED_PERMANENTLY
| StatusCode::FOUND
| StatusCode::TEMPORARY_REDIRECT
| StatusCode::PERMANENT_REDIRECT => false,
_ => return None,
};
if drop_body {
for header in [
CONTENT_TYPE,
CONTENT_LENGTH,
CONTENT_ENCODING,
TRANSFER_ENCODING,
] {
headers.remove(header);
}
let mut request = reqwest::Request::new(method, next_url);
*request.headers_mut() = headers;
*request.version_mut() = version;
*request.timeout_mut() = timeout;
Some(request)
} else {
replay.map(|mut request| {
*request.url_mut() = next_url;
request
})
}
}如果 body 是一次性流,try_clone() 会返回 None,307/308 就不能安全跟随。当前实现不会把它改写成 GET,也不会报一个新的 redirect error,而是把当前 3xx response 作为最终响应返回;上层因此仍能观察服务端原始状态。
6. 跨源要清理凭据
重定向不仅改变路线,也可能改变安全边界。Codex 以 scheme、host 和有效端口判断 origin;跨 origin 时移除 Authorization、Cookie、Proxy-Authorization 和 WWW-Authenticate。同源仍可保留认证头,但 Referer 会去掉用户名、密码和 fragment。
源码位置:codex-rs/http-client/src/route_aware_redirect.rs :: remove_sensitive_headers、insert_referer
pub(super) fn remove_sensitive_headers(
headers: &mut HeaderMap,
previous: &reqwest::Url,
next: &reqwest::Url,
) {
if !same_origin(previous, next) {
for header in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION, WWW_AUTHENTICATE] {
headers.remove(header);
}
headers.remove("cookie2");
}
}
pub(super) fn insert_referer(
headers: &mut HeaderMap,
previous: &reqwest::Url,
next: &reqwest::Url,
) {
headers.remove(REFERER);
if next.scheme() == "http" && previous.scheme() == "https" {
return;
}
let mut referer = previous.clone();
let _ = referer.set_username("");
let _ = referer.set_password(None);
referer.set_fragment(None);
if !same_origin(previous, next) {
referer.set_path("/");
referer.set_query(None);
}
if let Ok(value) = referer.as_str().parse() {
headers.insert(REFERER, value);
}
}这里的安全策略是“重定向边界上的最小泄漏”,不是完整的认证策略。它不能阻止调用方把秘密放进 URL,也不能替代上层对签名 URL 的生命周期管理;但它保证了 pool 自己构造的跨源下一跳不会自动携带常见凭据头。
7. 请求执行
固定客户端的 RequestBuilder::send 在真正发送前注入当前 tracing span 的 Header,并在成功或失败时统一记录诊断。new_without_request_logging 只关闭 URL 和响应头诊断,不会关闭请求本身,也不会关闭 trace Header。
源码位置:codex-rs/http-client/src/client.rs :: HttpClient::execute_without_request_logging、RequestBuilder::send
pub(crate) async fn execute_without_request_logging(
&self,
mut request: reqwest::Request,
) -> Result<reqwest::Response, reqwest::Error> {
request.headers_mut().extend(trace_headers());
self.inner.execute(request).await
}
pub async fn send(self) -> Result<HttpResponse, HttpError> {
let headers = trace_headers();
match self.builder.headers(headers).send().await {
Ok(response) => {
if self.request_logging == RequestLogging::Enabled {
tracing::debug!(
method = %self.method,
url = %self.url,
status = %response.status(),
headers = ?response.headers(),
version = ?response.version(),
"Request completed"
);
}
Ok(response)
}
Err(error) => {
if self.request_logging == RequestLogging::Enabled {
tracing::debug!(
method = %self.method,
url = %self.url,
status = error.status().map(|s| s.as_u16()),
error = %error,
"Request failed"
);
}
Err(error)
}
}
}追踪 Header 的注入依赖当前 span,而日志开关只影响诊断字段。两者分开是重要的:生产排障可能需要 trace propagation,却不能把包含 credential 的 URL 或响应头写入日志。需要更严格边界的调用方应从 factory 构建关闭日志的 client 或 pool。
8. HTTP与流共享策略
ReqwestTransport 是另一条消费边界。它把统一的 Request 转成 reqwest builder,普通执行会读取完整 body;流式执行则保留 bytes_stream。非成功状态统一变成带 status、headers 和可选 body 的 TransportError::Http,成功响应才进入上层解析器。
源码位置:codex-rs/http-client/src/transport.rs :: ReqwestTransport::execute、ReqwestTransport::stream
impl HttpTransport for ReqwestTransport {
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
self.trace_request(&req);
let url = req.url.clone();
let builder = self.build(req)?;
let resp = builder.send().await.map_err(Self::map_error)?;
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp.bytes().await.map_err(Self::map_error)?;
if !status.is_success() {
let body = String::from_utf8(bytes.to_vec()).ok();
return Err(TransportError::Http {
status,
url: Some(url),
headers: Some(headers),
body,
});
}
Ok(Response {
status,
headers,
body: bytes,
})
}
async fn stream(&self, req: Request) -> Result<StreamResponse, TransportError> {
self.trace_request(&req);
let url = req.url.clone();
let builder = self.build(req)?;
let resp = builder.send().await.map_err(Self::map_error)?;
let status = resp.status();
let headers = resp.headers().clone();
if !status.is_success() {
let body = resp.text().await.ok();
return Err(TransportError::Http {
status,
url: Some(url),
headers: Some(headers),
body,
});
}
let stream = resp
.bytes_stream()
.map(|result| result.map_err(Self::map_error));
Ok(StreamResponse {
status,
headers,
bytes: Box::pin(stream),
})
}
}普通执行和流式执行共享错误映射,但 body 生命周期不同:前者在返回前已经收集 bytes,后者把读取责任交给消费者。因而“HTTP 200”只证明传输层接受响应,不能证明 SSE 或 JSON 业务解析成功;后续 crate 仍必须处理流中断和格式错误。
9. 自定义 CA覆盖
TLS 根证书由 custom_ca 统一处理。CODEX_CA_CERTIFICATE 优先于 SSL_CERT_FILE,空值视为未设置;如果选中了 bundle,构建 HTTP client 时强制使用 rustls,并把 PEM 中的每个证书注册为额外根。WebSocket 侧使用同一逻辑构造 rustls ClientConfig。
源码位置:codex-rs/http-client/src/custom_ca.rs :: build_reqwest_client_with_custom_ca、maybe_build_rustls_client_config_with_custom_ca
pub fn build_reqwest_client_with_custom_ca(
builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, BuildCustomCaTransportError> {
build_reqwest_client_with_env(&ProcessEnv, builder)
}
pub fn maybe_build_rustls_client_config_with_custom_ca()
-> Result<Option<Arc<ClientConfig>>, BuildCustomCaTransportError> {
maybe_build_rustls_client_config_with_env(&ProcessEnv)
}
fn build_reqwest_client_with_env(
env_source: &dyn EnvSource,
mut builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, BuildCustomCaTransportError> {
if let Some(bundle) = env_source.configured_ca_bundle() {
ensure_rustls_crypto_provider();
builder = builder.use_rustls_tls();
let certificates = bundle.load_certificates()?;
for (idx, cert) in certificates.iter().enumerate() {
let certificate = match reqwest::Certificate::from_der(cert.as_ref()) {
Ok(certificate) => certificate,
Err(source) => {
warn!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
certificate_index = idx + 1,
error = %source,
"failed to register CA certificate"
);
return Err(BuildCustomCaTransportError::RegisterCertificate {
source_env: bundle.source_env,
path: bundle.path.clone(),
certificate_index: idx + 1,
source,
});
}
};
builder = builder.add_root_certificate(certificate);
}
return match builder.build() {
Ok(client) => Ok(client),
Err(source) => {
warn!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
error = %source,
"failed to build client after loading custom CA bundle"
);
Err(BuildCustomCaTransportError::BuildClientWithCustomCa {
source_env: bundle.source_env,
path: bundle.path.clone(),
source,
})
}
};
}
info!(
codex_ca_certificate_configured = false,
ssl_cert_file_configured = false,
"using system root certificates because no CA override environment variable was selected"
);
match builder.build() {
Ok(client) => Ok(client),
Err(source) => {
warn!(
error = %source,
"failed to build client while using system root certificates"
);
Err(BuildCustomCaTransportError::BuildClientWithSystemRoots(
source,
))
}
}
}自定义 CA 失败时不是“默默退回系统根”:新的 policy-aware 路径返回结构化 BuildCustomCaTransportError,其中区分读取失败、PEM 无效、证书注册失败和最终 client 构建失败。旧的 transport-default fallback 只为迁移兼容保留,不能据此推断所有调用方都会忽略 CA 错误。
10. 失败与清理
整个请求的失败点至少有三类:路线解析或 client 构建失败、连接/读取超时、重定向目标使用不支持的 scheme 或超过跳转上限。body 无法安全重放并不产生新错误,而是停止自动跳转并返回当前 3xx 响应。pool 对前述错误使用 RouteAwareRequestError 统一包装;如果 URL 本身包含秘密,调用方还可以在返回或记录前调用 without_url。
自定义 CA 的环境选择也有明确的优先级:
空值不是路径:configured_ca_bundle 会把空环境变量视为未设置,只有真正选中的 bundle 才进入 PEM 解析和 rustls 根注册。这个边界解释了为什么“设置了变量但值为空”不会制造一个指向空路径的构建错误。
取消调用方的 future 不会撤销已经进入操作系统或第三方库的所有同步工作;因此 macOS/Windows 的系统代理查询把 permit 交给 spawn_blocking 任务持有,避免取消外层 future 后并发查询失控。另一方面,pool 内部的 timeout deadline 会在每一跳重新计算剩余时间,防止重定向把请求预算无限延长。
11. 源码验证
11.1 路由与缓存
route_aware_client_pool_tests.rs 的 forwards_exact_urls_and_caches_clients_by_resolved_route 使用注入的 resolver 记录完整 URL,并让同一路线的请求复用 client。它证明的是“URL 传给 resolver 且 route 是缓存键”,不证明真实 PAC 文件在每个平台上的行为。
源码位置:codex-rs/http-client/src/route_aware_client_pool_tests.rs :: forwards_exact_urls_and_caches_clients_by_resolved_route
#[tokio::test]
async fn forwards_exact_urls_and_caches_clients_by_resolved_route() {
let pool = RouteAwareClientPool::with_builder(
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
ClientRouteClass::Api,
HttpClientBuilder::new(),
);
let direct_url = "https://example.com/first?target=direct";
let same_route_url = "https://example.com/second?target=direct%202";
let proxy_url = "https://example.com/third?target=proxy";
let resolver = FakeRouteResolver::new(HashMap::from([
(direct_url.to_string(), OutboundProxyRoute::Direct),
(same_route_url.to_string(), OutboundProxyRoute::Direct),
(
proxy_url.to_string(),
OutboundProxyRoute::Proxy {
url: "http://proxy.example".to_string(),
no_proxy: None,
},
),
]));
resolve_with(&pool, &resolver, direct_url)
.await
.expect("first client should build");
resolve_with(&pool, &resolver, same_route_url)
.await
.expect("second client should reuse the route");
resolve_with(&pool, &resolver, proxy_url)
.await
.expect("proxy client should build separately");
assert_eq!(pool.clients.lock().expect("client cache lock").len(), 2);
assert_eq!(
resolver.observed_urls(),
vec![
direct_url.to_string(),
same_route_url.to_string(),
proxy_url.to_string(),
]
);
}11.2 重定向安全
route_aware_redirect_tests.rs 的 redirect_credentials_are_retained_only_for_the_same_origin 直接构造前后 URL 和 HeaderMap,断言同源保留凭据、跨源删除凭据。它证明 helper 的 origin 判定,不证明上层服务一定会返回合法 Location。
源码位置:codex-rs/http-client/src/route_aware_redirect_tests.rs :: redirect_credentials_are_retained_only_for_the_same_origin
#[test]
fn redirect_credentials_are_retained_only_for_the_same_origin() {
for (previous, next, retain_credentials) in [
("https://example.com:8080/start", "https://example.com:8080/next", true),
("https://example.com:8080/start", "http://example.com:8080/next", false),
("https://example.com:8080/start", "https://other.example:8080/next", false),
("https://example.com:8080/start", "https://example.com:8081/next", false),
] {
let previous = reqwest::Url::parse(previous).expect("previous URL should parse");
let next = reqwest::Url::parse(next).expect("next URL should parse");
let mut headers = HeaderMap::from_iter([
(AUTHORIZATION, HeaderValue::from_static("Bearer secret")),
(COOKIE, HeaderValue::from_static("session=secret")),
]);
remove_sensitive_headers(&mut headers, &previous, &next);
assert_eq!(
(
headers.contains_key(AUTHORIZATION),
headers.contains_key(COOKIE),
),
(retain_credentials, retain_credentials),
"credential handling for {previous} -> {next}"
);
}
}11.3 超时边界
request_timeout_covers_route_selection 和 request_timeout_is_shared_across_redirect_hops 验证 timeout deadline 不只包住 send():路线解析、client 获取和重定向都消耗同一个预算。它们证明的是 pool 的时间边界,不证明底层 DNS 或 TLS 库内部每个系统调用都能被提前中断。
11.4 CA优先级
custom_ca.rs 中的 ca_path_prefers_codex_env、ca_path_falls_back_to_ssl_cert_file 和 ca_path_ignores_empty_values 用注入的环境源避免修改真实进程环境,分别证明优先级、回退和空值语义;rustls_config_reports_invalid_ca_file 则证明错误会保留来源环境和路径上下文。它们不证明某个企业 CA 的证书链一定能通过远端服务器验证。
12. 实践验证
先不看源码,回答两个问题:
- 为什么
RespectSystemProxy不能让 reqwest 在一个 client 内部自动跟随重定向?请指出“完整 URL 路由”和“按路线缓存”之间的关系。 - 一个 POST 被 307 重定向到另一个 origin 时,哪些条件决定它能否继续?如果继续,哪些 Header 必须被清理?
然后做一次只读验证:在 route_aware_client_pool_tests.rs 中找到跨 hop timeout 测试,沿着 timeout_deadline 搜索它如何覆盖 route resolution、client execution 和 redirect;再在 route_aware_redirect.rs 中找到 same_origin,列出它比较的三个 URL 属性。完成后,你应能区分“请求预算耗尽”“body 不可重放”和“跨源凭据被删除”三种不同用户现象。
也可以直接运行本文引用的最小验证集:
cargo test -p codex-http-client forwards_exact_urls_and_caches_clients_by_resolved_route
cargo test -p codex-http-client redirect_credentials_are_retained_only_for_the_same_origin
cargo test -p codex-http-client request_timeout_is_shared_across_redirect_hops
cargo test -p codex-http-client ca_path_prefers_codex_env这些命令验证四个局部契约;它们不会替代真实 PAC/WPAD 环境、远端 TLS 握手或上层 retry 的集成验证。
13. 技术边界
这些测试覆盖共享 HTTP transport 的结构和关键安全边界:策略选择、按 route 复用、重定向重算、日志控制、追踪注入和 custom CA。它们没有覆盖上层 API 的 retry 是否会重放一个不可重放 body,也没有覆盖每个操作系统的 PAC/WPAD 实现细节;这些结论需要分别回到更高层 client 和平台专用 resolver 测试。
下一步阅读 WebSocket Client生命周期 时,重点观察同一个 HttpClientFactory 如何跨 HTTP 与 WebSocket 传递代理策略和 custom CA,而 WebSocket 自己又如何管理连接、心跳、收发 task 与关闭。
