Skip to content

feat(egress): pool shared HTTP clients on the egress hot path - #1718

Open
omrishiv wants to merge 2 commits into
agentic-community:mainfrom
omrishiv:feat/egress-http-client-pooling
Open

feat(egress): pool shared HTTP clients on the egress hot path#1718
omrishiv wants to merge 2 commits into
agentic-community:mainfrom
omrishiv:feat/egress-http-client-pooling

Conversation

@omrishiv

@omrishiv omrishiv commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Every outbound call on the egress data path currently builds a fresh httpx
client and tears it down per request, so each OBO exchange, 3LO
exchange/refresh, egress-token vend, MCP-proxy stream, and periodic health
check pays a full TCP + TLS handshake with no keep-alive reuse. This PR introduces
process-lifetime, connection-pooled clients so repeated egress calls reuse warm
connections, cutting handshake churn and latency on the single-worker asyncio
loop — the throughput ceiling of the authenticated data path.

The change is plumbing that preserves the existing security posture: the
SSRF guard, per-request validate+pin, and credential-handling are unchanged;
one path (the MCP-proxy egress stream) is left exactly as main already had it
(guarded via EGRESS_UPSTREAM_PROFILE).

Motivation

  • Egress fan-out is growing (more registered MCP servers, more per-user OBO),
    and the per-call client pattern re-handshakes on every request.
  • Health checks re-handshake every registered server every cycle
    (default 300s) because the clients are rebuilt per cycle — the single biggest
    handshake source under fan-out.

What changed

Core — pooled accessors (registry/utils/url_guard.py)

  • shared_guarded_async_client(profile, verify) — one SSRF-guarded client per
    (profile, verify). GuardedAsyncTransport validates + pins every request
    before pool checkout; the pool is keyed by the pinned IP, so a rebound
    hostname re-resolves to a new origin and never reuses a stale connection
    (rebind-safe).
  • shared_plain_async_client() — pooled plain client for in-cluster,
    already-trusted
    hops only (the registry egress-token vend).
  • _NoStoreCookieJar — installed on every pooled client so a Set-Cookie is
    never stored and can never be replayed onto a later or concurrent request
    (concurrency-safe by construction; credentials never ride cookies here — this
    is defense-in-depth).
  • post_with_reconnect() — one transparent retry when a pooled keep-alive was
    closed while idle (httpx does not auto-retry non-idempotent POSTs). Used only
    on idempotent hops (OBO exchange, vend); see the 3LO note below.
  • aclose_shared_clients() / reset_shared_clients_for_tests().
  • No shared default headers — every credential rides a per-request header.

Call sites (guard posture unchanged)

  • OBO exchange (auth_server/egress_obo.py) and 3LO exchange/refresh
    (registry/egress_auth/oauth_engine.py) → pooled guarded credentialed client.
  • Egress-token vend (auth_server/server.py) → pooled plain client.
  • MCP-proxy egress stream (auth_server/server.py) → pooled guarded client
    (EGRESS_UPSTREAM_PROFILE), response-scoped; the shared client is never
    closed per request. The non-egress stream keeps a per-call plain client
    (closed in finally).
  • Registry health checks (registry/health/service.py) → process-lived
    shared clients instead of per-cycle; initialize+probe to one server now reuse
    a connection.
  • Both app lifespans close the pooled clients on shutdown (the registry
    closes after the health loop is drained, so no in-flight health request hits a
    closed client).

Config (three-surface parity)

Four new settings, wired through registry/core/config.py, the config API
(config_routes.py), Docker (.env.example + all 3 compose files), Terraform
(terraform/aws-ecs/…), and Helm (charts/{registry,auth-server} values +
config maps + reserved-env-names.txt), documented in
docs/unified-parameter-reference.md:

Env var Default Purpose
EGRESS_HTTP_POOL_MAX_CONNECTIONS 100 pool max_connections
EGRESS_HTTP_POOL_MAX_KEEPALIVE 20 pool max_keepalive_connections (clamped ≤ max_connections)
EGRESS_HTTP_POOL_KEEPALIVE_EXPIRY_SECONDS 30 idle keep-alive expiry; set below the shortest upstream/LB idle timeout
EGRESS_HTTP_POOL_CONNECT_RETRIES 1 transport connect-establishment retries

New metric mcpgw_registry_egress_conn_reset_total{site} counts keep-alive
reconnect retries (rising = KEEPALIVE_EXPIRY set above an upstream idle
timeout).

Security

  • SSRF guard preserved. Sharing per (profile, verify) does not weaken
    validate+pin: pinning runs per request before pool checkout, and the pool key
    is the pinned IP. verify is part of the key, so a verify=False client can
    never be reused where verification is expected.
  • No cross-request leakage. HTTP/1.1 is serialized per connection (no
    header/body bleed); no shared default identity headers; the no-store cookie
    jar makes cookie handling stateless.
  • Cross-SNI coalescing (documented behavioral change, not a bug). Because the
    pool key is the pinned IP, two hostnames that both validate to the same public
    IP can coalesce onto one TLS connection. This is safe — each request is
    independently pinned, the Host header is correct, and reaching host C over
    host B's connection requires C to already resolve to that IP. http2 is
    deliberately not enabled (it would coalesce far more aggressively).
  • 3LO refresh not auto-retried. oauth_engine._post_token handles
    single-use/rotating grants (authorization_code / refresh_token), so a blind
    re-POST after a connection reset could double-spend the grant. It is not
    wrapped in post_with_reconnect; a reset surfaces as a transient error the
    refresh worker retries safely.
  • Registration TOCTOU (attacker edits proxy_pass_url) is unchanged by
    pooling
    — connection selection is per-request by the pinned target, so a
    request for a changed address opens a new connection; it never reuses the old
    upstream's keep-alive. (That threat is a registration-integrity concern,
    orthogonal to this PR; this PR keeps the stream behind the SSRF guard.)

Testing

  • New tests/unit/utils/test_shared_http_clients.py: sharing identity, verify
    keying, guarded-transport/profile preservation, no-store cookie jar,
    rebuild-after-close, aclose closes all, post_with_reconnect
    retry-once/no-retry/reraise, and a pin-to-IP coalescing test (two
    hostnames → same IP origin, Host + SNI preserved).
  • Migrated the existing OBO / 3LO / vend / MCP-proxy tests to the shared-client
    seam (they now assert the credential-bearing POST flows through the guarded
    client and fails closed), plus an assertion that the shared egress client is
    not closed per request.
  • 848 tests pass with -W error::RuntimeWarning; all pre-commit gates green
    (ruff lint + format, mypy, bandit, detect-secrets, fast tests).

Backwards compatibility / rollout

  • Behavior is otherwise identical: per-request timeouts preserved (callback 5s,
    vend budget, health per-call, proxy timeout), guard posture unchanged, no new
    required config (all four settings default sensibly).
  • Feature-flag-free; safe to ship. Rollback is reverting the call sites, or
    setting EGRESS_HTTP_POOL_KEEPALIVE_EXPIRY_SECONDS=0 to disable keep-alive
    reuse while keeping the pooled client objects. (Note: max_connections=1 is
    not a rollback — it still keep-alives and serializes egress.)

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Replace per-call httpx client construction on the egress data path with
process-lifetime, connection-pooled clients so repeated egress calls reuse
warm TCP+TLS connections instead of handshaking every request.

url_guard gains pooled accessors:
- shared_guarded_async_client(profile, verify): one SSRF-guarded client per
  (profile, verify). The guard validates+pins per request before pool
  checkout and pools by the pinned IP, so rebind-safety is preserved.
- shared_plain_async_client(): pooled plain client for in-cluster trusted
  hops only (the registry egress-token vend).
- No shared mutable identity state: no default auth headers, and cookie
  persistence is disabled (a Set-Cookie cannot bleed across requests/users).
- post_with_reconnect(): one transparent retry when a pooled keep-alive was
  closed while idle (httpx does not auto-retry non-idempotent POSTs).
- aclose_shared_clients() / reset_shared_clients_for_tests().

Wire the pooled clients into the egress hot path (guard posture unchanged):
OBO + 3LO exchanges -> pooled guarded credentialed client; egress-token vend
-> pooled plain client; MCP-proxy egress stream -> pooled guarded client
(EGRESS_UPSTREAM_PROFILE), response-scoped and never closed per request
(non-egress keeps a per-call plain client); registry health checks -> shared
process-lived clients instead of per-cycle. Both app lifespans close the
pooled clients on shutdown (registry after the health loop is drained).

New settings across Docker/Terraform/Helm + config API:
EGRESS_HTTP_POOL_MAX_CONNECTIONS/MAX_KEEPALIVE/KEEPALIVE_EXPIRY_SECONDS/CONNECT_RETRIES.
New metric mcpgw_registry_egress_conn_reset_total{site}.

Tests: new tests/unit/utils/test_shared_http_clients.py; existing OBO/3LO/
vend/proxy tests migrated to the shared-client seam.
…rella chart

Review-driven follow-on to the egress connection-pooling change:

- Login OAuth callback (exchange_code_for_token + get_user_info) now reuses the
  pooled PLAIN client instead of a per-call httpx.AsyncClient. Deliberately NOT
  the HTTPS-only credentialed-OAuth guard: the callback token/userinfo endpoint
  is the operator-configured login IdP -- Keycloak/PingFederate use the in-cluster
  ${KEYCLOAK_URL}/base URL (default http://), which the guard would reject and
  break login. EGRESS_OAUTH_TRUSTED_IDP_HOSTS does not help (it relaxes the
  private-IP block, not the HTTPS requirement). Target is static config, never
  request-derived, so the plain client is correct; 5s timeout + per-request creds
  preserved. Plain-client contract doc updated; test asserts the shared client is
  not closed per request.

- post_with_reconnect narrowed to httpx.RemoteProtocolError only (the keep-alive
  reuse reset); ConnectError is left to the transport retries= (removes the
  redundant double-retry on connect). Tests updated + regression test added.

- Umbrella chart surfaces the four EGRESS_HTTP_POOL_* knobs under
  registry.egressAuth / auth-server.egressAuth as operational tuning (rest of
  egressAuth stays subchart-defaulted).

- docs/egress-http-client-pooling.md: document callback pooling + guard rationale.
@codecov-commenter

codecov-commenter commented Sep 2, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 83.84615% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
auth_server/server.py 76.92% 7 Missing and 2 partials ⚠️
registry/utils/url_guard.py 91.48% 4 Missing ⚠️
registry/health/service.py 85.00% 1 Missing and 2 partials ⚠️
registry/core/config.py 83.33% 1 Missing and 1 partial ⚠️
registry/main.py 0.00% 2 Missing ⚠️
auth_server/observability/meters.py 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants