diff --git a/src/httpware/_internal/exception_mapping.py b/src/httpware/_internal/exception_mapping.py index b6f8625..aa2cd48 100644 --- a/src/httpware/_internal/exception_mapping.py +++ b/src/httpware/_internal/exception_mapping.py @@ -1,21 +1,22 @@ -"""httpx2 -> httpware exception mapping + context-manager wrappers (shared). +"""httpx2 -> httpware exception mapping (shared). -map_httpx2_exception is a pure function used by both Client._terminal and -AsyncClient._terminal, and by both stream() methods. Clause ordering: +map_httpx2_exception is a pure function. Client._terminal, AsyncClient._terminal +and both stream() methods call it from an inline `except MAPPED_HTTPX2_ERRORS`, and +next to that clause turn the RuntimeError httpx2 raises on a closed client into +TransportError. Clause ordering: TimeoutException -> InvalidURL/CookieConflict -> NetworkError/RemoteProtocolError --> HTTPError (subclass before parent so the right type wins). The two context managers -below wrap it for use as `with`/`async with` blocks around the httpx2 call, -and also turn the RuntimeError httpx2 raises on a closed client into TransportError. +-> HTTPError (subclass before parent so the right type wins). """ -import contextlib -from collections.abc import AsyncGenerator, Generator - import httpx2 from httpware.errors import NetworkError, TimeoutError, TransportError # noqa: A004 +MAPPED_HTTPX2_ERRORS = (httpx2.HTTPError, httpx2.InvalidURL, httpx2.CookieConflict) +"""The httpx2 exceptions map_httpx2_exception translates.""" + + def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | TransportError: """Map an httpx2 exception to its httpware equivalent. @@ -31,36 +32,3 @@ def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | Tr if isinstance(exc, httpx2.HTTPError): return TransportError(str(exc)) return TransportError(str(exc)) # pragma: no cover — defensive default; httpx2.HTTPError is the root - - -@contextlib.asynccontextmanager -async def _httpx2_exception_mapper(client: httpx2.AsyncClient) -> AsyncGenerator[None]: - """Map httpx2 exceptions, and use of a closed `client`, to httpware exceptions. - - Shared by AsyncClient._terminal and stream(). - """ - try: - yield - except httpx2.HTTPError as exc: - raise map_httpx2_exception(exc) from exc - except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: - raise map_httpx2_exception(exc) from exc - except RuntimeError as exc: - if client.is_closed: - raise TransportError(str(exc)) from exc - raise - - -@contextlib.contextmanager -def _httpx2_exception_mapper_sync(client: httpx2.Client) -> Generator[None]: - """Sync sibling of _httpx2_exception_mapper.""" - try: - yield - except httpx2.HTTPError as exc: - raise map_httpx2_exception(exc) from exc - except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: - raise map_httpx2_exception(exc) from exc - except RuntimeError as exc: - if client.is_closed: - raise TransportError(str(exc)) from exc - raise diff --git a/src/httpware/client.py b/src/httpware/client.py index 48619dc..c0ace04 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -18,11 +18,7 @@ _read_capped_async, _validate_max_response_body_bytes, ) -from httpware._internal.exception_mapping import ( - _httpx2_exception_mapper, - _httpx2_exception_mapper_sync, - map_httpx2_exception, -) +from httpware._internal.exception_mapping import MAPPED_HTTPX2_ERRORS, map_httpx2_exception from httpware._internal.status import ( STREAMING_BODY_MARKER, _is_streaming_body_async, @@ -32,6 +28,7 @@ ) from httpware.decoders import ResponseDecoder from httpware.decoders._resolver import _DecoderResolver +from httpware.errors import TransportError from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next from httpware.middleware.chain import compose, compose_async @@ -390,6 +387,8 @@ def _build_request( client: httpx2.Client | httpx2.AsyncClient, method: str, url: httpx2.URL | str, kwargs: dict[str, typing.Any] ) -> httpx2.Request: """Build via `client`, keeping the URL's own query bytes ahead of any params, which httpx2 would replace.""" + if (isinstance(url, str) and "?" not in url) or (kwargs.get("params") is None and not client.params): + return client.build_request(method, url, **kwargs) parsed = httpx2.URL(url) if not parsed.query: return client.build_request(method, url, **kwargs) @@ -442,12 +441,18 @@ def __init__( async def _terminal(self, request: httpx2.Request) -> httpx2.Response: cap = self._max_response_body_bytes - async with _httpx2_exception_mapper(self._httpx2_client): + try: if cap is None: response = await self._httpx2_client.send(request) else: streaming = await _send_capped_async(self._httpx2_client, request, cap) response = await _read_capped_and_close_async(streaming, cap) + except MAPPED_HTTPX2_ERRORS as exc: + raise map_httpx2_exception(exc) from exc + except RuntimeError as exc: + if self._httpx2_client.is_closed: + raise TransportError(str(exc)) from exc + raise _raise_on_status_error(response) return response @@ -1292,7 +1297,7 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa accessible. You lose the streaming property on errors; rare in practice. Maps httpx2 exceptions raised during the request OR body consumption to - httpware exceptions via _httpx2_exception_mapper. + httpware exceptions. """ kwargs = _assemble_request_kwargs( params=params, @@ -1312,15 +1317,22 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa if cap is None else _stream_capped_async(self._httpx2_client, method, url, kwargs, cap) ) - async with _httpx2_exception_mapper(self._httpx2_client), opened as response: - if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx - if cap is None: - await response.aread() # pre-read body so exc.response.content works - _raise_on_status_error(response) - else: - # Bound the error pre-read; raises ResponseTooLargeError when over cap. - _raise_on_status_error(await _read_capped_async(response, cap, response.request)) - yield response + try: + async with opened as response: + if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx + if cap is None: + await response.aread() # pre-read body so exc.response.content works + _raise_on_status_error(response) + else: + # Bound the error pre-read; raises ResponseTooLargeError when over cap. + _raise_on_status_error(await _read_capped_async(response, cap, response.request)) + yield response + except MAPPED_HTTPX2_ERRORS as exc: + raise map_httpx2_exception(exc) from exc + except RuntimeError as exc: + if self._httpx2_client.is_closed: + raise TransportError(str(exc)) from exc + raise async def __aenter__(self) -> typing.Self: """Enter the async context manager; return self.""" @@ -1391,12 +1403,18 @@ def __init__( def _terminal(self, request: httpx2.Request) -> httpx2.Response: cap = self._max_response_body_bytes - with _httpx2_exception_mapper_sync(self._httpx2_client): + try: if cap is None: response = self._httpx2_client.send(request) else: streaming = _send_capped(self._httpx2_client, request, cap) response = _read_capped_and_close(streaming, cap) + except MAPPED_HTTPX2_ERRORS as exc: + raise map_httpx2_exception(exc) from exc + except RuntimeError as exc: + if self._httpx2_client.is_closed: + raise TransportError(str(exc)) from exc + raise _raise_on_status_error(response) return response @@ -2258,7 +2276,7 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo body is pre-read so exc.response.content is accessible. Maps httpx2 exceptions raised during the request OR body consumption to - httpware exceptions via _httpx2_exception_mapper_sync. + httpware exceptions. """ kwargs = _assemble_request_kwargs( params=params, @@ -2278,12 +2296,19 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo if cap is None else _stream_capped(self._httpx2_client, method, url, kwargs, cap) ) - with _httpx2_exception_mapper_sync(self._httpx2_client), opened as response: - if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx - if cap is None: - response.read() # pre-read body so exc.response.content works - _raise_on_status_error(response) - else: - # Bound the error pre-read; raises ResponseTooLargeError when over cap. - _raise_on_status_error(_read_capped(response, cap, response.request)) - yield response + try: + with opened as response: + if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx + if cap is None: + response.read() # pre-read body so exc.response.content works + _raise_on_status_error(response) + else: + # Bound the error pre-read; raises ResponseTooLargeError when over cap. + _raise_on_status_error(_read_capped(response, cap, response.request)) + yield response + except MAPPED_HTTPX2_ERRORS as exc: + raise map_httpx2_exception(exc) from exc + except RuntimeError as exc: + if self._httpx2_client.is_closed: + raise TransportError(str(exc)) from exc + raise diff --git a/tests/test_error_mapping_terminal.py b/tests/test_error_mapping_terminal.py index 2a05bbd..d86e7dc 100644 --- a/tests/test_error_mapping_terminal.py +++ b/tests/test_error_mapping_terminal.py @@ -118,6 +118,18 @@ async def test_send_on_closed_client_raises_transport_error() -> None: await client.send(httpx2.Request("GET", "https://example.test/x")) +async def test_runtime_error_on_open_client_propagates() -> None: + raised = RuntimeError("synthetic runtime error") + + def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + raise raised + + client = _client_with_handler(handler) + with pytest.raises(RuntimeError) as info: + await client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value is raised + + async def test_httpx2_decoding_error_maps_to_transport_error() -> None: """Non-transient HTTPError (e.g. DecodingError) maps to bare TransportError, not NetworkError.""" @@ -235,6 +247,18 @@ def test_sync_send_on_closed_client_raises_transport_error() -> None: client.send(httpx2.Request("GET", "https://example.test/x")) +def test_sync_runtime_error_on_open_client_propagates() -> None: + raised = RuntimeError("synthetic runtime error") + + def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + raise raised + + client = _sync_client_with_handler(handler) + with pytest.raises(RuntimeError) as info: + client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value is raised + + def test_sync_httpx2_decoding_error_maps_to_transport_error() -> None: """Non-transient HTTPError (e.g. DecodingError) maps to bare TransportError, not NetworkError.""" diff --git a/tests/test_url_query_fast_path.py b/tests/test_url_query_fast_path.py new file mode 100644 index 0000000..134f879 --- /dev/null +++ b/tests/test_url_query_fast_path.py @@ -0,0 +1,107 @@ +"""URLs that skip the query merge, and boundary URLs that must not skip it, build the expected request.""" + +from http import HTTPStatus + +import httpx2 +import pytest + +from httpware import AsyncClient, Client + + +def _ok(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, request=request) + + +_CASES = [ + pytest.param("https://example.test/x", None, {}, "https://example.test/x", id="no-query-no-params"), + pytest.param("https://example.test/x", {"b": "2"}, {"c": "3"}, "https://example.test/x?c=3&b=2", id="no-query"), + pytest.param("https://example.test/x?a=1", None, {}, "https://example.test/x?a=1", id="query-without-params"), + pytest.param( + "https://example.test/x?a=1", {}, {}, "https://example.test/x?a=1", id="query-with-empty-params-not-skipped" + ), + pytest.param( + "https://example.test/x?a=1", None, {"c": "3"}, "https://example.test/x?a=1&c=3", id="client-params-not-skipped" + ), + pytest.param( + "https://example.test/x#f?a=1", + {"b": "2"}, + {}, + "https://example.test/x?b=2#f?a=1", + id="question-mark-in-fragment", + ), + pytest.param( + "https://example.test/x?a=1#f", {"b": "2"}, {}, "https://example.test/x?a=1&b=2#f", id="query-and-fragment" + ), + pytest.param("https://example.test/x?", {"b": "2"}, {}, "https://example.test/x?b=2", id="empty-query"), + pytest.param( + httpx2.URL("https://example.test/x?a=1"), + {"b": "2"}, + {}, + "https://example.test/x?a=1&b=2", + id="url-instance-with-query-not-skipped", + ), + pytest.param( + httpx2.URL("https://example.test/x?a=1"), None, {}, "https://example.test/x?a=1", id="url-instance-no-params" + ), + pytest.param( + httpx2.URL("https://example.test/x"), {"b": "2"}, {}, "https://example.test/x?b=2", id="url-instance-no-query" + ), +] + + +@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _CASES) +def test_async_build_request_url( + url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str +) -> None: + client = AsyncClient(httpx2_client=httpx2.AsyncClient(params=client_params)) + assert str(client.build_request("GET", url, params=params).url) == expected + + +@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _CASES) +def test_sync_build_request_url( + url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str +) -> None: + client = Client(httpx2_client=httpx2.Client(params=client_params)) + assert str(client.build_request("GET", url, params=params).url) == expected + + +_BASE_URL_CASES = [ + pytest.param("items", None, {}, "https://example.test/api/items", id="relative-no-query"), + pytest.param("items?a=1", None, {}, "https://example.test/api/items?a=1", id="relative-query-without-params"), + pytest.param("/items?a=1", {"b": "2"}, {}, "https://example.test/api/items?a=1&b=2", id="relative-query-params"), + pytest.param( + "items?a=1", None, {"c": "3"}, "https://example.test/api/items?a=1&c=3", id="relative-query-client-params" + ), +] + + +@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _BASE_URL_CASES) +async def test_async_get_joins_base_url( + url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str +) -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return _ok(request) + + client = AsyncClient( + base_url="https://example.test/api", params=client_params, transport=httpx2.MockTransport(handler) + ) + await client.get(url, params=params) + assert str(captured[0].url) == expected + + +@pytest.mark.parametrize(("url", "params", "client_params", "expected"), _BASE_URL_CASES) +def test_sync_get_joins_base_url( + url: str, params: dict[str, str] | None, client_params: dict[str, str], expected: str +) -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return _ok(request) + + client = Client(base_url="https://example.test/api", params=client_params, transport=httpx2.MockTransport(handler)) + client.get(url, params=params) + assert str(captured[0].url) == expected