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
This commit is contained in:
Abhinav
2026-09-19 01:37:14 +00:00
committed by copyberry
parent 888be42a20
commit 3bb0a530d1
23 changed files with 1134 additions and 71 deletions
+1
View File
@@ -24,6 +24,7 @@ codex-http-client = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
tokio = { workspace = true, features = ["time"] }
[dev-dependencies]
pretty_assertions = "1"
+38 -4
View File
@@ -12,8 +12,10 @@ use anyhow::Result;
use codex_api::SharedAuthProvider;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_http_client::RouteAwareClientPool;
use codex_http_client::RouteAwareRequestBuilder;
use codex_http_client::RouteAwareRequestError;
use codex_login::CodexAuth;
use codex_login::default_client::get_codex_user_agent;
use codex_protocol::account::PlanType as AccountPlanType;
@@ -33,6 +35,7 @@ use http::header::USER_AGENT;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fmt;
use std::time::Duration;
pub(crate) mod analytics;
mod chatgpt_turn_cost;
@@ -320,6 +323,39 @@ impl Client {
Ok((body, content_type))
}
async fn exec_bootstrap_get(
&self,
url: &str,
) -> std::result::Result<(String, String), RequestError> {
let request = self.request(Method::GET, url).headers(self.headers());
if !self.http.allows_system_proxy_fallback() {
return self.exec_request_detailed(request, "GET", url).await;
}
// Bound the complete GET, including its body, to leave time for proxy discovery and retry
// within the cloud loader's startup budget. GETs are safe to retry after a response timeout.
match tokio::time::timeout(
Duration::from_secs(5),
self.exec_request_detailed(request, "GET", url),
)
.await
{
Ok(Err(RequestError::Other(error)))
if error
.downcast_ref::<RouteAwareRequestError>()
.is_some_and(|error| error.is_connect() || error.is_timeout()) => {}
Err(_) => {}
Ok(response) => return response,
}
let http = self
.http
.clone()
.with_outbound_proxy_policy(OutboundProxyPolicy::RespectSystemProxy);
let request = http.get(url).headers(self.headers());
self.exec_request_detailed(request, "GET", url).await
}
fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) {
Ok(v) => Ok(v),
@@ -349,8 +385,7 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url),
};
let req = self.request(Method::GET, &url).headers(self.headers());
let (body, _) = self.exec_request_detailed(req, "GET", &url).await?;
let (body, _) = self.exec_bootstrap_get(&url).await?;
serde_json::from_str(&body)
.map_err(|_| RequestError::Other(anyhow::anyhow!("Invalid accounts response.")))
}
@@ -480,8 +515,7 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/config/bundle", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/config/bundle", self.base_url),
};
let req = self.request(Method::GET, &url).headers(self.headers());
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
let (body, ct) = self.exec_bootstrap_get(&url).await?;
self.decode_json::<ConfigBundleResponse>(&url, &ct, &body)
.map_err(RequestError::from)
}
@@ -1,11 +1,21 @@
use std::io::BufRead;
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_http_client::cache_system_proxy_route_for_test;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
@@ -142,3 +152,197 @@ async fn migrated_requests_preserve_query_auth_and_json_body() {
);
assert!(requests[1].ends_with(r#"{"prompt":"hello"}"#));
}
const BLOCKED_ORIGIN: &str = "http://127.0.0.1:0";
fn run_without_environment_proxies(test_name: &str) -> bool {
const CHILD: &str = "CODEX_BOOTSTRAP_PROXY_TEST_CHILD";
if std::env::var_os(CHILD).is_some() {
return false;
}
let mut command = Command::new(std::env::current_exe().expect("test executable"));
command.arg("--exact").arg(test_name).env(CHILD, "1");
for key in [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
] {
command.env_remove(key);
}
let output = command.output().expect("run isolated proxy test");
assert!(
output.status.success(),
"{test_name} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
true
}
fn fallback_client(base_url: &str) -> Client {
Client::new(
base_url,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault).with_system_proxy_fallback(),
)
.with_auth_provider(Arc::new(codex_model_provider::BearerAuthProvider::new(
"bootstrap-token".to_string(),
)))
.with_chatgpt_account_id("workspace-123")
.with_user_agent("bootstrap-test")
}
#[tokio::test]
async fn bootstrap_gets_resolve_each_fallback_destination_and_preserve_headers() {
if run_without_environment_proxies(
"client::request_tests::bootstrap_gets_resolve_each_fallback_destination_and_preserve_headers",
) {
return;
}
for (base_suffix, api_prefix) in [("", "/api/codex"), ("/backend-api", "/wham")] {
let bundle_proxy = MockServer::start().await;
let accounts_proxy = MockServer::start().await;
let base_url = format!("{BLOCKED_ORIGIN}{base_suffix}");
let client = fallback_client(&base_url);
for (endpoint, proxy) in [
("config/bundle", &bundle_proxy),
("accounts/check", &accounts_proxy),
] {
let endpoint_path = format!("{base_suffix}{api_prefix}/{endpoint}");
cache_system_proxy_route_for_test(
&format!("{base_url}{api_prefix}/{endpoint}"),
proxy.uri(),
);
Mock::given(method("GET"))
.and(path(endpoint_path))
.and(header("authorization", "Bearer bootstrap-token"))
.and(header("chatgpt-account-id", "workspace-123"))
.and(header("user-agent", "bootstrap-test"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(proxy)
.await;
}
client.get_config_bundle().await.expect("cloud bundle");
client.get_accounts_check().await.expect("accounts check");
bundle_proxy.verify().await;
accounts_proxy.verify().await;
}
}
#[tokio::test]
async fn bootstrap_gets_keep_default_responses_without_proxy_retry() {
if run_without_environment_proxies(
"client::request_tests::bootstrap_gets_keep_default_responses_without_proxy_retry",
) {
return;
}
for status in [200, 401] {
let issuer = MockServer::start().await;
let proxy = MockServer::start().await;
for endpoint in ["config/bundle", "accounts/check"] {
cache_system_proxy_route_for_test(
&format!("{}/api/codex/{endpoint}", issuer.uri()),
proxy.uri(),
);
}
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(status).set_body_json(serde_json::json!({})))
.expect(2)
.mount(&issuer)
.await;
let client = fallback_client(&issuer.uri());
let bundle = client.get_config_bundle().await;
let accounts = client.get_accounts_check().await;
if status == 200 {
bundle.expect("default cloud route succeeds");
accounts.expect("default accounts route succeeds");
} else {
assert_eq!(
[
bundle.expect_err("cloud status error").status(),
accounts.expect_err("accounts status error").status(),
],
[Some(http::StatusCode::UNAUTHORIZED); 2]
);
}
issuer.verify().await;
assert_eq!(proxy.received_requests().await.unwrap_or_default().len(), 0);
}
}
#[tokio::test]
async fn bootstrap_gets_honor_disabled_fallback() {
if run_without_environment_proxies(
"client::request_tests::bootstrap_gets_honor_disabled_fallback",
) {
return;
}
let proxy = MockServer::start().await;
let base_url = BLOCKED_ORIGIN;
for endpoint in ["config/bundle", "accounts/check"] {
cache_system_proxy_route_for_test(&format!("{base_url}/api/codex/{endpoint}"), proxy.uri());
}
let client = Client::new(
base_url,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
assert!(client.get_config_bundle().await.is_err());
assert!(client.get_accounts_check().await.is_err());
assert_eq!(proxy.received_requests().await.unwrap_or_default().len(), 0);
}
#[tokio::test]
async fn bootstrap_get_recovers_from_stalled_body_before_cloud_startup_timeout() {
if run_without_environment_proxies(
"client::request_tests::bootstrap_get_recovers_from_stalled_body_before_cloud_startup_timeout",
) {
return;
}
let issuer = TcpListener::bind(("127.0.0.1", 0)).expect("bind stalled issuer");
let issuer_url = format!("http://{}", issuer.local_addr().expect("issuer address"));
let (finish, finished) = std::sync::mpsc::channel();
let issuer_thread = std::thread::spawn(move || {
let (mut stream, _) = issuer.accept().expect("accept bootstrap GET");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set read timeout");
let mut request = String::new();
std::io::BufReader::new(&stream)
.read_line(&mut request)
.expect("read request line");
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{",
)
.expect("send headers and incomplete body");
finished
.recv_timeout(Duration::from_secs(20))
.expect("proxy request finishes while issuer body is pending");
});
let proxy = MockServer::start().await;
cache_system_proxy_route_for_test(
&format!("{issuer_url}/api/codex/config/bundle"),
proxy.uri(),
);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&proxy)
.await;
let result = tokio::time::timeout(
Duration::from_secs(15),
fallback_client(&issuer_url).get_config_bundle(),
)
.await;
finish.send(()).expect("release stalled issuer");
issuer_thread.join().expect("issuer finishes");
result
.expect("proxy retry must fit inside the cloud startup budget")
.expect("cloud bundle via proxy");
proxy.verify().await;
}