Skip to content

HTTP Client路由与中间件

追踪 Codex 如何把代理决策、路由复用、重定向、TLS 根证书、追踪 Header 与敏感日志收口到统一 HTTP Client。

基于rust-v0.150.0
CodexRustModelHTTP

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

rust
#[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

rust
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

rust
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

rust
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 != &current_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(), &current_url, &next_request_url);
        insert_referer(next_request.headers_mut(), &current_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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
#[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

rust
#[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. 实践验证 ​

先不看源码,回答两个问题:

  1. 为什么 RespectSystemProxy 不能让 reqwest 在一个 client 内部自动跟随重定向?请指出“完整 URL 路由”和“按路线缓存”之间的关系。
  2. 一个 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 不可重放”和“跨源凭据被删除”三种不同用户现象。

也可以直接运行本文引用的最小验证集:

bash
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 与关闭。