Connection Pooling for Test-Lib HTTP Clients

On this page

Status

COMPLETE

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;
    });
}

Step 4: crates/craig-test-lib/src/lib.rs — Export shared_http_client

pub use client::{ApiResponse, ServiceClient, get_unauthenticated, post_unauthenticated, shared_http_client};

Step 5: Documentation

Update .claude/docs/shared-crates.md to document shared_http_client() and the connection pooling design.

Files Touched

File Change

crates/craig-test-lib/src/client.rs

Add shared_http_client(), change ServiceClient::new signature

crates/craig-test-lib/src/clients/rules.rs

Pass client through new()

crates/craig-test-lib/src/clients/cases.rs

Pass client through new()

crates/craig-test-lib/src/clients/placement.rs

Pass client through new()

crates/craig-test-lib/src/clients/exchange.rs

Pass client through new()

crates/craig-test-lib/src/clients/financial.rs

Pass client through new()

crates/craig-test-lib/src/clients/reporting.rs

Pass client through new()

crates/craig-test-lib/src/clients/security.rs

Pass client through new()

crates/craig-test-lib/src/clients/intake.rs

Pass client through new()

crates/craig-test-lib/src/harness.rs

Add client field, update 25 factories + register_delete

crates/craig-test-lib/src/lib.rs

Export shared_http_client

.claude/docs/shared-crates.md

Document connection pooling

services/*/tests/api/auth.rs (8 files)

Update direct ServiceClient::new() calls to pass shared client

What Does NOT Change

  • get_unauthenticated / post_unauthenticated — standalone functions for negative auth tests, intentionally separate

  • devstack_available() in lib.rs — one-shot connectivity check, fine with its own client

  • Service production code — this only touches craig-test-lib

  • .config/nextest.toml test-threads = 16 — kept as defense-in-depth

Verification

  1. cargo nextest run --workspace --lib — unit tests pass

  2. cargo xtask dev reload — rebuild services

  3. cargo nextest run --workspace — all integration tests pass with shared pool

  4. 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

Edit this page · latest