Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/advanced/ssl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
3 changes: 3 additions & 0 deletions docs/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For an overview of how to work with HTTPX exceptions, see [Exceptions (Quickstar
* PoolTimeout
* NetworkError
* ConnectError
* SSLError
* ReadError
* WriteError
* CloseError
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/httpcore2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions src/httpcore2/httpcore2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
ReadError,
ReadTimeout,
RemoteProtocolError,
SSLError,
TimeoutException,
UnsupportedProtocol,
WriteError,
Expand Down Expand Up @@ -127,6 +128,7 @@ def __init__(self, *args, **kwargs): # type: ignore
"WriteTimeout",
"NetworkError",
"ConnectError",
"SSLError",
"ReadError",
"WriteError",
]
Expand Down
3 changes: 2 additions & 1 deletion src/httpcore2/httpcore2/_backends/anyio.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ConnectTimeout,
ReadError,
ReadTimeout,
SSLError,
WriteError,
WriteTimeout,
map_exceptions,
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/httpcore2/httpcore2/_backends/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ExceptionMapping,
ReadError,
ReadTimeout,
SSLError,
WriteError,
WriteTimeout,
map_exceptions,
Expand Down Expand Up @@ -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):
Expand Down
14 changes: 12 additions & 2 deletions src/httpcore2/httpcore2/_backends/trio.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ExceptionMapping,
ReadError,
ReadTimeout,
SSLError,
WriteError,
WriteTimeout,
map_exceptions,
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/httpcore2/httpcore2/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ class ConnectError(NetworkError):
pass


class SSLError(ConnectError):
pass


class ReadError(NetworkError):
pass

Expand Down
8 changes: 8 additions & 0 deletions src/httpx2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/httpx2/httpx2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"ResponseNotRead",
"ServerSentEvent",
"SSEError",
"SSLError",
"stream",
"StreamClosed",
"StreamConsumed",
Expand Down
11 changes: 11 additions & 0 deletions src/httpx2/httpx2/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
· PoolTimeout
- NetworkError
· ConnectError
· SSLError
· ReadError
· WriteError
· CloseError
Expand Down Expand Up @@ -60,6 +61,7 @@
"RequestError",
"RequestNotRead",
"ResponseNotRead",
"SSLError",
"StreamClosed",
"StreamConsumed",
"StreamError",
Expand Down Expand Up @@ -201,6 +203,15 @@ class ConnectError(NetworkError):
"""


class SSLError(ConnectError):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"""
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.
Expand Down
2 changes: 2 additions & 0 deletions src/httpx2/httpx2/_transports/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
ReadError,
ReadTimeout,
RemoteProtocolError,
SSLError,
TimeoutException,
UnsupportedProtocol,
WriteError,
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions tests/httpcore2/_async/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions tests/httpcore2/_sync/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/httpx2/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
Loading