Skip to content

Commit 85c12f0

Browse files
committed
perf: Two-phase HTTP client init to avoid macOS Keychain blocking
Build the reqwest HTTP client in two phases to remove ~200ms of macOS Keychain enumeration from the critical path: Phase 1 (instant): Build a client with bundled Mozilla CAs (webpki-roots). Available immediately for any early callers. Phase 2 (background): Build a full client with system Keychain CAs (native-roots) on a background thread. Once ready, all subsequent requests use this client, supporting corporate proxy CAs. This is transparent to all users — standard HTTPS works immediately via Phase 1, and corporate proxy environments get full CA support once Phase 2 completes (~200ms later, well before any remote cache or API request is made).
1 parent 24bd765 commit 85c12f0

File tree

3 files changed

+93
-26
lines changed

3 files changed

+93
-26
lines changed

crates/turborepo-api-client/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ license = "MIT"
77
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
88
[features]
99
native-tls = ["reqwest/native-tls"]
10-
rustls-tls = ["reqwest/rustls-tls-native-roots"]
10+
rustls-tls = [
11+
"reqwest/rustls-tls-native-roots",
12+
"reqwest/rustls-tls-webpki-roots",
13+
]
1114

1215
[dev-dependencies]
1316
anyhow = { workspace = true }

crates/turborepo-api-client/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,30 @@ impl APIClient {
609609
/// resulting client.
610610
#[tracing::instrument(skip_all)]
611611
pub fn build_http_client(connect_timeout: Option<Duration>) -> Result<reqwest::Client> {
612+
Self::build_http_client_with_native_roots(connect_timeout, true)
613+
}
614+
615+
/// Builds an HTTP client using only the bundled Mozilla CA bundle
616+
/// (webpki-roots). This is instant (~0ms) because no system Keychain
617+
/// access is needed. Sufficient for all standard HTTPS connections.
618+
#[tracing::instrument(skip_all)]
619+
pub fn build_http_client_webpki_only(
620+
connect_timeout: Option<Duration>,
621+
) -> Result<reqwest::Client> {
622+
Self::build_http_client_with_native_roots(connect_timeout, false)
623+
}
624+
625+
fn build_http_client_with_native_roots(
626+
connect_timeout: Option<Duration>,
627+
#[allow(unused_variables)] native_roots: bool,
628+
) -> Result<reqwest::Client> {
612629
let mut builder = reqwest::Client::builder();
630+
#[cfg(feature = "rustls-tls")]
631+
{
632+
builder = builder
633+
.tls_built_in_webpki_certs(true)
634+
.tls_built_in_native_certs(native_roots);
635+
}
613636
if let Some(dur) = connect_timeout {
614637
builder = builder.connect_timeout(dur);
615638
}
Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,44 @@
11
use std::sync::{
2-
Arc,
2+
Arc, OnceLock,
33
atomic::{AtomicBool, Ordering},
44
};
55

6-
use tokio::sync::OnceCell;
7-
86
use crate::{APIClient, Error};
97

108
/// Shared reqwest client initialization for all run-time network consumers.
119
///
12-
/// Call `activate()` as soon as a command knows it will need networking, then
13-
/// use `get_or_init()` at the actual point of use. This overlaps TLS/client
14-
/// setup with other startup work without constructing a client for commands
15-
/// that never touch the network.
16-
#[derive(Clone, Default)]
10+
/// Uses two-phase initialization to avoid blocking on macOS Keychain
11+
/// enumeration (~200ms). Phase 1 builds an instant client with bundled
12+
/// Mozilla CAs (webpki-roots). Phase 2 builds a full client with system
13+
/// CAs (native-roots) in the background. Consumers get whichever is
14+
/// best available at the time of use.
15+
#[derive(Clone)]
1716
pub struct SharedHttpClient {
18-
cell: Arc<OnceCell<reqwest::Client>>,
17+
/// Instant client with bundled Mozilla CAs only (~0ms to build).
18+
fast_client: Arc<OnceLock<reqwest::Client>>,
19+
/// Full client with system Keychain CAs (~200ms on macOS).
20+
/// Built in the background; preferred once ready.
21+
native_client: Arc<OnceLock<reqwest::Client>>,
1922
warming: Arc<AtomicBool>,
2023
}
2124

25+
impl Default for SharedHttpClient {
26+
fn default() -> Self {
27+
Self {
28+
fast_client: Arc::new(OnceLock::new()),
29+
native_client: Arc::new(OnceLock::new()),
30+
warming: Arc::new(AtomicBool::new(false)),
31+
}
32+
}
33+
}
34+
2235
impl SharedHttpClient {
2336
pub fn new() -> Self {
2437
Self::default()
2538
}
2639

2740
pub fn activate(&self) {
28-
if self.cell.get().is_some() {
41+
if self.native_client.get().is_some() {
2942
return;
3043
}
3144

@@ -37,26 +50,54 @@ impl SharedHttpClient {
3750
return;
3851
}
3952

40-
let this = self.clone();
41-
tokio::spawn(async move {
42-
let _ = this.get_or_init().await;
43-
this.warming.store(false, Ordering::Release);
53+
// Phase 1: build fast client in background (webpki-roots only, ~0ms).
54+
let fast = self.fast_client.clone();
55+
tokio::task::spawn_blocking(move || {
56+
let _span = tracing::info_span!("http_client_init_fast").entered();
57+
let _ = fast.get_or_init(|| {
58+
APIClient::build_http_client_webpki_only(None)
59+
.expect("failed to build webpki HTTP client")
60+
});
61+
});
62+
63+
// Phase 2: build full client in background (native-roots, ~200ms on macOS).
64+
let native = self.native_client.clone();
65+
let warming = self.warming.clone();
66+
tokio::task::spawn_blocking(move || {
67+
let _span = tracing::info_span!("http_client_init").entered();
68+
let _ = native.get_or_init(|| {
69+
let client =
70+
APIClient::build_http_client(None).expect("failed to build native HTTP client");
71+
warming.store(false, Ordering::Release);
72+
client
73+
});
4474
});
4575
}
4676

4777
pub async fn get_or_init(&self) -> Result<reqwest::Client, Error> {
48-
let client = self
49-
.cell
50-
.get_or_try_init(|| async {
51-
tokio::task::spawn_blocking(|| {
52-
let _span = tracing::info_span!("http_client_init").entered();
53-
APIClient::build_http_client(None)
54-
})
55-
.await
56-
.map_err(|_| Error::HttpClientCancelled)?
78+
// Prefer the full client (includes system CAs for corporate proxies)
79+
if let Some(client) = self.native_client.get() {
80+
return Ok(client.clone());
81+
}
82+
83+
// If the fast client is ready, use it while native is still building
84+
if let Some(client) = self.fast_client.get() {
85+
return Ok(client.clone());
86+
}
87+
88+
// Neither is ready — build the fast client synchronously as fallback
89+
let fast = self.fast_client.clone();
90+
let client = tokio::task::spawn_blocking(move || {
91+
let _span = tracing::info_span!("http_client_init_fast").entered();
92+
fast.get_or_init(|| {
93+
APIClient::build_http_client_webpki_only(None)
94+
.expect("failed to build webpki HTTP client")
5795
})
58-
.await?;
96+
.clone()
97+
})
98+
.await
99+
.map_err(|_| Error::HttpClientCancelled)?;
59100

60-
Ok(client.clone())
101+
Ok(client)
61102
}
62103
}

0 commit comments

Comments
 (0)