Skip to content

Close the tunnel connection when TLS setup fails after CONNECT - #1226

Open
itzzdev09 wants to merge 2 commits into
pydantic:mainfrom
itzzdev09:fix/tunnel-tls-failure-leaks-pooled-connection
Open

itzzdev09 wants to merge 2 commits into
pydantic:mainfrom
itzzdev09:fix/tunnel-tls-failure-leaks-pooled-connection

Conversation

@itzzdev09

@itzzdev09 itzzdev09 commented Sep 22, 2026 •

Copy link
Copy Markdown

Fixes #1175.

The bug

In TunnelHTTPConnection.handle_request() (_sync/_async http_proxy.py), a proxy CONNECT that succeeds leaves the underlying stream registered on self._connection. If the subsequent stream.start_tls() call then raises (e.g. a certificate/handshake failure), the exception was left to propagate without closing self._connection first — unlike the CONNECT-status-check branch a few lines above it, which already does exactly this on a non-2xx CONNECT response:

if connect_response.status < 200 or connect_response.status > 299:
    ...
    self._connection.close()
    raise ProxyError(msg)

self._connection already completed one successful request (the CONNECT) and self._connected stays False, so it keeps accurately reporting itself as ACTIVE, Request Count: 1 to the pool — nothing ever told it the tunnel died. The pool's own exception handling in handle_request() only removes the failed request from its queue, never the connection from self._connections, so it has no other way to notice. Each failed handshake permanently consumes a connection-pool slot; once failures reach max_connections, unrelated later requests raise PoolTimeout instead of the real ConnectError.

The fix

Wrap start_tls() the same way the status-check block already does:

with Trace("start_tls", logger, request, kwargs) as trace:
    try:
        stream = stream.start_tls(**kwargs)
    except BaseException:
        self._connection.close()  # aclose() in the async variant
        raise
    trace.return_value = stream

Applied to both _sync/http_proxy.py and _async/http_proxy.py (they're kept in sync via scripts/unasync.py, which I re-ran after editing the async source).

Testing

Added test_proxy_tunneling_with_tls_failure to both tests/httpcore2/_sync/test_http_proxy.py and tests/httpcore2/_async/test_http_proxy.py, following the existing MockStream/MockBackend pattern used by test_proxy_tunneling_with_403: a FailingTLSStream whose start_tls always raises ConnectError, run three times against a pool with max_connections=2, asserting not proxy.connections after every failure.

  • Reverting only the fix reproduces the exact bug: the test fails with a stale ACTIVE TunnelHTTPConnection left in proxy.connections.
  • With the fix applied, all three failures leave the pool empty, as expected.
  • Also independently reproduced the original discussion's own repro script against this branch (before/after), matching its exact ConnectError → ConnectError → PoolTimeout sequence when reverted, and ConnectError ×3 with an empty pool after each, when fixed.
  • ruff check / ruff format --check and mypy clean on all four touched files.
  • Full tests/httpcore2 suite: 235 passed, only 3 pre-existing keepalive_expiry=0.0 timing flakes remain, which I confirmed also fail identically on unmodified main (unrelated to this change).
  • scripts/unasync.py --check passes (sync/async stay in lockstep).

Review in cubic

In TunnelHTTPConnection.handle_request() (both _sync and _async), a
proxy CONNECT that succeeds leaves the underlying stream registered on
self._connection. If the subsequent stream.start_tls() call then
raises (e.g. a certificate/handshake failure), that exception was
previously left to propagate without closing self._connection first,
unlike the CONNECT-status-check branch a few lines above it, which
already does this correctly on a non-2xx CONNECT response.

Since self._connection already completed one successful request (the
CONNECT) and self._connected stays False, it keeps reporting itself as
ACTIVE with Request Count: 1 to the pool - an accurate state, since
nothing told it the tunnel is dead. The pool's own exception handling
in handle_request() only removes the failed *request* from its queue,
not the connection from self._connections, so it has no other way to
notice. Each failed handshake permanently consumes a connection-pool
slot; once failures reach max_connections, unrelated later requests
raise PoolTimeout instead of the real ConnectError.

Wrap start_tls() the same way the CONNECT status-check block already
does: close the connection on any exception before re-raising.

Adds a regression test (sync + async) using a MockStream whose
start_tls always fails, asserting the pool has zero connections after
each of several consecutive failures with a small max_connections.
Verified it reproduces the exact discussion pydantic#1175 bug when the fix is
reverted (a stale ACTIVE connection is left behind), and passes with
it applied. ruff check/format and mypy clean on the touched files;
full tests/httpcore2 suite passes except three pre-existing,
unrelated keepalive_expiry timing flakes that also fail identically
on main without this change.
@codspeed

codspeed Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 17 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing itzzdev09:fix/tunnel-tls-failure-leaks-pooled-connection (acd25a5) with main (df9783d)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

…nal exception

- Wrap the cleanup close in AsyncShieldCancellation (sync: ShieldCancellation)
  so a Trio cancellation during exception handling can't interrupt it and
  leave the TLS socket open, matching the existing pattern used elsewhere
  in this codebase for exception-path cleanup.
- Swallow a failure from the close itself so it can't mask the original
  TLS handshake exception callers are expecting.

Regenerated the sync files via scripts/unasync.py.

Added test_proxy_tunneling_with_tls_failure_and_failing_close: makes the
close raise and asserts the original ConnectError still propagates. Fails
on the pre-fix code (raises the close's RuntimeError instead).
mbeijen added a commit to mbeijen/httpx2 that referenced this pull request Oct 25, 2026
Restores the 08cbebd (proxy SSL context) item that only lived on the
old local branch, marks pydantic#1069 and the NO_PROXY CIDR work as landed,
and flags overlaps with pydantic#1226, pydantic#1270, pydantic#1211, pydantic#1174 and pydantic#1169.
@mbeijen

mbeijen commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

I had the same fix open as #1010 (port of encode/httpcore#1049) and have closed it in favour of this one, since this one also handles cancellation and has the better regression test. One nit: the cleanup swallows BaseException from aclose(); except Exception would be enough to keep the TLS error from being masked, without hiding a KeyboardInterrupt. Otherwise LGTM.

mbeijen added a commit to mbeijen/httpx2 that referenced this pull request Oct 25, 2026
@NorbertRop

Copy link
Copy Markdown

We hit this in production. A TLS reset during the handshake through our HTTP proxy (tinyproxy) left the tunnel in the pool as ACTIVE. Our client polls over a pool of one connection (python-telegram-bot’s long polling), so every later request failed with PoolTimeout until we restarted the process. Reproduced on httpcore2 2.13.1 and httpcore 1.0.9, and closing the proxy connection when start_tls fails, as this PR does, fixes it in our repro. Would be great to get this merged.

This branch has not been deployed

No deployments
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.

4 participants