From 7a823e56c527faf86937ee2d7bfbadb237d6f26e Mon Sep 17 00:00:00 2001 From: ccbest Date: Sat, 22 Aug 2026 08:21:50 -0400 Subject: [PATCH 1/5] Introduce `SSLError` for TLS handshake failures Adds `SSLError` to both `httpcore2` and `httpx2`, raised when a TLS handshake fails. It subclasses `ConnectError`, so code that already catches `ConnectError` is unaffected, while code that wants to distinguish a TLS failure from a TCP failure now can. This also brings the hierarchy in line with the libraries the change is meant to ease migration from: `requests.exceptions.SSLError` subclasses `ConnectionError`, and `aiohttp.ClientSSLError` subclasses `ClientConnectorError`. On the `trio` backend a failed handshake arrives wrapped in a `trio.BrokenResourceError`, which carries no message of its own, so the error previously surfaced as a `ConnectError` with an empty string. The underlying `ssl.SSLError` is now recovered from `__cause__`, which both types the error correctly and restores the message. Closes #854 --- docs/advanced/ssl.md | 2 +- docs/exceptions.md | 3 +++ src/httpcore2/CHANGELOG.md | 13 ++++++++++ src/httpcore2/httpcore2/__init__.py | 2 ++ src/httpcore2/httpcore2/_backends/anyio.py | 3 ++- src/httpcore2/httpcore2/_backends/sync.py | 4 ++++ src/httpcore2/httpcore2/_backends/trio.py | 13 ++++++++-- src/httpcore2/httpcore2/_exceptions.py | 9 +++++++ src/httpx2/CHANGELOG.md | 8 +++++++ src/httpx2/httpx2/__init__.py | 1 + src/httpx2/httpx2/_exceptions.py | 10 ++++++++ src/httpx2/httpx2/_transports/default.py | 2 ++ tests/httpcore2/_async/test_integration.py | 28 ++++++++++++++++++++++ tests/httpcore2/_sync/test_integration.py | 28 ++++++++++++++++++++++ tests/httpx2/test_exceptions.py | 15 ++++++++++++ 15 files changed, 137 insertions(+), 4 deletions(-) diff --git a/docs/advanced/ssl.md b/docs/advanced/ssl.md index 09ef9d1d..aea261ee 100644 --- a/docs/advanced/ssl.md +++ b/docs/advanced/ssl.md @@ -6,7 +6,7 @@ By default httpx2 will verify HTTPS connections, and raise an error for invalid ```pycon >>> httpx2.get("https://expired.badssl.com/") -httpx2.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997) +httpx2.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997) ``` You can disable SSL verification completely and allow insecure requests... diff --git a/docs/exceptions.md b/docs/exceptions.md index cbf88535..1abfe98a 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -16,6 +16,7 @@ For an overview of how to work with HTTPX exceptions, see [Exceptions (Quickstar * PoolTimeout * NetworkError * ConnectError + * SSLError * ReadError * WriteError * CloseError @@ -60,6 +61,8 @@ For an overview of how to work with HTTPX exceptions, see [Exceptions (Quickstar ::: httpx2.ConnectError +::: httpx2.SSLError + ::: httpx2.ReadError ::: httpx2.WriteError diff --git a/src/httpcore2/CHANGELOG.md b/src/httpcore2/CHANGELOG.md index 5f0007fb..a25c0ecb 100644 --- a/src/httpcore2/CHANGELOG.md +++ b/src/httpcore2/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Added + +* Add `SSLError`, raised when a TLS handshake fails. It subclasses `ConnectError`. + ([#XXXX](https://github.com/pydantic/httpx2/pull/XXXX)) + +### Fixed + +* Preserve the underlying `ssl.SSLError` message on the `trio` backend, where a + failed handshake previously surfaced with an empty message. + ([#XXXX](https://github.com/pydantic/httpx2/pull/XXXX)) + ## 2.12.0 (August 18th, 2026) No changes since `2.11.0`. Version bumped to stay in lockstep with `httpx2`. diff --git a/src/httpcore2/httpcore2/__init__.py b/src/httpcore2/httpcore2/__init__.py index 708eea9a..8abc0d16 100644 --- a/src/httpcore2/httpcore2/__init__.py +++ b/src/httpcore2/httpcore2/__init__.py @@ -31,6 +31,7 @@ ReadError, ReadTimeout, RemoteProtocolError, + SSLError, TimeoutException, UnsupportedProtocol, WriteError, @@ -127,6 +128,7 @@ def __init__(self, *args, **kwargs): # type: ignore "WriteTimeout", "NetworkError", "ConnectError", + "SSLError", "ReadError", "WriteError", ] diff --git a/src/httpcore2/httpcore2/_backends/anyio.py b/src/httpcore2/httpcore2/_backends/anyio.py index 16c270e8..0c308083 100644 --- a/src/httpcore2/httpcore2/_backends/anyio.py +++ b/src/httpcore2/httpcore2/_backends/anyio.py @@ -12,6 +12,7 @@ ConnectTimeout, ReadError, ReadTimeout, + SSLError, WriteError, WriteTimeout, map_exceptions, @@ -64,7 +65,7 @@ async def start_tls( TimeoutError: ConnectTimeout, anyio.BrokenResourceError: ConnectError, anyio.EndOfStream: ConnectError, - ssl.SSLError: ConnectError, + ssl.SSLError: SSLError, } with map_exceptions(exc_map): try: diff --git a/src/httpcore2/httpcore2/_backends/sync.py b/src/httpcore2/httpcore2/_backends/sync.py index 54ce5428..92ab9fab 100644 --- a/src/httpcore2/httpcore2/_backends/sync.py +++ b/src/httpcore2/httpcore2/_backends/sync.py @@ -12,6 +12,7 @@ ExceptionMapping, ReadError, ReadTimeout, + SSLError, WriteError, WriteTimeout, map_exceptions, @@ -149,6 +150,9 @@ def start_tls( ) -> NetworkStream: exc_map: ExceptionMapping = { socket.timeout: ConnectTimeout, + # `ssl.SSLError` is a subclass of `OSError`, and `map_exceptions` + # uses the first matching entry, so it must be listed first. + ssl.SSLError: SSLError, OSError: ConnectError, } with map_exceptions(exc_map): diff --git a/src/httpcore2/httpcore2/_backends/trio.py b/src/httpcore2/httpcore2/_backends/trio.py index 742985b9..2cec302e 100644 --- a/src/httpcore2/httpcore2/_backends/trio.py +++ b/src/httpcore2/httpcore2/_backends/trio.py @@ -11,6 +11,7 @@ ExceptionMapping, ReadError, ReadTimeout, + SSLError, WriteError, WriteTimeout, map_exceptions, @@ -60,6 +61,7 @@ async def start_tls( timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { trio.TooSlowError: ConnectTimeout, + ssl.SSLError: SSLError, trio.BrokenResourceError: ConnectError, } ssl_stream = trio.SSLStream( @@ -73,9 +75,16 @@ async def start_tls( try: with trio.fail_after(timeout_or_inf): await ssl_stream.do_handshake() - except Exception as exc: # pragma: no cover + except Exception as exc: await self.aclose() - raise exc + # `trio` reports a failed handshake as `BrokenResourceError`, which + # carries no message of its own. Re-raise the underlying + # `ssl.SSLError` so that it maps to `SSLError` and the reason for + # the failure isn't lost. + cause = exc.__cause__ + if isinstance(exc, trio.BrokenResourceError) and isinstance(cause, ssl.SSLError): + raise cause from exc + raise exc # pragma: no cover return TrioStream(ssl_stream) def get_extra_info(self, info: str) -> typing.Any: diff --git a/src/httpcore2/httpcore2/_exceptions.py b/src/httpcore2/httpcore2/_exceptions.py index a54d43b7..7cf87a68 100644 --- a/src/httpcore2/httpcore2/_exceptions.py +++ b/src/httpcore2/httpcore2/_exceptions.py @@ -76,6 +76,15 @@ class ConnectError(NetworkError): pass +class SSLError(ConnectError): + """ + Raised when a TLS handshake fails. + + A subclass of `ConnectError`, since the handshake is part of establishing + the connection. + """ + + class ReadError(NetworkError): pass diff --git a/src/httpx2/CHANGELOG.md b/src/httpx2/CHANGELOG.md index a74acdbd..6a764dc0 100644 --- a/src/httpx2/CHANGELOG.md +++ b/src/httpx2/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Added + +* Add `SSLError`, raised when a TLS handshake fails. It subclasses `ConnectError`, + so existing `except ConnectError` handling continues to catch it. + ([#XXXX](https://github.com/pydantic/httpx2/pull/XXXX)) + ## 2.12.0 (August 18th, 2026) ### Changed diff --git a/src/httpx2/httpx2/__init__.py b/src/httpx2/httpx2/__init__.py index 461d20bd..34300b1f 100644 --- a/src/httpx2/httpx2/__init__.py +++ b/src/httpx2/httpx2/__init__.py @@ -74,6 +74,7 @@ "ResponseNotRead", "ServerSentEvent", "SSEError", + "SSLError", "stream", "StreamClosed", "StreamConsumed", diff --git a/src/httpx2/httpx2/_exceptions.py b/src/httpx2/httpx2/_exceptions.py index 2b74151b..fca341ca 100644 --- a/src/httpx2/httpx2/_exceptions.py +++ b/src/httpx2/httpx2/_exceptions.py @@ -60,6 +60,7 @@ "RequestError", "RequestNotRead", "ResponseNotRead", + "SSLError", "StreamClosed", "StreamConsumed", "StreamError", @@ -201,6 +202,15 @@ class ConnectError(NetworkError): """ +class SSLError(ConnectError): + """ + Failed to establish a TLS connection. + + A subclass of `ConnectError`, since the TLS handshake is part of + establishing the connection. + """ + + class CloseError(NetworkError): """ Failed to close a connection. diff --git a/src/httpx2/httpx2/_transports/default.py b/src/httpx2/httpx2/_transports/default.py index 17d48c4f..bbff37ff 100644 --- a/src/httpx2/httpx2/_transports/default.py +++ b/src/httpx2/httpx2/_transports/default.py @@ -48,6 +48,7 @@ ReadError, ReadTimeout, RemoteProtocolError, + SSLError, TimeoutException, UnsupportedProtocol, WriteError, @@ -79,6 +80,7 @@ def _load_httpcore_exceptions() -> dict[type[Exception], type[httpx2.HTTPError]] httpcore2.PoolTimeout: PoolTimeout, httpcore2.NetworkError: NetworkError, httpcore2.ConnectError: ConnectError, + httpcore2.SSLError: SSLError, httpcore2.ReadError: ReadError, httpcore2.WriteError: WriteError, httpcore2.ProxyError: ProxyError, diff --git a/tests/httpcore2/_async/test_integration.py b/tests/httpcore2/_async/test_integration.py index 1325c734..726b1368 100644 --- a/tests/httpcore2/_async/test_integration.py +++ b/tests/httpcore2/_async/test_integration.py @@ -23,6 +23,34 @@ async def test_ssl_request(httpbin_secure: Server) -> None: assert response.status == 200 +@pytest.mark.anyio +async def test_ssl_verification_failure(httpbin_secure: Server) -> None: + """ + A failed TLS handshake raises `SSLError`, which is a subclass of `ConnectError` + so that existing `except ConnectError` handling keeps working. + """ + async with httpcore2.AsyncConnectionPool() as pool: + with pytest.raises(httpcore2.SSLError) as exc_info: + await pool.request("GET", httpbin_secure.url) + + assert isinstance(exc_info.value, httpcore2.ConnectError) + + +@pytest.mark.trio +async def test_ssl_verification_failure_includes_reason(httpbin_secure: Server) -> None: + """ + The underlying `ssl.SSLError` message is preserved. + + Some backends wrap the handshake failure in an exception that carries no + message of its own, so the reason has to be recovered from the `__cause__`. + """ + async with httpcore2.AsyncConnectionPool() as pool: + with pytest.raises(httpcore2.SSLError) as exc_info: + await pool.request("GET", httpbin_secure.url) + + assert "CERTIFICATE_VERIFY_FAILED" in str(exc_info.value) + + @pytest.mark.anyio async def test_extra_info(httpbin_secure: Server) -> None: ssl_context = ssl.create_default_context() diff --git a/tests/httpcore2/_sync/test_integration.py b/tests/httpcore2/_sync/test_integration.py index 06f1aafb..376b8129 100644 --- a/tests/httpcore2/_sync/test_integration.py +++ b/tests/httpcore2/_sync/test_integration.py @@ -24,6 +24,34 @@ def test_ssl_request(httpbin_secure: Server) -> None: +def test_ssl_verification_failure(httpbin_secure: Server) -> None: + """ + A failed TLS handshake raises `SSLError`, which is a subclass of `ConnectError` + so that existing `except ConnectError` handling keeps working. + """ + with httpcore2.ConnectionPool() as pool: + with pytest.raises(httpcore2.SSLError) as exc_info: + pool.request("GET", httpbin_secure.url) + + assert isinstance(exc_info.value, httpcore2.ConnectError) + + + +def test_ssl_verification_failure_includes_reason(httpbin_secure: Server) -> None: + """ + The underlying `ssl.SSLError` message is preserved. + + Some backends wrap the handshake failure in an exception that carries no + message of its own, so the reason has to be recovered from the `__cause__`. + """ + with httpcore2.ConnectionPool() as pool: + with pytest.raises(httpcore2.SSLError) as exc_info: + pool.request("GET", httpbin_secure.url) + + assert "CERTIFICATE_VERIFY_FAILED" in str(exc_info.value) + + + def test_extra_info(httpbin_secure: Server) -> None: ssl_context = ssl.create_default_context() ssl_context.check_hostname = False diff --git a/tests/httpx2/test_exceptions.py b/tests/httpx2/test_exceptions.py index 3d0cac7d..c22c4336 100644 --- a/tests/httpx2/test_exceptions.py +++ b/tests/httpx2/test_exceptions.py @@ -9,6 +9,7 @@ if typing.TYPE_CHECKING: from conftest import TestServer + from pytest_httpbin.serve import Server def test_httpcore_all_exceptions_mapped() -> None: @@ -47,6 +48,20 @@ def test_httpcore_exception_mapping(server: TestServer) -> None: ) +def test_ssl_exception_mapping(httpbin_secure: Server) -> None: + """ + A failed TLS handshake maps to `httpx2.SSLError`. + + `SSLError` subclasses `ConnectError`, so code that already catches + `ConnectError` keeps working unchanged. + """ + with pytest.raises(httpx2.SSLError) as exc_info: + httpx2.get(httpbin_secure.url) + + assert isinstance(exc_info.value, httpx2.ConnectError) + assert "CERTIFICATE_VERIFY_FAILED" in str(exc_info.value) + + def test_request_attribute() -> None: # Exception without request attribute exc = httpx2.ReadTimeout("Read operation timed out") From 08109640400aa398745b3e6e79a086ab4f7c0273 Mon Sep 17 00:00:00 2001 From: ccbest Date: Sat, 22 Aug 2026 08:41:30 -0400 Subject: [PATCH 2/5] Match httpcore2 exception style for `SSLError` The other exception classes in `httpcore2._exceptions` are bare `pass`, and httpcore2 docstrings are not rendered anywhere in the docs. The rationale for the parent class lives on the `httpx2` counterpart, which is rendered. --- src/httpcore2/httpcore2/_exceptions.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/httpcore2/httpcore2/_exceptions.py b/src/httpcore2/httpcore2/_exceptions.py index 7cf87a68..14d8ff8e 100644 --- a/src/httpcore2/httpcore2/_exceptions.py +++ b/src/httpcore2/httpcore2/_exceptions.py @@ -77,12 +77,7 @@ class ConnectError(NetworkError): class SSLError(ConnectError): - """ - Raised when a TLS handshake fails. - - A subclass of `ConnectError`, since the handshake is part of establishing - the connection. - """ + pass class ReadError(NetworkError): From da3dda5aac1ca8c5ae2822635f627f5c9da71db3 Mon Sep 17 00:00:00 2001 From: ccbest Date: Sat, 22 Aug 2026 09:47:33 -0400 Subject: [PATCH 3/5] Avoid a cyclic exception chain on the trio backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-raising trio's own `ssl.SSLError` created a reference cycle: trio sets `BrokenResourceError.__cause__` to the ssl error, so `raise cause from exc` pointed the ssl error back at the `BrokenResourceError`. Code walking `__cause__ or __context__` — a common pattern in logging and error reporting — would not terminate. Raise a new `SSLError` carrying the original message instead. The chain is now acyclic and in causal order: httpx2.SSLError -> httpcore2.SSLError -> trio.BrokenResourceError -> ssl.SSLCertVerificationError -> None --- src/httpcore2/httpcore2/_backends/trio.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/httpcore2/httpcore2/_backends/trio.py b/src/httpcore2/httpcore2/_backends/trio.py index 2cec302e..9bd8f0b5 100644 --- a/src/httpcore2/httpcore2/_backends/trio.py +++ b/src/httpcore2/httpcore2/_backends/trio.py @@ -78,12 +78,13 @@ async def start_tls( except Exception as exc: await self.aclose() # `trio` reports a failed handshake as `BrokenResourceError`, which - # carries no message of its own. Re-raise the underlying - # `ssl.SSLError` so that it maps to `SSLError` and the reason for - # the failure isn't lost. + # carries no message of its own. Raise `SSLError` with the message + # from the underlying `ssl.SSLError` so the reason isn't lost. + # Note we raise a new exception rather than re-raising the cause, + # which `trio` already back-references and would make cyclic. cause = exc.__cause__ if isinstance(exc, trio.BrokenResourceError) and isinstance(cause, ssl.SSLError): - raise cause from exc + raise SSLError(str(cause)) from exc raise exc # pragma: no cover return TrioStream(ssl_stream) From 5044bc56c1e5df832f6380be2fa049c37f79e56e Mon Sep 17 00:00:00 2001 From: ccbest Date: Sat, 22 Aug 2026 10:10:32 -0400 Subject: [PATCH 4/5] Reference PR #1156 in changelog entries --- src/httpcore2/CHANGELOG.md | 4 ++-- src/httpx2/CHANGELOG.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/httpcore2/CHANGELOG.md b/src/httpcore2/CHANGELOG.md index a25c0ecb..46a799f9 100644 --- a/src/httpcore2/CHANGELOG.md +++ b/src/httpcore2/CHANGELOG.md @@ -9,13 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added * Add `SSLError`, raised when a TLS handshake fails. It subclasses `ConnectError`. - ([#XXXX](https://github.com/pydantic/httpx2/pull/XXXX)) + ([#1156](https://github.com/pydantic/httpx2/pull/1156)) ### Fixed * Preserve the underlying `ssl.SSLError` message on the `trio` backend, where a failed handshake previously surfaced with an empty message. - ([#XXXX](https://github.com/pydantic/httpx2/pull/XXXX)) + ([#1156](https://github.com/pydantic/httpx2/pull/1156)) ## 2.12.0 (August 18th, 2026) diff --git a/src/httpx2/CHANGELOG.md b/src/httpx2/CHANGELOG.md index 6a764dc0..f57b8776 100644 --- a/src/httpx2/CHANGELOG.md +++ b/src/httpx2/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). * Add `SSLError`, raised when a TLS handshake fails. It subclasses `ConnectError`, so existing `except ConnectError` handling continues to catch it. - ([#XXXX](https://github.com/pydantic/httpx2/pull/XXXX)) + ([#1156](https://github.com/pydantic/httpx2/pull/1156)) ## 2.12.0 (August 18th, 2026) From f001430917868faa7627e9ad73ca566186ba151b Mon Sep 17 00:00:00 2001 From: ccbest Date: Sun, 23 Aug 2026 08:59:17 -0400 Subject: [PATCH 5/5] AI Review comments: Update exceptions module docstring to include SSLError and change test assertions to look for lower-case reason text --- src/httpx2/httpx2/_exceptions.py | 1 + tests/httpcore2/_async/test_integration.py | 4 +++- tests/httpcore2/_sync/test_integration.py | 4 +++- tests/httpx2/test_exceptions.py | 4 +++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/httpx2/httpx2/_exceptions.py b/src/httpx2/httpx2/_exceptions.py index fca341ca..df707796 100644 --- a/src/httpx2/httpx2/_exceptions.py +++ b/src/httpx2/httpx2/_exceptions.py @@ -11,6 +11,7 @@ · PoolTimeout - NetworkError · ConnectError + · SSLError · ReadError · WriteError · CloseError diff --git a/tests/httpcore2/_async/test_integration.py b/tests/httpcore2/_async/test_integration.py index 726b1368..f20c6cf7 100644 --- a/tests/httpcore2/_async/test_integration.py +++ b/tests/httpcore2/_async/test_integration.py @@ -48,7 +48,9 @@ async def test_ssl_verification_failure_includes_reason(httpbin_secure: Server) with pytest.raises(httpcore2.SSLError) as exc_info: await pool.request("GET", httpbin_secure.url) - assert "CERTIFICATE_VERIFY_FAILED" in str(exc_info.value) + # Match the lower-case reason text rather than the `CERTIFICATE_VERIFY_FAILED` + # mnemonic, which is not emitted by every OpenSSL/LibreSSL build. + assert "certificate verify failed" in str(exc_info.value) @pytest.mark.anyio diff --git a/tests/httpcore2/_sync/test_integration.py b/tests/httpcore2/_sync/test_integration.py index 376b8129..795f3fb9 100644 --- a/tests/httpcore2/_sync/test_integration.py +++ b/tests/httpcore2/_sync/test_integration.py @@ -48,7 +48,9 @@ def test_ssl_verification_failure_includes_reason(httpbin_secure: Server) -> Non with pytest.raises(httpcore2.SSLError) as exc_info: pool.request("GET", httpbin_secure.url) - assert "CERTIFICATE_VERIFY_FAILED" in str(exc_info.value) + # Match the lower-case reason text rather than the `CERTIFICATE_VERIFY_FAILED` + # mnemonic, which is not emitted by every OpenSSL/LibreSSL build. + assert "certificate verify failed" in str(exc_info.value) diff --git a/tests/httpx2/test_exceptions.py b/tests/httpx2/test_exceptions.py index c22c4336..eb8d5e70 100644 --- a/tests/httpx2/test_exceptions.py +++ b/tests/httpx2/test_exceptions.py @@ -59,7 +59,9 @@ def test_ssl_exception_mapping(httpbin_secure: Server) -> None: httpx2.get(httpbin_secure.url) assert isinstance(exc_info.value, httpx2.ConnectError) - assert "CERTIFICATE_VERIFY_FAILED" in str(exc_info.value) + # Match the lower-case reason text rather than the `CERTIFICATE_VERIFY_FAILED` + # mnemonic, which is not emitted by every OpenSSL/LibreSSL build. + assert "certificate verify failed" in str(exc_info.value) def test_request_attribute() -> None: