diff --git a/README.md b/README.md index a5a2de57..6ac6c0b8 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ ``` [Impit](https://github.com/apify/impit) is the default HTTP client and is installed automatically. To use the - built-in [HTTPX](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which + built-in [HTTPX2](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which provides Pydantic's maintained continuation of HTTPX, and pass `http_client=Httpx2HttpClient()` to `ApifyClient.with_custom_http_client()`: @@ -135,7 +135,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r - **Tiered timeouts** — short / medium / long tiers picked per endpoint, overridable per call ([Timeouts](https://docs.apify.com/api/client/python/docs/concepts/timeouts)). - **Pagination and streaming** — iterate datasets, key-value store keys, or live logs without manual paging or buffering ([Pagination](https://docs.apify.com/api/client/python/docs/concepts/pagination), [Streaming](https://docs.apify.com/api/client/python/docs/concepts/streaming-resources)). - **Convenience methods** — `call()`, `wait_for_finish()`, nested resource access, and other shortcuts that hide platform quirks ([Convenience methods](https://docs.apify.com/api/client/python/docs/concepts/convenience-methods)). -- **Pluggable HTTP layer** — use the default [Impit](https://github.com/apify/impit)-based client, opt in to the built-in [HTTPX](https://github.com/pydantic/httpx2) client, or plug in any custom implementation ([HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)). +- **Pluggable HTTP layer** — use the default [Impit](https://github.com/apify/impit)-based client, opt in to the built-in [HTTPX2](https://github.com/pydantic/httpx2) client, or plug in any custom implementation ([HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)). - **Structured errors** — every API error surfaces as an [`ApifyApiError`](https://docs.apify.com/api/client/python/reference/class/ApifyApiError) with HTTP-specific subclasses for precise handling ([Error handling](https://docs.apify.com/api/client/python/docs/concepts/error-handling)). - **Debug logging** — opt-in structured logging on the `apify_client` logger captures request URLs, status codes, retry attempts, and more ([Logging](https://docs.apify.com/api/client/python/docs/concepts/logging)). diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 1a530baf..44b3ac1f 100644 --- a/docs/01_introduction/index.mdx +++ b/docs/01_introduction/index.mdx @@ -63,7 +63,7 @@ For better request-body compression, opt in to `brotli`, which compresses better For details, see [HTTP compression](../02_concepts/13_http_compression.mdx). -The client uses [Impit](https://github.com/apify/impit) as its default HTTP transport. To use the built-in [HTTPX](https://github.com/pydantic/httpx2) transport, install the optional `httpx2` extra, which provides Pydantic's maintained continuation of HTTPX: +The client uses [Impit](https://github.com/apify/impit) as its default HTTP transport. To use the built-in [HTTPX2](https://github.com/pydantic/httpx2) transport, install the optional `httpx2` extra, which provides Pydantic's maintained continuation of HTTPX: diff --git a/docs/02_concepts/10_custom_http_clients.mdx b/docs/02_concepts/10_custom_http_clients.mdx index a254281a..7d4289d6 100644 --- a/docs/02_concepts/10_custom_http_clients.mdx +++ b/docs/02_concepts/10_custom_http_clients.mdx @@ -19,7 +19,7 @@ import ArchitectureImportsExample from '!!raw-loader!./code/10_architecture_impo import PluggingInAsyncExample from '!!raw-loader!./code/10_plugging_in_async.py'; import PluggingInSyncExample from '!!raw-loader!./code/10_plugging_in_sync.py'; -The Apify API client uses a pluggable HTTP layer. It ships with an [Impit](https://github.com/apify/impit)-based default, offers [HTTPX](https://github.com/pydantic/httpx2) as an optional built-in alternative, and accepts custom synchronous or asynchronous implementations. +The Apify API client uses a pluggable HTTP layer. It ships with an [Impit](https://github.com/apify/impit)-based default, offers [HTTPX2](https://github.com/pydantic/httpx2) as an optional built-in alternative, and accepts custom synchronous or asynchronous implementations. ## Default HTTP client @@ -45,11 +45,11 @@ You can configure the default client through the -## Built-in HTTPX client +## Built-in HTTPX2 client -The package also provides `Httpx2HttpClient` and `Httpx2HttpClientAsync`. They use the same request preparation, compression, retry policy, timeout tiers and growth, error handling, logging, and statistics as the default Impit clients, with [HTTPX](https://github.com/pydantic/httpx2) as the transport. +The package also provides `Httpx2HttpClient` and `Httpx2HttpClientAsync`. They use the same request preparation, compression, retry policy, timeout tiers and growth, error handling, logging, and statistics as the default Impit clients, with [HTTPX2](https://github.com/pydantic/httpx2) as the transport. -HTTPX is an optional dependency provided by the `httpx2` package, Pydantic's maintained continuation of HTTPX. Install `apify-client[httpx2]`, then pass the appropriate client to `ApifyClient.with_custom_http_client`. Impit remains the default even when the extra is installed. +The `httpx2` package, Pydantic's maintained continuation of HTTPX, is an optional dependency. Install `apify-client[httpx2]`, then pass the appropriate client to `ApifyClient.with_custom_http_client`. Impit remains the default even when the extra is installed. ```bash pip install "apify-client[httpx2]" @@ -70,9 +70,9 @@ uv add "apify-client[httpx2]" -Configure retries, timeout tiers, default headers, and compression on the HTTPX client instance. The token passed to `with_custom_http_client` is applied automatically unless the HTTP client already has an `Authorization` header. The examples use the clients as context managers so their connection pools are closed deterministically. If a context manager doesn't fit your application's lifecycle, call `close()` on `Httpx2HttpClient` or `await aclose()` on `Httpx2HttpClientAsync` during shutdown. +Configure retries, timeout tiers, default headers, and compression on the HTTPX2 client instance. The token passed to `with_custom_http_client` is applied automatically unless the HTTP client already has an `Authorization` header. The examples use the clients as context managers so their connection pools are closed deterministically. If a context manager doesn't fit your application's lifecycle, call `close()` on `Httpx2HttpClient` or `await aclose()` on `Httpx2HttpClientAsync` during shutdown. -Timeout values are passed to the selected transport. Impit enforces them as a deadline for the whole request, body included. HTTPX applies them to each socket operation instead, so a response whose body arrives slowly keeps resetting the timeout and can outlast both the requested timeout and `timeout_max`. The `no_timeout` option disables HTTPX's timeouts. +Timeout values are passed to the selected transport. Impit enforces them as a deadline for the whole request, body included. HTTPX2 applies them to each socket operation instead, so a response whose body arrives slowly keeps resetting the timeout and can outlast both the requested timeout and `timeout_max`. The `no_timeout` option disables HTTPX2's timeouts. ## Architecture @@ -80,7 +80,7 @@ Internally, the HTTP client hierarchy has three layers: - A common internal base contains configuration and utilities shared by synchronous and asynchronous clients, including headers, request-body preparation, parameters, compression, and timeout tiers. It isn't a public extension point. - `HttpClient` and `HttpClientAsync` add the synchronous or asynchronous request pipeline, retry loop, transport hooks, and lifecycle interface. -- The built-in Impit and HTTPX classes inherit directly from the corresponding sync or async class and adapt the underlying transport. +- The built-in Impit and HTTPX2 classes inherit directly from the corresponding sync or async class and adapt the underlying transport. `HttpClient.is_timeout_error(exc)` and `HttpClientAsync.is_timeout_error(exc)` are the public, transport-neutral way to tell whether an exception is a timeout, so code built on the client, such as streamed logs, doesn't need to know which transport raised it. @@ -103,7 +103,7 @@ The public `call` method provides the shared request pipeline. A concrete transp - `is_timeout_error(exc)` identifies transport-specific timeout exceptions for higher-level client features. The default recognizes Python's `TimeoutError`. Timeout classification is independent of retryability, so a timeout the retry loop should retry has to be listed in `is_retryable_transport_error` too. - `close()` or `aclose()` closes resources owned by the transport. The default does nothing, which is correct for a transport that owns no pool or session. -Decorate your implementations with `@override`, as the built-in Impit and HTTPX adapters do, so a type checker catches a misspelled or incompatible override. +Decorate your implementations with `@override`, as the built-in Impit and HTTPX2 adapters do, so a type checker catches a misspelled or incompatible override. ### The HTTP response protocol @@ -124,9 +124,9 @@ Decorate your implementations with `@override`, as the built-in Impit and HTTPX | `aiter_bytes() -> AsyncIterator[bytes]` | Iterate body in chunks (async) | :::note -Many HTTP libraries, including our default [Impit](https://github.com/apify/impit) or for example [HTTPX](https://github.com/pydantic/httpx2) already satisfy this protocol out of the box. +Many HTTP libraries, including our default [Impit](https://github.com/apify/impit) or for example [HTTPX2](https://github.com/pydantic/httpx2) already satisfy this protocol out of the box. -For a streamed response, consume the body inside the streaming context manager with `iter_bytes()` / `aiter_bytes()`, or call `read()` / `aread()` before accessing `content`. Some transports, including HTTPX, intentionally reject `content` on an unread streamed response. +For a streamed response, consume the body inside the streaming context manager with `iter_bytes()` / `aiter_bytes()`, or call `read()` / `aread()` before accessing `content`. Some transports, including HTTPX2, intentionally reject `content` on an unread streamed response. ::: ### Plugging it in @@ -154,7 +154,7 @@ If you override `call` itself, your implementation becomes responsible for reque ## Use cases -Custom HTTP clients might be useful when the built-in Impit and HTTPX clients don't cover your requirements, for example when you need to: +Custom HTTP clients might be useful when the built-in Impit and HTTPX2 clients don't cover your requirements, for example when you need to: - **Use a different HTTP library** - Integrate [requests](https://requests.readthedocs.io/), [aiohttp](https://docs.aiohttp.org/), or another transport. - **Route through a proxy** - Add proxy support or request routing. diff --git a/docs/03_guides/05_custom_http_client.mdx b/docs/03_guides/05_custom_http_client.mdx index 52a02848..05f1c05e 100644 --- a/docs/03_guides/05_custom_http_client.mdx +++ b/docs/03_guides/05_custom_http_client.mdx @@ -14,7 +14,7 @@ import CustomHttpClientSyncExample from '!!raw-loader!./code/05_custom_http_clie This guide implements a custom `HttpClientAsync` with [aiohttp](https://docs.aiohttp.org/) and a custom `HttpClient` with [requests](https://requests.readthedocs.io/). Neither library satisfies the `HttpResponse` protocol, so both examples also show how to adapt a foreign response API. -For an overview of the architecture and the built-in Impit and HTTPX implementations, see [HTTP clients](../02_concepts/10_custom_http_clients.mdx). +For an overview of the architecture and the built-in Impit and HTTPX2 implementations, see [HTTP clients](../02_concepts/10_custom_http_clients.mdx). ## Installation @@ -47,5 +47,5 @@ Each example has three parts: :::warning -These examples are compact integrations, not a replacement for all built-in client behavior. A production custom client should account for transport-specific details such as proxy configuration, TLS settings, redirects, and response resource cleanup. Timeout semantics differ per transport too: the aiohttp example passes the value as a budget for the whole request, while `requests` applies it to each socket read. Both example sessions also keep a shared cookie jar, which replays server cookies on later API requests. The built-in HTTPX client clears it instead. +These examples are compact integrations, not a replacement for all built-in client behavior. A production custom client should account for transport-specific details such as proxy configuration, TLS settings, redirects, and response resource cleanup. Timeout semantics differ per transport too: the aiohttp example passes the value as a budget for the whole request, while `requests` applies it to each socket read. Both example sessions also keep a shared cookie jar, which replays server cookies on later API requests. The built-in HTTPX2 client clears it instead. ::: diff --git a/docs/04_upgrading/upgrading_to_v2.mdx b/docs/04_upgrading/upgrading_to_v2.mdx index c794503f..ed02202b 100644 --- a/docs/04_upgrading/upgrading_to_v2.mdx +++ b/docs/04_upgrading/upgrading_to_v2.mdx @@ -14,7 +14,7 @@ Support for Python 3.9 has been dropped. The Apify Python API Client v2.x now re ## New underlying HTTP library -In v2.0, the Apify Python API client switched from using [`httpx`](https://github.com/pydantic/httpx2) to [`impit`](https://github.com/apify/impit) as the underlying HTTP library. However, this change shouldn't have much impact on the end user. +In v2.0, the Apify Python API client switched from using [`httpx`](https://www.python-httpx.org/) to [`impit`](https://github.com/apify/impit) as the underlying HTTP library. However, this change shouldn't have much impact on the end user. ## API method changes diff --git a/src/apify_client/http_clients/__init__.py b/src/apify_client/http_clients/__init__.py index 399726f5..6bfea628 100644 --- a/src/apify_client/http_clients/__init__.py +++ b/src/apify_client/http_clients/__init__.py @@ -5,7 +5,7 @@ _install_import_hook(__name__) -# `httpx2` is an optional extra, so the import is wrapped in try_import. Accessing the HTTPX clients without the +# `httpx2` is an optional extra, so the import is wrapped in try_import. Accessing the HTTPX2 clients without the # extra installed raises a clear ImportError instead of failing at package import time. with _try_import( __name__, diff --git a/src/apify_client/http_clients/_httpx2.py b/src/apify_client/http_clients/_httpx2.py index f2b155b4..429fda78 100644 --- a/src/apify_client/http_clients/_httpx2.py +++ b/src/apify_client/http_clients/_httpx2.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -import httpx2 as httpx +import httpx2 from typing_extensions import override from apify_client._consts import ( @@ -24,32 +24,32 @@ _PERMANENT_ERRORS = ( - # A request HTTPX rejects before sending it, e.g. one carrying an invalid header value. - httpx.LocalProtocolError, - # A URL scheme HTTPX refuses to speak, which repeating the request cannot change. - httpx.UnsupportedProtocol, + # A request HTTPX2 rejects before sending it, e.g. one carrying an invalid header value. + httpx2.LocalProtocolError, + # A URL scheme HTTPX2 refuses to speak, which repeating the request cannot change. + httpx2.UnsupportedProtocol, # An over-long redirect chain is a routing loop, which repeating the request cannot break. - httpx.TooManyRedirects, + httpx2.TooManyRedirects, # Only `Response.raise_for_status()` raises this, and the client never calls it - the shared pipeline decides on # status codes from the response itself. - httpx.HTTPStatusError, + httpx2.HTTPStatusError, ) -"""HTTPX errors that a retry cannot fix. Everything else in the `httpx.HTTPError` tree counts as transient.""" +"""HTTPX2 errors that a retry cannot fix. Everything else in the `httpx2.HTTPError` tree counts as transient.""" @docs_group('HTTP clients') class Httpx2HttpClient(HttpClient): - """Synchronous HTTP client for the Apify API built on top of [HTTPX](https://github.com/pydantic/httpx2). + """Synchronous HTTP client for the Apify API built on top of [HTTPX2](https://github.com/pydantic/httpx2). - This client wraps `httpx.Client` and adds automatic retries with exponential backoff for rate-limited + This client wraps `httpx2.Client` and adds automatic retries with exponential backoff for rate-limited (HTTP 429) and server error (HTTP 5xx) responses. - HTTPX applies a request timeout to each socket operation rather than to the request as a whole, so a response + HTTPX2 applies a request timeout to each socket operation rather than to the request as a whole, so a response whose body arrives slowly keeps resetting it and can outlast both the requested timeout and `timeout_max`. The default Impit client enforces the same value as a deadline for the whole request, body included. Requires the `httpx2` extra: `pip install "apify-client[httpx2]"`. The `httpx2` package is Pydantic's maintained - continuation of HTTPX, which this module imports under the `httpx` name. + continuation of HTTPX. """ def __init__( @@ -66,7 +66,7 @@ def __init__( headers: dict[str, str] | None = None, http_compressor: HttpCompressor | None = None, ) -> None: - """Initialize the HTTPX-based synchronous HTTP client. + """Initialize the HTTPX2-based synchronous HTTP client. Args: token: Apify API token for authentication. @@ -93,27 +93,27 @@ def __init__( http_compressor=http_compressor, ) - self._httpx_client = httpx.Client( + self._httpx2_client = httpx2.Client( follow_redirects=True, event_hooks={'response': [self._clear_response_cookies]}, ) @override def is_timeout_error(self, exc: Exception) -> bool: - return super().is_timeout_error(exc) or isinstance(exc, httpx.TimeoutException) + return super().is_timeout_error(exc) or isinstance(exc, httpx2.TimeoutException) @override def is_retryable_transport_error(self, exc: Exception) -> bool: - # Every error from HTTPX's own hierarchy counts as transient except the permanently-failing types listed in - # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX adds later is retried rather than + # Every error from HTTPX2's own hierarchy counts as transient except the permanently-failing types listed in + # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX2 adds later is retried rather than # silently treated as fatal. HTTP status code errors are handled by the shared pipeline based on the # response status code, not here. - return isinstance(exc, httpx.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS) + return isinstance(exc, httpx2.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS) @override def close(self) -> None: - """Close the underlying HTTPX connection pool.""" - self._httpx_client.close() + """Close the underlying HTTPX2 connection pool.""" + self._httpx2_client.close() @override def send_request( @@ -125,8 +125,8 @@ def send_request( content: bytes | None, timeout: float | None, stream: bool, - ) -> httpx.Response: - request = self._httpx_client.build_request( + ) -> httpx2.Response: + request = self._httpx2_client.build_request( method=method, url=url, headers=headers, @@ -134,26 +134,26 @@ def send_request( timeout=timeout, ) _restore_explicit_cookie_header(request, headers) - return self._httpx_client.send(request, stream=stream) + return self._httpx2_client.send(request, stream=stream) - def _clear_response_cookies(self, _response: httpx.Response) -> None: - """Prevent HTTPX's shared cookie jar from leaking server cookies into later API requests.""" - self._httpx_client.cookies.clear() + def _clear_response_cookies(self, _response: httpx2.Response) -> None: + """Prevent HTTPX2's shared cookie jar from leaking server cookies into later API requests.""" + self._httpx2_client.cookies.clear() @docs_group('HTTP clients') class Httpx2HttpClientAsync(HttpClientAsync): - """Asynchronous HTTP client for the Apify API built on top of [HTTPX](https://github.com/pydantic/httpx2). + """Asynchronous HTTP client for the Apify API built on top of [HTTPX2](https://github.com/pydantic/httpx2). - This client wraps `httpx.AsyncClient` and adds automatic retries with exponential backoff for rate-limited + This client wraps `httpx2.AsyncClient` and adds automatic retries with exponential backoff for rate-limited (HTTP 429) and server error (HTTP 5xx) responses. - HTTPX applies a request timeout to each socket operation rather than to the request as a whole, so a response + HTTPX2 applies a request timeout to each socket operation rather than to the request as a whole, so a response whose body arrives slowly keeps resetting it and can outlast both the requested timeout and `timeout_max`. The default Impit client enforces the same value as a deadline for the whole request, body included. Requires the `httpx2` extra: `pip install "apify-client[httpx2]"`. The `httpx2` package is Pydantic's maintained - continuation of HTTPX, which this module imports under the `httpx` name. + continuation of HTTPX. """ def __init__( @@ -170,7 +170,7 @@ def __init__( headers: dict[str, str] | None = None, http_compressor: HttpCompressor | None = None, ) -> None: - """Initialize the HTTPX-based asynchronous HTTP client. + """Initialize the HTTPX2-based asynchronous HTTP client. Args: token: Apify API token for authentication. @@ -197,27 +197,27 @@ def __init__( http_compressor=http_compressor, ) - self._httpx_async_client = httpx.AsyncClient( + self._httpx2_async_client = httpx2.AsyncClient( follow_redirects=True, event_hooks={'response': [self._clear_response_cookies]}, ) @override def is_timeout_error(self, exc: Exception) -> bool: - return super().is_timeout_error(exc) or isinstance(exc, httpx.TimeoutException) + return super().is_timeout_error(exc) or isinstance(exc, httpx2.TimeoutException) @override def is_retryable_transport_error(self, exc: Exception) -> bool: - # Every error from HTTPX's own hierarchy counts as transient except the permanently-failing types listed in - # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX adds later is retried rather than + # Every error from HTTPX2's own hierarchy counts as transient except the permanently-failing types listed in + # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX2 adds later is retried rather than # silently treated as fatal. HTTP status code errors are handled by the shared pipeline based on the # response status code, not here. - return isinstance(exc, httpx.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS) + return isinstance(exc, httpx2.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS) @override async def aclose(self) -> None: - """Close the underlying asynchronous HTTPX connection pool.""" - await self._httpx_async_client.aclose() + """Close the underlying asynchronous HTTPX2 connection pool.""" + await self._httpx2_async_client.aclose() @override async def send_request( @@ -229,8 +229,8 @@ async def send_request( content: bytes | None, timeout: float | None, stream: bool, - ) -> httpx.Response: - request = self._httpx_async_client.build_request( + ) -> httpx2.Response: + request = self._httpx2_async_client.build_request( method=method, url=url, headers=headers, @@ -238,17 +238,17 @@ async def send_request( timeout=timeout, ) _restore_explicit_cookie_header(request, headers) - return await self._httpx_async_client.send(request, stream=stream) + return await self._httpx2_async_client.send(request, stream=stream) - async def _clear_response_cookies(self, _response: httpx.Response) -> None: - """Prevent HTTPX's shared cookie jar from leaking server cookies into later API requests.""" - self._httpx_async_client.cookies.clear() + async def _clear_response_cookies(self, _response: httpx2.Response) -> None: + """Prevent HTTPX2's shared cookie jar from leaking server cookies into later API requests.""" + self._httpx2_async_client.cookies.clear() -def _restore_explicit_cookie_header(request: httpx.Request, headers: dict[str, str]) -> None: - """Keep only cookies explicitly supplied for this request, never cookies from HTTPX's shared jar. +def _restore_explicit_cookie_header(request: httpx2.Request, headers: dict[str, str]) -> None: + """Keep only cookies explicitly supplied for this request, never cookies from HTTPX2's shared jar. - HTTPX drops the `Cookie` header when it builds a redirect request and rebuilds it from the jar, so an explicit + HTTPX2 drops the `Cookie` header when it builds a redirect request and rebuilds it from the jar, so an explicit cookie only reaches the first hop of a redirected request. """ explicit_cookie = next((value for key, value in headers.items() if key.lower() == 'cookie'), None) diff --git a/tests/unit/test_client_headers.py b/tests/unit/test_client_headers.py index e3e02d43..8706c31b 100644 --- a/tests/unit/test_client_headers.py +++ b/tests/unit/test_client_headers.py @@ -6,7 +6,7 @@ from importlib import metadata from typing import TYPE_CHECKING -import httpx2 as httpx +import httpx2 from werkzeug import Request, Response from apify_client.http_clients import Httpx2HttpClient, Httpx2HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync @@ -28,9 +28,9 @@ def _transport_wire_headers( """Return the headers the transport adds on its own and the content encodings it advertises.""" if issubclass(client_class, (ImpitHttpClient, ImpitHttpClientAsync)): return {}, {'zstd', 'gzip', 'deflate', 'br'} - # HTTPX advertises whichever decoders happen to be installed alongside it, so read the set off the client + # HTTPX2 advertises whichever decoders happen to be installed alongside it, so read the set off the client # itself rather than hard-coding it and breaking whenever the environment gains or loses a codec. - with httpx.Client() as probe: + with httpx2.Client() as probe: return {'Connection': 'keep-alive'}, _parse_accept_encoding(probe.headers['accept-encoding']) @@ -187,56 +187,56 @@ def _echo_cookie_handler(request: Request) -> Response: return Response(json.dumps({'cookie': request.headers.get('Cookie')}), content_type='application/json') -def test_httpx_does_not_reuse_server_cookies(httpserver: HTTPServer) -> None: - """A Set-Cookie response must not enter HTTPX's shared cookie jar, nor leak into a later API request.""" +def test_httpx2_does_not_reuse_server_cookies(httpserver: HTTPServer) -> None: + """A Set-Cookie response must not enter HTTPX2's shared cookie jar, nor leak into a later API request.""" httpserver.expect_request('/set-cookie').respond_with_data('ok', headers={'Set-Cookie': 'session=secret'}) httpserver.expect_request('/echo-cookie').respond_with_handler(_echo_cookie_handler) with Httpx2HttpClient() as client: client.call(method='GET', url=httpserver.url_for('/set-cookie')) - assert len(client._httpx_client.cookies) == 0 + assert len(client._httpx2_client.cookies) == 0 response = client.call(method='GET', url=httpserver.url_for('/echo-cookie')) assert response.json() == {'cookie': None} -async def test_httpx_async_does_not_reuse_server_cookies(httpserver: HTTPServer) -> None: - """The asynchronous HTTPX pool also remains stateless between API calls.""" +async def test_httpx2_async_does_not_reuse_server_cookies(httpserver: HTTPServer) -> None: + """The asynchronous HTTPX2 pool also remains stateless between API calls.""" httpserver.expect_request('/set-cookie').respond_with_data('ok', headers={'Set-Cookie': 'session=secret'}) httpserver.expect_request('/echo-cookie').respond_with_handler(_echo_cookie_handler) async with Httpx2HttpClientAsync() as client: await client.call(method='GET', url=httpserver.url_for('/set-cookie')) - assert len(client._httpx_async_client.cookies) == 0 + assert len(client._httpx2_async_client.cookies) == 0 response = await client.call(method='GET', url=httpserver.url_for('/echo-cookie')) assert response.json() == {'cookie': None} -def test_httpx_drops_cookies_left_in_the_shared_jar(httpserver: HTTPServer) -> None: +def test_httpx2_drops_cookies_left_in_the_shared_jar(httpserver: HTTPServer) -> None: """A cookie another in-flight request left in the shared jar must not ride along on the next request.""" httpserver.expect_request('/echo-cookie').respond_with_handler(_echo_cookie_handler) with Httpx2HttpClient() as client: - client._httpx_client.cookies.set('session', 'secret', domain=httpserver.host) + client._httpx2_client.cookies.set('session', 'secret', domain=httpserver.host) response = client.call(method='GET', url=httpserver.url_for('/echo-cookie')) assert response.json() == {'cookie': None} -async def test_httpx_async_drops_cookies_left_in_the_shared_jar(httpserver: HTTPServer) -> None: +async def test_httpx2_async_drops_cookies_left_in_the_shared_jar(httpserver: HTTPServer) -> None: """The asynchronous pool, where concurrent requests really do share one jar, drops leftover cookies too.""" httpserver.expect_request('/echo-cookie').respond_with_handler(_echo_cookie_handler) async with Httpx2HttpClientAsync() as client: - client._httpx_async_client.cookies.set('session', 'secret', domain=httpserver.host) + client._httpx2_async_client.cookies.set('session', 'secret', domain=httpserver.host) response = await client.call(method='GET', url=httpserver.url_for('/echo-cookie')) assert response.json() == {'cookie': None} -def test_httpx_does_not_carry_server_cookies_across_a_redirect(httpserver: HTTPServer) -> None: - """A cookie set by a redirecting response must not ride along on the next hop, which HTTPX builds from its jar.""" +def test_httpx2_does_not_carry_server_cookies_across_a_redirect(httpserver: HTTPServer) -> None: + """A cookie set by a redirecting response must not ride along on the next hop, which HTTPX2 builds from its jar.""" httpserver.expect_request('/redirect').respond_with_data( '', status=302, @@ -250,7 +250,7 @@ def test_httpx_does_not_carry_server_cookies_across_a_redirect(httpserver: HTTPS assert response.json() == {'cookie': None} -async def test_httpx_async_does_not_carry_server_cookies_across_a_redirect(httpserver: HTTPServer) -> None: +async def test_httpx2_async_does_not_carry_server_cookies_across_a_redirect(httpserver: HTTPServer) -> None: """The asynchronous pool keeps a redirecting response's cookie off the next hop too.""" httpserver.expect_request('/redirect').respond_with_data( '', @@ -265,7 +265,7 @@ async def test_httpx_async_does_not_carry_server_cookies_across_a_redirect(https assert response.json() == {'cookie': None} -def test_httpx_keeps_explicit_cookie_header(httpserver: HTTPServer) -> None: +def test_httpx2_keeps_explicit_cookie_header(httpserver: HTTPServer) -> None: """Disabling the shared cookie jar must not remove a Cookie header explicitly supplied by the caller.""" httpserver.expect_request('/echo-explicit-cookie').respond_with_handler(_echo_cookie_handler) @@ -279,7 +279,7 @@ def test_httpx_keeps_explicit_cookie_header(httpserver: HTTPServer) -> None: assert response.json() == {'cookie': 'explicit=value'} -async def test_httpx_async_keeps_explicit_cookie_header(httpserver: HTTPServer) -> None: +async def test_httpx2_async_keeps_explicit_cookie_header(httpserver: HTTPServer) -> None: """The asynchronous pool forwards an explicitly supplied Cookie header as well.""" httpserver.expect_request('/echo-explicit-cookie').respond_with_handler(_echo_cookie_handler) diff --git a/tests/unit/test_client_timeouts.py b/tests/unit/test_client_timeouts.py index 046ae905..f2ce6bb4 100644 --- a/tests/unit/test_client_timeouts.py +++ b/tests/unit/test_client_timeouts.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock -import httpx2 as httpx +import httpx2 import impit import pytest @@ -23,8 +23,8 @@ if TYPE_CHECKING: from _pytest.logging import LogCaptureFixture -UNSET_HTTPX_TIMEOUT = {'connect': None, 'read': None, 'write': None, 'pool': None} -"""What HTTPX stores on a request built with `timeout=None`: every sub-timeout unset, not the client default.""" +UNSET_HTTPX2_TIMEOUT = {'connect': None, 'read': None, 'write': None, 'pool': None} +"""What HTTPX2 stores on a request built with `timeout=None`: every sub-timeout unset, not the client default.""" @pytest.fixture @@ -40,7 +40,7 @@ def successful_response() -> Mock: def retryable_error(client: HttpClient | HttpClientAsync) -> Exception: if isinstance(client, (ImpitHttpClient, ImpitHttpClientAsync)): return impit.TimeoutException('timeout') - return httpx.ReadTimeout('timeout', request=httpx.Request('GET', 'https://example.com')) + return httpx2.ReadTimeout('timeout', request=httpx2.Request('GET', 'https://example.com')) @pytest.mark.parametrize( @@ -235,29 +235,29 @@ async def test_no_timeout_mapping_for_async_impit_adapter() -> None: assert client._impit_async_client.request.call_args.kwargs['timeout'] == 86_400 -def test_no_timeout_mapping_for_sync_httpx_adapter(monkeypatch: pytest.MonkeyPatch) -> None: - """The synchronous HTTPX adapter maps no-timeout to every HTTPX sub-timeout being unset.""" - # Only the transport call is stubbed, so the real `build_request` decides what `None` means to HTTPX. +def test_no_timeout_mapping_for_sync_httpx2_adapter(monkeypatch: pytest.MonkeyPatch) -> None: + """The synchronous HTTPX2 adapter maps no-timeout to every HTTPX2 sub-timeout being unset.""" + # Only the transport call is stubbed, so the real `build_request` decides what `None` means to HTTPX2. with Httpx2HttpClient() as client: send = Mock(return_value=successful_response()) - monkeypatch.setattr(client._httpx_client, 'send', send) + monkeypatch.setattr(client._httpx2_client, 'send', send) client.send_request( method='GET', url='https://example.com', headers={}, content=None, timeout=None, stream=False ) - assert send.call_args.args[0].extensions['timeout'] == UNSET_HTTPX_TIMEOUT + assert send.call_args.args[0].extensions['timeout'] == UNSET_HTTPX2_TIMEOUT -async def test_no_timeout_mapping_for_async_httpx_adapter(monkeypatch: pytest.MonkeyPatch) -> None: - """The asynchronous HTTPX adapter maps no-timeout to every HTTPX sub-timeout being unset.""" - # Only the transport call is stubbed, so the real `build_request` decides what `None` means to HTTPX. +async def test_no_timeout_mapping_for_async_httpx2_adapter(monkeypatch: pytest.MonkeyPatch) -> None: + """The asynchronous HTTPX2 adapter maps no-timeout to every HTTPX2 sub-timeout being unset.""" + # Only the transport call is stubbed, so the real `build_request` decides what `None` means to HTTPX2. async with Httpx2HttpClientAsync() as client: send = AsyncMock(return_value=successful_response()) - monkeypatch.setattr(client._httpx_async_client, 'send', send) + monkeypatch.setattr(client._httpx2_async_client, 'send', send) await client.send_request( method='GET', url='https://example.com', headers={}, content=None, timeout=None, stream=False ) - assert send.call_args.args[0].extensions['timeout'] == UNSET_HTTPX_TIMEOUT + assert send.call_args.args[0].extensions['timeout'] == UNSET_HTTPX2_TIMEOUT diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 4e55cb8b..3e441189 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, Mock import brotli -import httpx2 as httpx +import httpx2 import impit import pytest @@ -267,22 +267,22 @@ async def test_http_client_async_creates_async_impit_client() -> None: await client.aclose() -def test_http_client_creates_sync_httpx_client() -> None: - """The synchronous HTTPX adapter creates the underlying HTTPX client, and the close hook closes its pool.""" +def test_http_client_creates_sync_httpx2_client() -> None: + """The synchronous HTTPX2 adapter creates the underlying HTTPX2 client, and the close hook closes its pool.""" client = Httpx2HttpClient(token='test_token_123') - assert isinstance(client._httpx_client, httpx.Client) + assert isinstance(client._httpx2_client, httpx2.Client) client.close() - assert client._httpx_client.is_closed + assert client._httpx2_client.is_closed -async def test_http_client_async_creates_async_httpx_client() -> None: - """The asynchronous HTTPX adapter creates the underlying HTTPX client, and the close hook closes its pool.""" +async def test_http_client_async_creates_async_httpx2_client() -> None: + """The asynchronous HTTPX2 adapter creates the underlying HTTPX2 client, and the close hook closes its pool.""" client = Httpx2HttpClientAsync(token='test_token_123') - assert isinstance(client._httpx_async_client, httpx.AsyncClient) + assert isinstance(client._httpx2_async_client, httpx2.AsyncClient) await client.aclose() - assert client._httpx_async_client.is_closed + assert client._httpx2_async_client.is_closed def test_parse_params_none() -> None: @@ -416,20 +416,20 @@ async def test_async_http_client_classifies_timeout_errors() -> None: @pytest.mark.parametrize( 'exc', [ - # Even the generic base class is transient: HTTPX subclasses it for every failure mode, so an + # Even the generic base class is transient: HTTPX2 subclasses it for every failure mode, so an # unclassified failure is safer to retry. - pytest.param(httpx.HTTPError('unclassified failure'), id='bare HTTPError'), - pytest.param(httpx.TimeoutException('timeout'), id='TimeoutException'), - pytest.param(httpx.NetworkError('network error'), id='NetworkError'), - pytest.param(httpx.RemoteProtocolError('remote protocol error'), id='RemoteProtocolError'), - pytest.param(httpx.DecodingError('decoding error'), id='DecodingError'), + pytest.param(httpx2.HTTPError('unclassified failure'), id='bare HTTPError'), + pytest.param(httpx2.TimeoutException('timeout'), id='TimeoutException'), + pytest.param(httpx2.NetworkError('network error'), id='NetworkError'), + pytest.param(httpx2.RemoteProtocolError('remote protocol error'), id='RemoteProtocolError'), + pytest.param(httpx2.DecodingError('decoding error'), id='DecodingError'), # One `ProxyError` covers both a proxy rejecting the CONNECT tunnel and a 407, so a transient case cannot # be told from a permanent one - retrying is the safer default. - pytest.param(httpx.ProxyError('proxy error'), id='ProxyError'), + pytest.param(httpx2.ProxyError('proxy error'), id='ProxyError'), ], ) -def test_httpx_is_retryable_transport_error(exc: Exception) -> None: - """A transient HTTPX transport failure is classified as retryable.""" +def test_httpx2_is_retryable_transport_error(exc: Exception) -> None: + """A transient HTTPX2 transport failure is classified as retryable.""" with Httpx2HttpClient() as client: assert client.is_retryable_transport_error(exc) @@ -437,43 +437,43 @@ def test_httpx_is_retryable_transport_error(exc: Exception) -> None: @pytest.mark.parametrize( 'exc', [ - pytest.param(httpx.LocalProtocolError('invalid header value'), id='LocalProtocolError'), - pytest.param(httpx.UnsupportedProtocol('unsupported scheme'), id='UnsupportedProtocol'), - pytest.param(httpx.TooManyRedirects('too many redirects'), id='TooManyRedirects'), + pytest.param(httpx2.LocalProtocolError('invalid header value'), id='LocalProtocolError'), + pytest.param(httpx2.UnsupportedProtocol('unsupported scheme'), id='UnsupportedProtocol'), + pytest.param(httpx2.TooManyRedirects('too many redirects'), id='TooManyRedirects'), pytest.param( - httpx.HTTPStatusError( + httpx2.HTTPStatusError( 'status error', - request=httpx.Request('GET', 'https://example.com'), - response=httpx.Response(500), + request=httpx2.Request('GET', 'https://example.com'), + response=httpx2.Response(500), ), id='HTTPStatusError', ), - # HTTPX reports a bad URL outside the `httpx.HTTPError` tree entirely. - pytest.param(httpx.InvalidURL('unsupported scheme'), id='InvalidURL'), + # HTTPX2 reports a bad URL outside the `httpx2.HTTPError` tree entirely. + pytest.param(httpx2.InvalidURL('unsupported scheme'), id='InvalidURL'), pytest.param(ValueError('value error'), id='ValueError'), pytest.param(RuntimeError('runtime error'), id='RuntimeError'), pytest.param(Exception('generic exception'), id='Exception'), ], ) -def test_httpx_is_not_retryable_transport_error(exc: Exception) -> None: - """A transport failure a retry cannot fix, and anything outside HTTPX's hierarchy, is not retried.""" +def test_httpx2_is_not_retryable_transport_error(exc: Exception) -> None: + """A transport failure a retry cannot fix, and anything outside HTTPX2's hierarchy, is not retried.""" with Httpx2HttpClient() as client: assert not client.is_retryable_transport_error(exc) -def test_sync_httpx_client_classifies_timeout_errors() -> None: - """The built-in synchronous HTTPX client exposes transport-neutral timeout classification.""" +def test_sync_httpx2_client_classifies_timeout_errors() -> None: + """The built-in synchronous HTTPX2 client exposes transport-neutral timeout classification.""" with Httpx2HttpClient() as client: assert client.is_timeout_error(TimeoutError('test')) - assert client.is_timeout_error(httpx.TimeoutException('test')) + assert client.is_timeout_error(httpx2.TimeoutException('test')) assert not client.is_timeout_error(ValueError('test')) -async def test_async_httpx_client_classifies_timeout_errors() -> None: - """The built-in asynchronous HTTPX client exposes transport-neutral timeout classification.""" +async def test_async_httpx2_client_classifies_timeout_errors() -> None: + """The built-in asynchronous HTTPX2 client exposes transport-neutral timeout classification.""" async with Httpx2HttpClientAsync() as client: assert client.is_timeout_error(TimeoutError('test')) - assert client.is_timeout_error(httpx.TimeoutException('test')) + assert client.is_timeout_error(httpx2.TimeoutException('test')) assert not client.is_timeout_error(ValueError('test')) @@ -514,52 +514,52 @@ def test_transient_transport_error_is_retried() -> None: assert request.call_count == 3 -def test_httpx_permanent_transport_error_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None: - """The HTTPX adapter feeds the same fail-fast classification into the shared pipeline.""" +def test_httpx2_permanent_transport_error_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None: + """The HTTPX2 adapter feeds the same fail-fast classification into the shared pipeline.""" with Httpx2HttpClient(token='test_token', min_delay_between_retries=timedelta(0)) as client: - send = Mock(side_effect=httpx.UnsupportedProtocol('unsupported scheme')) - monkeypatch.setattr(client._httpx_client, 'send', send) + send = Mock(side_effect=httpx2.UnsupportedProtocol('unsupported scheme')) + monkeypatch.setattr(client._httpx2_client, 'send', send) - with pytest.raises(httpx.UnsupportedProtocol): + with pytest.raises(httpx2.UnsupportedProtocol): client.call(method='GET', url='https://api.test.com/endpoint') send.assert_called_once() -async def test_httpx_permanent_transport_error_is_not_retried_async(monkeypatch: pytest.MonkeyPatch) -> None: - """The asynchronous HTTPX adapter applies the same policy, failing on the first attempt.""" +async def test_httpx2_permanent_transport_error_is_not_retried_async(monkeypatch: pytest.MonkeyPatch) -> None: + """The asynchronous HTTPX2 adapter applies the same policy, failing on the first attempt.""" async with Httpx2HttpClientAsync(token='test_token', min_delay_between_retries=timedelta(0)) as client: - send = AsyncMock(side_effect=httpx.UnsupportedProtocol('unsupported scheme')) - monkeypatch.setattr(client._httpx_async_client, 'send', send) + send = AsyncMock(side_effect=httpx2.UnsupportedProtocol('unsupported scheme')) + monkeypatch.setattr(client._httpx2_async_client, 'send', send) - with pytest.raises(httpx.UnsupportedProtocol): + with pytest.raises(httpx2.UnsupportedProtocol): await client.call(method='GET', url='https://api.test.com/endpoint') send.assert_awaited_once() -def test_httpx_transient_transport_error_is_retried(monkeypatch: pytest.MonkeyPatch) -> None: - """The HTTPX adapter keeps a transient transport failure inside the shared retry loop.""" +def test_httpx2_transient_transport_error_is_retried(monkeypatch: pytest.MonkeyPatch) -> None: + """The HTTPX2 adapter keeps a transient transport failure inside the shared retry loop.""" with Httpx2HttpClient(token='test_token', max_retries=2, min_delay_between_retries=timedelta(0)) as client: - send = Mock(side_effect=httpx.TimeoutException('timeout')) - monkeypatch.setattr(client._httpx_client, 'send', send) + send = Mock(side_effect=httpx2.TimeoutException('timeout')) + monkeypatch.setattr(client._httpx2_client, 'send', send) - with pytest.raises(httpx.TimeoutException): + with pytest.raises(httpx2.TimeoutException): client.call(method='GET', url='https://api.test.com/endpoint') # `max_retries` attempts inside the backoff loop, plus the final one it makes after the last delay. assert send.call_count == 3 -async def test_httpx_transient_transport_error_is_retried_async(monkeypatch: pytest.MonkeyPatch) -> None: - """The asynchronous HTTPX adapter keeps a transient transport failure inside the shared retry loop too.""" +async def test_httpx2_transient_transport_error_is_retried_async(monkeypatch: pytest.MonkeyPatch) -> None: + """The asynchronous HTTPX2 adapter keeps a transient transport failure inside the shared retry loop too.""" async with Httpx2HttpClientAsync( token='test_token', max_retries=2, min_delay_between_retries=timedelta(0) ) as client: - send = AsyncMock(side_effect=httpx.TimeoutException('timeout')) - monkeypatch.setattr(client._httpx_async_client, 'send', send) + send = AsyncMock(side_effect=httpx2.TimeoutException('timeout')) + monkeypatch.setattr(client._httpx2_async_client, 'send', send) - with pytest.raises(httpx.TimeoutException): + with pytest.raises(httpx2.TimeoutException): await client.call(method='GET', url='https://api.test.com/endpoint') # `max_retries` attempts inside the backoff loop, plus the final one it makes after the last delay. diff --git a/tests/unit/test_pluggable_http_client.py b/tests/unit/test_pluggable_http_client.py index ab7448f9..563f65b8 100644 --- a/tests/unit/test_pluggable_http_client.py +++ b/tests/unit/test_pluggable_http_client.py @@ -367,8 +367,8 @@ def test_public_exports() -> None: assert not hasattr(http_clients_module, 'HttpClientBase') -def test_httpx_clients_raise_clear_error_when_extra_missing() -> None: - """Missing HTTPX keeps normal and star imports usable while explicit HTTPX access raises a clear error.""" +def test_httpx2_clients_raise_clear_error_when_extra_missing() -> None: + """Missing HTTPX2 keeps normal and star imports usable while explicit HTTPX2 access raises a clear error.""" script = dedent( """ import sys