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..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 @@ -58,3 +59,30 @@ 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` 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"] == expected