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..46a799f9 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`. + ([#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. + ([#1156](https://github.com/pydantic/httpx2/pull/1156)) + ## 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..9bd8f0b5 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,17 @@ 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. 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 SSLError(str(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..14d8ff8e 100644 --- a/src/httpcore2/httpcore2/_exceptions.py +++ b/src/httpcore2/httpcore2/_exceptions.py @@ -76,6 +76,10 @@ class ConnectError(NetworkError): pass +class SSLError(ConnectError): + pass + + class ReadError(NetworkError): pass diff --git a/src/httpx2/CHANGELOG.md b/src/httpx2/CHANGELOG.md index a74acdbd..f57b8776 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. + ([#1156](https://github.com/pydantic/httpx2/pull/1156)) + ## 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..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 @@ -60,6 +61,7 @@ "RequestError", "RequestNotRead", "ResponseNotRead", + "SSLError", "StreamClosed", "StreamConsumed", "StreamError", @@ -201,6 +203,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..f20c6cf7 100644 --- a/tests/httpcore2/_async/test_integration.py +++ b/tests/httpcore2/_async/test_integration.py @@ -23,6 +23,36 @@ 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) + + # 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 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..795f3fb9 100644 --- a/tests/httpcore2/_sync/test_integration.py +++ b/tests/httpcore2/_sync/test_integration.py @@ -24,6 +24,36 @@ 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) + + # 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_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..eb8d5e70 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,22 @@ 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) + # 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: # Exception without request attribute exc = httpx2.ReadTimeout("Read operation timed out")