Connection Pooling for Test-Lib HTTP Clients
On this page
- Status
- Context
- Changes
- Step 1:
crates/craig-test-lib/src/client.rs— Accept shared client - Step 2:
crates/craig-test-lib/src/clients/*.rs(8 files) — Pass client through - Step 3:
crates/craig-test-lib/src/harness.rs— Store shared client, pass to factories - Step 4:
crates/craig-test-lib/src/lib.rs— Exportshared_http_client - Step 5: Documentation
- Step 1:
- Files Touched
- What Does NOT Change
- Verification
- GitLab
Context
Each ServiceClient::new() in crates/craig-test-lib/src/client.rs creates a fresh reqwest::Client with its own connection pool.
A single integration test that calls 2-3 harness factory methods spawns 2-3 independent connection pools.
With nextest running 16 tests in parallel, this produces 30-50 concurrent connection pools hitting the Docker devstack.
This wastes OS sockets and contributed to the random 30s timeout failures we fixed symptomatically with test-threads = 16 in .config/nextest.toml.
The architecturally correct fix is to share a single reqwest::Client (which internally manages a connection pool with keep-alive) across all ServiceClient instances within a TestHarness.
reqwest::Client uses Arc internally, so .clone() is cheap.
Changes
Step 1: crates/craig-test-lib/src/client.rs — Accept shared client
Change ServiceClient::new to accept a reqwest::Client instead of building one:
// Before
pub fn new(base_url: &str, token: &str) -> Self {
Self {
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build reqwest client"),
base_url: base_url.trim_end_matches('/').to_string(),
token: token.to_string(),
}
}
// After
pub fn new(client: reqwest::Client, base_url: &str, token: &str) -> Self {
Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
token: token.to_string(),
}
}
Add a public constructor for building the shared client (called once by TestHarness):
/// Build a `reqwest::Client` configured for integration tests.
pub fn shared_http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.pool_max_idle_per_host(4)
.build()
.expect("failed to build reqwest client")
}
Leave get_unauthenticated and post_unauthenticated as-is — standalone functions for negative auth tests that intentionally don’t reuse the authenticated pool.
Step 2: crates/craig-test-lib/src/clients/*.rs (8 files) — Pass client through
Each typed client’s new() gains a client: reqwest::Client first parameter:
// Before
pub fn new(base_url: &str, token: &str) -> Self {
Self { inner: ServiceClient::new(base_url, token) }
}
// After
pub fn new(client: reqwest::Client, base_url: &str, token: &str) -> Self {
Self { inner: ServiceClient::new(client, base_url, token) }
}
Files: rules.rs, cases.rs, placement.rs, exchange.rs, financial.rs, reporting.rs, security.rs, intake.rs
Step 3: crates/craig-test-lib/src/harness.rs — Store shared client, pass to factories
Add a client: reqwest::Client field to TestHarness:
pub struct TestHarness {
pub config: TestConfig,
pub tokens: KeycloakTokenProvider,
client: reqwest::Client,
cleanup_stack: Vec<CleanupFn>,
}
Initialize in new():
pub async fn new() -> Result<Self> {
let config = TestConfig::from_env();
let tokens = KeycloakTokenProvider::new(&config);
let client = crate::client::shared_http_client();
Ok(Self { config, tokens, client, cleanup_stack: Vec::new() })
}
Update all 25 factory methods to pass self.client.clone():
pub async fn admin_rules_client(&self) -> Result<RulesClient> {
let token = self.tokens.get_token(ADMIN_USER, ADMIN_PASS).await?;
Ok(RulesClient::new(self.client.clone(), &self.config.rules_url, &token))
}
Update register_delete to capture self.client.clone() instead of reqwest::Client::new():
pub fn register_delete(&mut self, base_url: String, token: String, path: String) {
let client = self.client.clone();
self.register_cleanup(async move {
let _ = client
.delete(format!("{base_url}{path}"))
.bearer_auth(&token)
.send()
.await;
});
}
Files Touched
| File | Change |
|---|---|
|
Add |
|
Pass |
|
Pass |
|
Pass |
|
Pass |
|
Pass |
|
Pass |
|
Pass |
|
Pass |
|
Add |
|
Export |
|
Document connection pooling |
|
Update direct |
What Does NOT Change
-
get_unauthenticated/post_unauthenticated— standalone functions for negative auth tests, intentionally separate -
devstack_available()inlib.rs— one-shot connectivity check, fine with its own client -
Service production code — this only touches
craig-test-lib -
.config/nextest.tomltest-threads = 16— kept as defense-in-depth
Verification
-
cargo nextest run --workspace --lib— unit tests pass -
cargo xtask dev reload— rebuild services -
cargo nextest run --workspace— all integration tests pass with shared pool -
cargo xtask e2e— E2E tests pass
GitLab
-
Branch:
feature/connection-pooling -
Issue:
refactor: Share reqwest::Client across test-lib ServiceClients -
Labels:
refactor,P2-medium -
Weight: 3