From 1037938765379165cbcb68483a866cd7d94e9748 Mon Sep 17 00:00:00 2001 From: Ehsan Barkhordar Date: Fri, 4 Sep 2026 02:55:26 +0000 Subject: [PATCH 1/2] Validate a caller-supplied `extensions["timeout"]` The mapping reaches the transport unchanged and its values are handed to `socket.settimeout()`, so a non-numeric value raised a stdlib TypeError from inside the connect, read or write path rather than a clear error from httpx2. Refs #1162 --- src/httpx2/httpx2/_client.py | 17 +++++++++++++++++ tests/httpx2/test_timeouts.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index 4246a4c9..5d292574 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -113,6 +113,19 @@ class UseClientDefault: ACCEPT_ENCODING = ", ".join([key for key in SUPPORTED_DECODERS.keys() if key != "identity"]) +def _validate_timeout_extension(timeout: typing.Any) -> None: + # A caller-supplied `extensions["timeout"]` reaches the transport unchanged, and its + # values are passed to `socket.settimeout()`, which only accepts numbers. + if not isinstance(timeout, typing.Mapping): + raise TypeError( + f"extensions['timeout'] must be a mapping, got {type(timeout).__name__}. " + "Use `Timeout(...).as_dict()` to build one." + ) + for name, value in timeout.items(): + if value is not None and not isinstance(value, (int, float)): + raise TypeError(f"extensions['timeout'][{name!r}] must be a number or None, got {type(value).__name__}.") + + class ClientState(enum.Enum): # UNOPENED: # The client has been instantiated, but has not been used to send a request, @@ -357,6 +370,8 @@ def build_request( if "timeout" not in extensions: timeout = self.timeout if isinstance(timeout, UseClientDefault) else Timeout(timeout) extensions = dict(**extensions, timeout=timeout.as_dict()) + else: + _validate_timeout_extension(extensions["timeout"]) return Request( method, url, @@ -561,6 +576,8 @@ def _set_timeout(self, request: Request) -> None: if "timeout" not in request.extensions: timeout = self.timeout if isinstance(self.timeout, UseClientDefault) else Timeout(self.timeout) request.extensions = dict(**request.extensions, timeout=timeout.as_dict()) + else: + _validate_timeout_extension(request.extensions["timeout"]) class Client(BaseClient): diff --git a/tests/httpx2/test_timeouts.py b/tests/httpx2/test_timeouts.py index 6ce30e2b..7b499908 100644 --- a/tests/httpx2/test_timeouts.py +++ b/tests/httpx2/test_timeouts.py @@ -58,3 +58,27 @@ async def test_async_client_new_request_send_timeout(server: TestServer) -> None async with httpx2.AsyncClient(timeout=timeout) as client: with pytest.raises(httpx2.TimeoutException): await client.send(httpx2.Request("GET", server.url.copy_with(path="/slow_response"))) + + +@pytest.mark.parametrize("name", ["connect", "read", "write", "pool"]) +def test_timeout_extension_value_must_be_a_number(name: str) -> None: + request = httpx2.Request("GET", "http://127.0.0.1:1/") + request.extensions["timeout"] = {name: httpx2.Timeout(5.0)} + + with pytest.raises(TypeError, match=f"extensions\\['timeout'\\]\\[{name!r}\\]"): + httpx2.Client().send(request) + + +def test_timeout_extension_must_be_a_mapping() -> None: + request = httpx2.Request("GET", "http://127.0.0.1:1/") + request.extensions["timeout"] = httpx2.Timeout(5.0) + + with pytest.raises(TypeError, match="extensions\\['timeout'\\] must be a mapping"): + httpx2.Client().send(request) + + +@pytest.mark.parametrize("timeout", [{"connect": 5.0}, {"read": 5}, {"connect": None}, {}]) +def test_timeout_extension_accepts_numbers_and_none(timeout: dict[str, float | None]) -> None: + request = httpx2.Client().build_request("GET", "http://127.0.0.1:1/", extensions={"timeout": timeout}) + + assert request.extensions["timeout"] == timeout From f7150349ebf7050839ef7e3057b94982bf31a3db Mon Sep 17 00:00:00 2001 From: Ehsan Barkhordar Date: Sat, 5 Sep 2026 03:05:43 +0000 Subject: [PATCH 2/2] test: compare the timeout extension against a snapshot, not itself Request shallow-copies extensions, so request.extensions["timeout"] is the mapping the caller passed in and the equality was x == x. Snapshot it with deepcopy first, which also pins that the new validator does not mutate its argument. --- tests/httpx2/test_timeouts.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/httpx2/test_timeouts.py b/tests/httpx2/test_timeouts.py index 7b499908..987d73e4 100644 --- a/tests/httpx2/test_timeouts.py +++ b/tests/httpx2/test_timeouts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import typing import pytest @@ -79,6 +80,9 @@ def test_timeout_extension_must_be_a_mapping() -> None: @pytest.mark.parametrize("timeout", [{"connect": 5.0}, {"read": 5}, {"connect": None}, {}]) def test_timeout_extension_accepts_numbers_and_none(timeout: dict[str, float | None]) -> None: + # `Request` shallow-copies `extensions`, so the mapping reaching the transport is the + # caller's own object. Compare against a snapshot or the assertion is `x == x`. + expected = copy.deepcopy(timeout) request = httpx2.Client().build_request("GET", "http://127.0.0.1:1/", extensions={"timeout": timeout}) - assert request.extensions["timeout"] == timeout + assert request.extensions["timeout"] == expected