apify-client 3.2.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {apify_client-3.2.0 → apify_client-3.2.1}/CHANGELOG.md +9 -0
  2. {apify_client-3.2.0 → apify_client-3.2.1}/PKG-INFO +3 -3
  3. {apify_client-3.2.0 → apify_client-3.2.1}/README.md +2 -2
  4. {apify_client-3.2.0 → apify_client-3.2.1}/pyproject.toml +1 -1
  5. {apify_client-3.2.0 → apify_client-3.2.1}/pyproject.toml.orig +1 -1
  6. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_pagination.py +54 -26
  7. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/dataset.py +11 -9
  8. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_status_message_watcher.py +30 -13
  9. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_streamed_log.py +57 -12
  10. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_clients/__init__.py +1 -1
  11. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_clients/_httpx2.py +47 -47
  12. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/types.py +2 -1
  13. {apify_client-3.2.0 → apify_client-3.2.1}/CONTRIBUTING.md +0 -0
  14. {apify_client-3.2.0 → apify_client-3.2.1}/LICENSE +0 -0
  15. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/__init__.py +0 -0
  16. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_apify_client.py +0 -0
  17. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_client_registry.py +0 -0
  18. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_consts.py +0 -0
  19. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_docs.py +0 -0
  20. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_literals.py +0 -0
  21. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_logging.py +0 -0
  22. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_models.py +0 -0
  23. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/__init__.py +0 -0
  24. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/_resource_client.py +0 -0
  25. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/actor.py +0 -0
  26. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/actor_collection.py +0 -0
  27. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/actor_env_var.py +0 -0
  28. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/actor_env_var_collection.py +0 -0
  29. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/actor_version.py +0 -0
  30. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/actor_version_collection.py +0 -0
  31. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/build.py +0 -0
  32. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/build_collection.py +0 -0
  33. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/dataset_collection.py +0 -0
  34. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/key_value_store.py +0 -0
  35. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/key_value_store_collection.py +0 -0
  36. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/log.py +0 -0
  37. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/request_queue.py +0 -0
  38. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/request_queue_collection.py +0 -0
  39. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/run.py +0 -0
  40. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/run_collection.py +0 -0
  41. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/schedule.py +0 -0
  42. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/schedule_collection.py +0 -0
  43. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/store_collection.py +0 -0
  44. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/task.py +0 -0
  45. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/task_collection.py +0 -0
  46. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/user.py +0 -0
  47. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/webhook.py +0 -0
  48. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/webhook_collection.py +0 -0
  49. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/webhook_dispatch.py +0 -0
  50. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_resource_clients/webhook_dispatch_collection.py +0 -0
  51. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_statistics.py +0 -0
  52. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_typeddicts.py +0 -0
  53. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_utils/__init__.py +0 -0
  54. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_utils/crypto.py +0 -0
  55. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_utils/encoding.py +0 -0
  56. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_utils/errors.py +0 -0
  57. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_utils/http.py +0 -0
  58. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_utils/time.py +0 -0
  59. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/_utils/try_import.py +0 -0
  60. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/errors.py +0 -0
  61. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_clients/_base.py +0 -0
  62. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_clients/_impit.py +0 -0
  63. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_compressors/__init__.py +0 -0
  64. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_compressors/_base.py +0 -0
  65. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_compressors/_brotli.py +0 -0
  66. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_compressors/_gzip.py +0 -0
  67. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/http_compressors/_resolve.py +0 -0
  68. {apify_client-3.2.0 → apify_client-3.2.1}/src/apify_client/py.typed +0 -0
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [3.2.1](https://github.com/apify/apify-client-python/releases/tag/v3.2.1) (2026-09-25)
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - Stop dataset iterators from skipping items when unwind is used ([#1059](https://github.com/apify/apify-client-python/pull/1059)) ([8e30197](https://github.com/apify/apify-client-python/commit/8e301977ecb80b7c01251cacf071a3f6201469ba)) by [@vdusek](https://github.com/vdusek), closes [#1058](https://github.com/apify/apify-client-python/issues/1058)
10
+ - Keep a failing background watcher from breaking a successful actor call ([#1027](https://github.com/apify/apify-client-python/pull/1027)) ([efc009d](https://github.com/apify/apify-client-python/commit/efc009d78f08d3a03a10db386201e48e45d01c2d)) by [@vdusek](https://github.com/vdusek)
11
+ - Make `JsonSerializable` type alias read-only ([#1072](https://github.com/apify/apify-client-python/pull/1072)) ([bd82a26](https://github.com/apify/apify-client-python/commit/bd82a2645d3c64f0e4a30de3a016c16d92d48340)) by [@Pijukatel](https://github.com/Pijukatel)
12
+
13
+
5
14
  ## [3.2.0](https://github.com/apify/apify-client-python/releases/tag/v3.2.0) (2026-09-03)
6
15
 
7
16
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apify_client
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Apify API client for Python
5
5
  Keywords: apify,api,client,automation,crawling,scraping
6
6
  Author: Apify Technologies s.r.o.
@@ -95,7 +95,7 @@ Description-Content-Type: text/markdown
95
95
  ```
96
96
 
97
97
  [Impit](https://github.com/apify/impit) is the default HTTP client and is installed automatically. To use the
98
- built-in [HTTPX](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which
98
+ built-in [HTTPX2](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which
99
99
  provides Pydantic's maintained continuation of HTTPX, and pass `http_client=Httpx2HttpClient()` to
100
100
  `ApifyClient.with_custom_http_client()`:
101
101
 
@@ -172,7 +172,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r
172
172
  - **Tiered timeouts** — short / medium / long tiers picked per endpoint, overridable per call ([Timeouts](https://docs.apify.com/api/client/python/docs/concepts/timeouts)).
173
173
  - **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)).
174
174
  - **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)).
175
- - **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)).
175
+ - **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)).
176
176
  - **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)).
177
177
  - **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)).
178
178
 
@@ -58,7 +58,7 @@
58
58
  ```
59
59
 
60
60
  [Impit](https://github.com/apify/impit) is the default HTTP client and is installed automatically. To use the
61
- built-in [HTTPX](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which
61
+ built-in [HTTPX2](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which
62
62
  provides Pydantic's maintained continuation of HTTPX, and pass `http_client=Httpx2HttpClient()` to
63
63
  `ApifyClient.with_custom_http_client()`:
64
64
 
@@ -135,7 +135,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r
135
135
  - **Tiered timeouts** — short / medium / long tiers picked per endpoint, overridable per call ([Timeouts](https://docs.apify.com/api/client/python/docs/concepts/timeouts)).
136
136
  - **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)).
137
137
  - **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)).
138
- - **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)).
138
+ - **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)).
139
139
  - **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)).
140
140
  - **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)).
141
141
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify_client"
7
- version = "3.2.0"
7
+ version = "3.2.1"
8
8
  description = "Apify API client for Python"
9
9
  license = "Apache-2.0"
10
10
  license-files = ["LICENSE"]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify_client"
7
- version = "3.2.0"
7
+ version = "3.2.1"
8
8
  description = "Apify API client for Python"
9
9
  authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
10
10
  license = "Apache-2.0"
@@ -19,9 +19,10 @@ The value of 1000 keeps backwards compatibility with the previous fixed cache si
19
19
  class HasItems(Protocol[T]):
20
20
  """Structural contract for a single page of results from a paginated API endpoint.
21
21
 
22
- Implementations must expose `items`. They may optionally expose `count` - the number of items scanned by the API for
23
- this page, which can exceed `len(items)` when filters drop items from the response. The iterator helpers consult
24
- `count` opportunistically via `getattr` for offset bookkeeping and fall back to `len(items)` when it is absent.
22
+ Implementations must expose `items`. They may optionally expose `count` - the number of rows the API scanned to
23
+ produce this page, which `len(items)` can land below (filters drop items) or above (`unwind` splits one row into
24
+ several items). The iterator helpers consult `count` opportunistically via `getattr` for offset bookkeeping and
25
+ fall back to `len(items)` when it is absent.
25
26
  """
26
27
 
27
28
  items: list[T]
@@ -38,20 +39,19 @@ def get_items_iterator(
38
39
 
39
40
  The `callback` is invoked lazily to fetch each page from the API. It must accept `limit` and `offset` keyword
40
41
  arguments and return an object whose `items` attribute is a list. If the object also exposes a `count` attribute, it
41
- is used for offset bookkeeping (the Apify API's `count` reflects items scanned, which can exceed items returned when
42
- filters are applied).
42
+ is used for offset bookkeeping - `_page_scanned_rows` describes how the next offset is derived.
43
43
 
44
- Iteration stops when a page scans no items (`count` is `0`, or `items` is empty when `count` is absent) or when the
45
- user-requested `limit` is reached. A page can scan items while returning none - filters like `clean` drop items from
46
- `items` but still count toward `count` - so terminating on scanned rather than returned items keeps the iterator
47
- advancing across fully-filtered pages. The `total` field is intentionally not consulted, because it can change
48
- between calls.
44
+ Iteration stops when a page scans no rows or when the user-requested `limit` is reached. A page can scan rows while
45
+ returning no items - filters like `clean` drop items from `items` but still count toward `count` - so terminating on
46
+ scanned rather than returned rows keeps the iterator advancing across fully-filtered pages. The `total` field is
47
+ intentionally not consulted, because it can change between calls.
49
48
 
50
49
  Args:
51
50
  callback: Function returning a single page of items.
52
- limit: Maximum total number of items to yield across all pages. `None` or `0` means no limit.
51
+ limit: Maximum total number of rows scanned across all pages. On the dataset items endpoint `unwind` can
52
+ turn one row into several items, so more items than this can be yielded. `None` or `0` means no limit.
53
53
  offset: Starting offset for the first page.
54
- chunk_size: Maximum number of items requested per API call. `None` or `0` lets the API decide.
54
+ chunk_size: Per-page cap, sent to the API as its `limit`. `None` or `0` lets the API decide.
55
55
  """
56
56
  effective_chunk = chunk_size or 0
57
57
  initial_offset = offset or 0
@@ -59,13 +59,14 @@ def get_items_iterator(
59
59
  fetched_items = 0
60
60
 
61
61
  while True:
62
+ page_limit = _next_page_limit(initial_limit, fetched_items, effective_chunk)
62
63
  current_page = callback(
63
- limit=_next_page_limit(initial_limit, fetched_items, effective_chunk),
64
+ limit=page_limit,
64
65
  offset=initial_offset + fetched_items,
65
66
  )
66
67
  yield from current_page.items
67
68
 
68
- page_scanned = max(getattr(current_page, 'count', 0), len(current_page.items))
69
+ page_scanned = _page_scanned_rows(current_page, page_limit)
69
70
  fetched_items += page_scanned
70
71
 
71
72
  if not page_scanned or (initial_limit and fetched_items >= initial_limit):
@@ -89,14 +90,15 @@ async def get_items_iterator_async(
89
90
  fetched_items = 0
90
91
 
91
92
  while True:
93
+ page_limit = _next_page_limit(initial_limit, fetched_items, effective_chunk)
92
94
  current_page = await callback(
93
- limit=_next_page_limit(initial_limit, fetched_items, effective_chunk),
95
+ limit=page_limit,
94
96
  offset=initial_offset + fetched_items,
95
97
  )
96
98
  for item in current_page.items:
97
99
  yield item
98
100
 
99
- page_scanned = max(getattr(current_page, 'count', 0), len(current_page.items))
101
+ page_scanned = _page_scanned_rows(current_page, page_limit)
100
102
  fetched_items += page_scanned
101
103
 
102
104
  if not page_scanned or (initial_limit and fetched_items >= initial_limit):
@@ -126,20 +128,30 @@ def get_cursor_iterator(
126
128
  limit: int | None = None,
127
129
  chunk_size: int | None = None,
128
130
  ) -> Iterator[KeyValueStoreKey] | Iterator[Request]:
129
- """Yield individual items from cursor-paginated API responses.
131
+ """Yield individual items from a cursor-paginated API response.
132
+
133
+ This iterator supports the two API responses that use cursor pagination. `ListOfKeys` is used for key-value store
134
+ keys, while `ListOfRequests` is used for request queue requests.
135
+
136
+ Pagination continues until either:
137
+
138
+ - the API returns no next cursor, or
139
+ - the requested `limit` is reached.
140
+
141
+ An empty page does not explicitly stop the iteration. In practice, both supported endpoints return a next cursor
142
+ only when the current page contains items, so an empty page always has a `None` cursor and naturally ends the
143
+ iteration.
130
144
 
131
- Cursor pagination is restricted to the two API responses that expose it: `ListOfKeys` (for key-value store keys) and
132
- `ListOfRequests` (for request queue requests). Iteration ends when the next cursor is `None` or the user-requested
133
- `limit` is reached. Emptiness alone does not stop iteration: server-side filters (such as the request-queue state
134
- `filter`) can drop every item on a page while a live cursor still points at more data, so termination relies on the
135
- cursor, not on whether a page returned items. Unlike offset responses, cursor responses expose no scanned-item
136
- `count`, so `count` cannot be used to detect a fully-filtered page here.
145
+ The endpoints determine the next cursor differently:
146
+
147
+ - For key-value store keys, the cursor is the last key returned on the current page.
148
+ - For request queue requests, a cursor is returned only when the current page is full.
137
149
 
138
150
  Args:
139
- callback: Function returning a single page of items. Receives `cursor` and `limit` kwargs.
140
- cursor: Value of the cursor for the first request, or `None` to start from the beginning.
151
+ callback: Function that returns one page of items and accepts `cursor` and `limit` keyword arguments.
152
+ cursor: Cursor to use for the first request. If `None`, iteration starts from the beginning.
141
153
  limit: Maximum total number of items to yield across all pages.
142
- chunk_size: Maximum number of items requested per API call.
154
+ chunk_size: Maximum number of items to request in a single API call.
143
155
  """
144
156
  effective_chunk = chunk_size or 0
145
157
  initial_limit = limit or 0
@@ -218,3 +230,19 @@ def _next_page_limit(initial_limit: int, fetched_items: int, effective_chunk: in
218
230
  if not effective_chunk:
219
231
  return remaining
220
232
  return min(remaining, effective_chunk)
233
+
234
+
235
+ def _page_scanned_rows(page: HasItems[T], requested_limit: int) -> int:
236
+ """Compute how far the offset advances past `page`, in dataset rows.
237
+
238
+ Neither reported number is right on its own. `count` follows the rows the API scanned, but it is derived from a
239
+ dataset's item count, which is incremented by a throttled write and so lags a fresh push. `len(items)` counts the
240
+ items the API shaped out of those rows: filters (`clean`, `skip_empty`, `skip_hidden`) drop some, and `unwind`
241
+ splits one row into several. The larger of the two absorbs a `count` that lags behind the items returned, and
242
+ capping it at the rows the call asked for keeps an unwound page from advancing past rows the next call would then
243
+ never read. The cap is a valid bound because the endpoint applies the `limit` it is sent verbatim; on a page
244
+ covering fewer rows than that, the advance can still overshoot into rows a concurrent push appends afterwards. A
245
+ `requested_limit` of `0` means the call sent no limit, leaving the advance unbounded.
246
+ """
247
+ scanned_rows = max(getattr(page, 'count', 0), len(page.items))
248
+ return min(scanned_rows, requested_limit) if requested_limit else scanned_rows
@@ -40,7 +40,7 @@ class DatasetItemsPage:
40
40
  """The offset of the first item in this page."""
41
41
 
42
42
  count: int
43
- """Number of items in this page."""
43
+ """Number of dataset rows the API scanned for this page, or the number of items returned when that is larger."""
44
44
 
45
45
  limit: int
46
46
  """The limit that was used for this request."""
@@ -204,8 +204,8 @@ class DatasetClient(ResourceClient):
204
204
  items=items,
205
205
  total=int(response.headers['x-apify-pagination-total']),
206
206
  offset=int(response.headers['x-apify-pagination-offset']),
207
- # x-apify-pagination-count returns count of processed items, not count of returned items
208
- # This makes difference when items were filtered using hidden/empty
207
+ # The header counts the rows the API scanned, which `unwind` and a lagging dataset item count can
208
+ # both leave below the number of items returned.
209
209
  count=max(int(response.headers['x-apify-pagination-count']), len(items)),
210
210
  # API returns 999999999999 when no limit is used
211
211
  limit=int(response.headers['x-apify-pagination-limit']),
@@ -237,7 +237,8 @@ class DatasetClient(ResourceClient):
237
237
 
238
238
  Args:
239
239
  offset: Number of items that should be skipped at the start. The default value is 0.
240
- limit: Maximum number of items to return. By default there is no limit.
240
+ limit: Maximum number of dataset rows to scan. Fewer items are yielded when filters drop some, more
241
+ when `unwind` splits a row into several. By default there is no limit.
241
242
  desc: By default, results are returned in the same order as they were stored. To reverse the order,
242
243
  set this parameter to True.
243
244
  clean: If True, returns only non-empty items and skips hidden fields (i.e. fields starting with
@@ -260,7 +261,7 @@ class DatasetClient(ResourceClient):
260
261
  skip_hidden: If True, then hidden fields are skipped from the output, i.e. fields starting with
261
262
  the # character.
262
263
  signature: Signature used to access the items.
263
- chunk_size: Maximum number of items requested per API call when iterating across pages.
264
+ chunk_size: Maximum number of dataset rows requested per API call when iterating across pages.
264
265
  timeout: Timeout for the API HTTP request.
265
266
 
266
267
  Yields:
@@ -763,8 +764,8 @@ class DatasetClientAsync(ResourceClientAsync):
763
764
  items=items,
764
765
  total=int(response.headers['x-apify-pagination-total']),
765
766
  offset=int(response.headers['x-apify-pagination-offset']),
766
- # x-apify-pagination-count returns count of processed items, not count of returned items
767
- # This makes difference when items were filtered using hidden/empty
767
+ # The header counts the rows the API scanned, which `unwind` and a lagging dataset item count can
768
+ # both leave below the number of items returned.
768
769
  count=max(int(response.headers['x-apify-pagination-count']), len(items)),
769
770
  # API returns 999999999999 when no limit is used
770
771
  limit=int(response.headers['x-apify-pagination-limit']),
@@ -796,7 +797,8 @@ class DatasetClientAsync(ResourceClientAsync):
796
797
 
797
798
  Args:
798
799
  offset: Number of items that should be skipped at the start. The default value is 0.
799
- limit: Maximum number of items to return. By default there is no limit.
800
+ limit: Maximum number of dataset rows to scan. Fewer items are yielded when filters drop some, more
801
+ when `unwind` splits a row into several. By default there is no limit.
800
802
  desc: By default, results are returned in the same order as they were stored. To reverse the order,
801
803
  set this parameter to True.
802
804
  clean: If True, returns only non-empty items and skips hidden fields (i.e. fields starting with
@@ -819,7 +821,7 @@ class DatasetClientAsync(ResourceClientAsync):
819
821
  skip_hidden: If True, then hidden fields are skipped from the output, i.e. fields starting with
820
822
  the # character.
821
823
  signature: Signature used to access the items.
822
- chunk_size: Maximum number of items requested per API call when iterating across pages.
824
+ chunk_size: Maximum number of dataset rows requested per API call when iterating across pages.
823
825
  timeout: Timeout for the API HTTP request.
824
826
 
825
827
  Yields:
@@ -120,11 +120,19 @@ class StatusMessageWatcherAsync(StatusMessageWatcherBase):
120
120
  await self.stop()
121
121
 
122
122
  async def _log_changed_status_message(self) -> None:
123
- while True:
124
- run_data = await self._run_client.get()
125
- if not self._log_run_data(run_data):
126
- break
127
- await asyncio.sleep(self._check_period)
123
+ try:
124
+ while True:
125
+ run_data = await self._run_client.get()
126
+ if not self._log_run_data(run_data):
127
+ break
128
+ await asyncio.sleep(self._check_period)
129
+ except Exception as exc:
130
+ if self._run_client._http_client.is_timeout_error(exc): # noqa: SLF001
131
+ # An expected timeout, so warn rather than leak a traceback.
132
+ self._to_logger.warning('Status message redirection stopped: the status request timed out.')
133
+ else:
134
+ # A failed poll must not escape into `stop` and surface as a failure of the run.
135
+ self._to_logger.exception('Status message redirection stopped due to unexpected error:')
128
136
 
129
137
 
130
138
  @docs_group('Other')
@@ -159,10 +167,11 @@ class StatusMessageWatcher(StatusMessageWatcherBase):
159
167
 
160
168
  The caller is responsible for cleanup by calling the `stop` method when done.
161
169
  """
162
- if self._logging_thread:
170
+ if self._logging_thread and self._logging_thread.is_alive():
163
171
  raise RuntimeError('Logging thread already active')
164
172
  self._stop_logging = False
165
- self._logging_thread = threading.Thread(target=self._log_changed_status_message)
173
+ # A daemon thread, so a watcher still polling cannot hold up interpreter shutdown.
174
+ self._logging_thread = threading.Thread(target=self._log_changed_status_message, daemon=True)
166
175
  self._logging_thread.start()
167
176
  return self._logging_thread
168
177
 
@@ -189,9 +198,17 @@ class StatusMessageWatcher(StatusMessageWatcherBase):
189
198
  self.stop()
190
199
 
191
200
  def _log_changed_status_message(self) -> None:
192
- while True:
193
- if not self._log_run_data(self._run_client.get()):
194
- break
195
- if self._stop_logging:
196
- break
197
- time.sleep(self._check_period)
201
+ try:
202
+ while True:
203
+ if not self._log_run_data(self._run_client.get()):
204
+ break
205
+ if self._stop_logging:
206
+ break
207
+ time.sleep(self._check_period)
208
+ except Exception as exc:
209
+ if self._run_client._http_client.is_timeout_error(exc): # noqa: SLF001
210
+ # An expected timeout, so warn rather than leak a traceback.
211
+ self._to_logger.warning('Status message redirection stopped: the status request timed out.')
212
+ else:
213
+ # A failed poll must not escape the background thread.
214
+ self._to_logger.exception('Status message redirection stopped due to unexpected error:')
@@ -7,7 +7,7 @@ import threading
7
7
  from asyncio import Task
8
8
  from datetime import UTC, datetime
9
9
  from threading import Thread
10
- from typing import TYPE_CHECKING, ClassVar, Self, cast
10
+ from typing import TYPE_CHECKING, ClassVar, Self
11
11
 
12
12
  from apify_client._docs import docs_group
13
13
 
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
15
15
  from types import TracebackType
16
16
 
17
17
  from apify_client._resource_clients import LogClient, LogClientAsync
18
+ from apify_client.http_clients import HttpResponse
18
19
  from apify_client.types import Timeout
19
20
 
20
21
 
@@ -79,10 +80,10 @@ class StreamedLogBase:
79
80
  """Guess the log level from the message."""
80
81
  # Using only levels explicitly mentioned in the logging module
81
82
  known_levels = ('CRITICAL', 'FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'NOTSET')
83
+ level_names_to_levels = logging.getLevelNamesMapping()
82
84
  for level in known_levels:
83
85
  if level in message:
84
- # `getLevelName` returns an `int` when string is passed as input.
85
- return cast('int', logging.getLevelName(level))
86
+ return level_names_to_levels[level]
86
87
  # Unknown log level. Fall back to the default.
87
88
  return logging.INFO
88
89
 
@@ -98,6 +99,13 @@ class StreamedLog(StreamedLogBase):
98
99
  call `start` and `stop` manually. Obtain an instance via `RunClient.get_streamed_log`.
99
100
  """
100
101
 
102
+ _stop_timeout_s: ClassVar[float] = 5
103
+ """Upper bound on how long `stop` waits for the streaming thread to finish.
104
+
105
+ Closing the response only ends the read on a transport that honours it, which neither Impit nor HTTPX does, so
106
+ without a bound `stop` would wait for the next chunk, which on a quiet run may be hours away.
107
+ """
108
+
101
109
  def __init__(self, log_client: LogClient, *, to_logger: logging.Logger, from_start: bool = True) -> None:
102
110
  """Initialize `StreamedLog`.
103
111
 
@@ -111,6 +119,7 @@ class StreamedLog(StreamedLogBase):
111
119
  super().__init__(to_logger=to_logger, from_start=from_start)
112
120
  self._log_client = log_client
113
121
  self._streaming_thread: Thread | None = None
122
+ self._log_stream: HttpResponse | None = None
114
123
  self._stop_logging = False
115
124
 
116
125
  def start(self) -> Thread:
@@ -118,7 +127,7 @@ class StreamedLog(StreamedLogBase):
118
127
 
119
128
  The caller is responsible for cleanup by calling the `stop` method when done.
120
129
  """
121
- if self._streaming_thread:
130
+ if self._streaming_thread and self._streaming_thread.is_alive():
122
131
  raise RuntimeError('Streaming thread already active')
123
132
  self._stop_logging = False
124
133
  # A daemon thread so a stream still blocked on a read can never hold up interpreter shutdown.
@@ -127,13 +136,29 @@ class StreamedLog(StreamedLogBase):
127
136
  return self._streaming_thread
128
137
 
129
138
  def stop(self) -> None:
130
- """Signal the streaming thread to stop logging and wait for it to finish."""
139
+ """Signal the streaming thread to stop logging and wait up to `_stop_timeout_s` for it to finish.
140
+
141
+ A thread that outlives the wait is a daemon with `_stop_logging` set, so it exits after at most one more chunk,
142
+ and only then does its buffered tail reach the logger. Its handle is kept while it is alive, so `start` cannot
143
+ revive it beside a second thread on the same buffer.
144
+ """
131
145
  if not self._streaming_thread:
132
146
  raise RuntimeError('Streaming thread is not active')
133
147
  self._stop_logging = True
134
- self._streaming_thread.join()
135
- self._streaming_thread = None
136
- self._stop_logging = False
148
+ # Read once; the streaming thread clears the attribute as soon as the stream ends.
149
+ log_stream = self._log_stream
150
+ if log_stream is not None:
151
+ try:
152
+ log_stream.close()
153
+ except Exception:
154
+ # A failing `close` in a custom transport must not fail the caller.
155
+ self._to_logger.exception('Closing the log stream failed:')
156
+ self._streaming_thread.join(timeout=self._stop_timeout_s)
157
+ if self._streaming_thread.is_alive():
158
+ # Otherwise log messages arriving after `stop` returned have no explanation.
159
+ self._to_logger.debug('Log streaming thread outlived the stop timeout; it ends after the next chunk.')
160
+ else:
161
+ self._streaming_thread = None
137
162
 
138
163
  def __enter__(self) -> Self:
139
164
  """Start the streaming thread within the context. Exiting the context will finish the streaming thread."""
@@ -151,15 +176,30 @@ class StreamedLog(StreamedLogBase):
151
176
  with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
152
177
  if not log_stream:
153
178
  return
179
+ # Published so `stop` can close the response.
180
+ self._log_stream = log_stream
154
181
  try:
182
+ # `stop` may have run before the response existed for it to close.
183
+ if self._stop_logging:
184
+ return
155
185
  for data in log_stream.iter_bytes():
156
186
  self._process_new_data(data)
157
187
  if self._stop_logging:
158
188
  break
159
189
  finally:
160
- # Flush the last buffered part even if the read timed out or was stopped.
161
- self._log_buffer_content(include_last_part=True)
190
+ self._log_stream = None
191
+ try:
192
+ # Flush the last buffered part even if the read timed out or was stopped.
193
+ self._log_buffer_content(include_last_part=True)
194
+ except Exception:
195
+ # A truncated stream leaves an undecodable tail, which is worth a traceback even while a stop
196
+ # is in progress.
197
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
162
198
  except Exception as exc:
199
+ if self._stop_logging:
200
+ # `stop` closed the stream out from under the read, so the failure is expected.
201
+ self._to_logger.debug('Log streaming stopped while `stop` was in progress: %r', exc)
202
+ return
163
203
  if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
164
204
  # The stream cannot continue, so warn and let the thread end instead of leaking a traceback.
165
205
  self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
@@ -236,8 +276,13 @@ class StreamedLogAsync(StreamedLogBase):
236
276
  async for data in log_stream.aiter_bytes():
237
277
  self._process_new_data(data)
238
278
  finally:
239
- # Flush the last buffered part even if the task is cancelled by `stop()`.
240
- self._log_buffer_content(include_last_part=True)
279
+ try:
280
+ # Flush the last buffered part even if the task is cancelled by `stop()`.
281
+ self._log_buffer_content(include_last_part=True)
282
+ except Exception:
283
+ # A truncated stream leaves an undecodable tail. Keeping the failure here also keeps the
284
+ # cancellation `stop` raised propagating, so the task ends up cancelled as asyncio expects.
285
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
241
286
  except Exception as exc:
242
287
  if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
243
288
  # A timeout on the long-lived stream is an expected terminal condition, not an error.
@@ -5,7 +5,7 @@ from apify_client.http_clients._impit import ImpitHttpClient, ImpitHttpClientAsy
5
5
 
6
6
  _install_import_hook(__name__)
7
7
 
8
- # `httpx2` is an optional extra, so the import is wrapped in try_import. Accessing the HTTPX clients without the
8
+ # `httpx2` is an optional extra, so the import is wrapped in try_import. Accessing the HTTPX2 clients without the
9
9
  # extra installed raises a clear ImportError instead of failing at package import time.
10
10
  with _try_import(
11
11
  __name__,
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- import httpx2 as httpx
5
+ import httpx2
6
6
  from typing_extensions import override
7
7
 
8
8
  from apify_client._consts import (
@@ -24,32 +24,32 @@ if TYPE_CHECKING:
24
24
 
25
25
 
26
26
  _PERMANENT_ERRORS = (
27
- # A request HTTPX rejects before sending it, e.g. one carrying an invalid header value.
28
- httpx.LocalProtocolError,
29
- # A URL scheme HTTPX refuses to speak, which repeating the request cannot change.
30
- httpx.UnsupportedProtocol,
27
+ # A request HTTPX2 rejects before sending it, e.g. one carrying an invalid header value.
28
+ httpx2.LocalProtocolError,
29
+ # A URL scheme HTTPX2 refuses to speak, which repeating the request cannot change.
30
+ httpx2.UnsupportedProtocol,
31
31
  # An over-long redirect chain is a routing loop, which repeating the request cannot break.
32
- httpx.TooManyRedirects,
32
+ httpx2.TooManyRedirects,
33
33
  # Only `Response.raise_for_status()` raises this, and the client never calls it - the shared pipeline decides on
34
34
  # status codes from the response itself.
35
- httpx.HTTPStatusError,
35
+ httpx2.HTTPStatusError,
36
36
  )
37
- """HTTPX errors that a retry cannot fix. Everything else in the `httpx.HTTPError` tree counts as transient."""
37
+ """HTTPX2 errors that a retry cannot fix. Everything else in the `httpx2.HTTPError` tree counts as transient."""
38
38
 
39
39
 
40
40
  @docs_group('HTTP clients')
41
41
  class Httpx2HttpClient(HttpClient):
42
- """Synchronous HTTP client for the Apify API built on top of [HTTPX](https://github.com/pydantic/httpx2).
42
+ """Synchronous HTTP client for the Apify API built on top of [HTTPX2](https://github.com/pydantic/httpx2).
43
43
 
44
- This client wraps `httpx.Client` and adds automatic retries with exponential backoff for rate-limited
44
+ This client wraps `httpx2.Client` and adds automatic retries with exponential backoff for rate-limited
45
45
  (HTTP 429) and server error (HTTP 5xx) responses.
46
46
 
47
- HTTPX applies a request timeout to each socket operation rather than to the request as a whole, so a response
47
+ HTTPX2 applies a request timeout to each socket operation rather than to the request as a whole, so a response
48
48
  whose body arrives slowly keeps resetting it and can outlast both the requested timeout and `timeout_max`. The
49
49
  default Impit client enforces the same value as a deadline for the whole request, body included.
50
50
 
51
51
  Requires the `httpx2` extra: `pip install "apify-client[httpx2]"`. The `httpx2` package is Pydantic's maintained
52
- continuation of HTTPX, which this module imports under the `httpx` name.
52
+ continuation of HTTPX.
53
53
  """
54
54
 
55
55
  def __init__(
@@ -66,7 +66,7 @@ class Httpx2HttpClient(HttpClient):
66
66
  headers: dict[str, str] | None = None,
67
67
  http_compressor: HttpCompressor | None = None,
68
68
  ) -> None:
69
- """Initialize the HTTPX-based synchronous HTTP client.
69
+ """Initialize the HTTPX2-based synchronous HTTP client.
70
70
 
71
71
  Args:
72
72
  token: Apify API token for authentication.
@@ -93,27 +93,27 @@ class Httpx2HttpClient(HttpClient):
93
93
  http_compressor=http_compressor,
94
94
  )
95
95
 
96
- self._httpx_client = httpx.Client(
96
+ self._httpx2_client = httpx2.Client(
97
97
  follow_redirects=True,
98
98
  event_hooks={'response': [self._clear_response_cookies]},
99
99
  )
100
100
 
101
101
  @override
102
102
  def is_timeout_error(self, exc: Exception) -> bool:
103
- return super().is_timeout_error(exc) or isinstance(exc, httpx.TimeoutException)
103
+ return super().is_timeout_error(exc) or isinstance(exc, httpx2.TimeoutException)
104
104
 
105
105
  @override
106
106
  def is_retryable_transport_error(self, exc: Exception) -> bool:
107
- # Every error from HTTPX's own hierarchy counts as transient except the permanently-failing types listed in
108
- # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX adds later is retried rather than
107
+ # Every error from HTTPX2's own hierarchy counts as transient except the permanently-failing types listed in
108
+ # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX2 adds later is retried rather than
109
109
  # silently treated as fatal. HTTP status code errors are handled by the shared pipeline based on the
110
110
  # response status code, not here.
111
- return isinstance(exc, httpx.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS)
111
+ return isinstance(exc, httpx2.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS)
112
112
 
113
113
  @override
114
114
  def close(self) -> None:
115
- """Close the underlying HTTPX connection pool."""
116
- self._httpx_client.close()
115
+ """Close the underlying HTTPX2 connection pool."""
116
+ self._httpx2_client.close()
117
117
 
118
118
  @override
119
119
  def send_request(
@@ -125,8 +125,8 @@ class Httpx2HttpClient(HttpClient):
125
125
  content: bytes | None,
126
126
  timeout: float | None,
127
127
  stream: bool,
128
- ) -> httpx.Response:
129
- request = self._httpx_client.build_request(
128
+ ) -> httpx2.Response:
129
+ request = self._httpx2_client.build_request(
130
130
  method=method,
131
131
  url=url,
132
132
  headers=headers,
@@ -134,26 +134,26 @@ class Httpx2HttpClient(HttpClient):
134
134
  timeout=timeout,
135
135
  )
136
136
  _restore_explicit_cookie_header(request, headers)
137
- return self._httpx_client.send(request, stream=stream)
137
+ return self._httpx2_client.send(request, stream=stream)
138
138
 
139
- def _clear_response_cookies(self, _response: httpx.Response) -> None:
140
- """Prevent HTTPX's shared cookie jar from leaking server cookies into later API requests."""
141
- self._httpx_client.cookies.clear()
139
+ def _clear_response_cookies(self, _response: httpx2.Response) -> None:
140
+ """Prevent HTTPX2's shared cookie jar from leaking server cookies into later API requests."""
141
+ self._httpx2_client.cookies.clear()
142
142
 
143
143
 
144
144
  @docs_group('HTTP clients')
145
145
  class Httpx2HttpClientAsync(HttpClientAsync):
146
- """Asynchronous HTTP client for the Apify API built on top of [HTTPX](https://github.com/pydantic/httpx2).
146
+ """Asynchronous HTTP client for the Apify API built on top of [HTTPX2](https://github.com/pydantic/httpx2).
147
147
 
148
- This client wraps `httpx.AsyncClient` and adds automatic retries with exponential backoff for rate-limited
148
+ This client wraps `httpx2.AsyncClient` and adds automatic retries with exponential backoff for rate-limited
149
149
  (HTTP 429) and server error (HTTP 5xx) responses.
150
150
 
151
- HTTPX applies a request timeout to each socket operation rather than to the request as a whole, so a response
151
+ HTTPX2 applies a request timeout to each socket operation rather than to the request as a whole, so a response
152
152
  whose body arrives slowly keeps resetting it and can outlast both the requested timeout and `timeout_max`. The
153
153
  default Impit client enforces the same value as a deadline for the whole request, body included.
154
154
 
155
155
  Requires the `httpx2` extra: `pip install "apify-client[httpx2]"`. The `httpx2` package is Pydantic's maintained
156
- continuation of HTTPX, which this module imports under the `httpx` name.
156
+ continuation of HTTPX.
157
157
  """
158
158
 
159
159
  def __init__(
@@ -170,7 +170,7 @@ class Httpx2HttpClientAsync(HttpClientAsync):
170
170
  headers: dict[str, str] | None = None,
171
171
  http_compressor: HttpCompressor | None = None,
172
172
  ) -> None:
173
- """Initialize the HTTPX-based asynchronous HTTP client.
173
+ """Initialize the HTTPX2-based asynchronous HTTP client.
174
174
 
175
175
  Args:
176
176
  token: Apify API token for authentication.
@@ -197,27 +197,27 @@ class Httpx2HttpClientAsync(HttpClientAsync):
197
197
  http_compressor=http_compressor,
198
198
  )
199
199
 
200
- self._httpx_async_client = httpx.AsyncClient(
200
+ self._httpx2_async_client = httpx2.AsyncClient(
201
201
  follow_redirects=True,
202
202
  event_hooks={'response': [self._clear_response_cookies]},
203
203
  )
204
204
 
205
205
  @override
206
206
  def is_timeout_error(self, exc: Exception) -> bool:
207
- return super().is_timeout_error(exc) or isinstance(exc, httpx.TimeoutException)
207
+ return super().is_timeout_error(exc) or isinstance(exc, httpx2.TimeoutException)
208
208
 
209
209
  @override
210
210
  def is_retryable_transport_error(self, exc: Exception) -> bool:
211
- # Every error from HTTPX's own hierarchy counts as transient except the permanently-failing types listed in
212
- # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX adds later is retried rather than
211
+ # Every error from HTTPX2's own hierarchy counts as transient except the permanently-failing types listed in
212
+ # `_PERMANENT_ERRORS`. Retrying is the default so a subclass HTTPX2 adds later is retried rather than
213
213
  # silently treated as fatal. HTTP status code errors are handled by the shared pipeline based on the
214
214
  # response status code, not here.
215
- return isinstance(exc, httpx.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS)
215
+ return isinstance(exc, httpx2.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS)
216
216
 
217
217
  @override
218
218
  async def aclose(self) -> None:
219
- """Close the underlying asynchronous HTTPX connection pool."""
220
- await self._httpx_async_client.aclose()
219
+ """Close the underlying asynchronous HTTPX2 connection pool."""
220
+ await self._httpx2_async_client.aclose()
221
221
 
222
222
  @override
223
223
  async def send_request(
@@ -229,8 +229,8 @@ class Httpx2HttpClientAsync(HttpClientAsync):
229
229
  content: bytes | None,
230
230
  timeout: float | None,
231
231
  stream: bool,
232
- ) -> httpx.Response:
233
- request = self._httpx_async_client.build_request(
232
+ ) -> httpx2.Response:
233
+ request = self._httpx2_async_client.build_request(
234
234
  method=method,
235
235
  url=url,
236
236
  headers=headers,
@@ -238,17 +238,17 @@ class Httpx2HttpClientAsync(HttpClientAsync):
238
238
  timeout=timeout,
239
239
  )
240
240
  _restore_explicit_cookie_header(request, headers)
241
- return await self._httpx_async_client.send(request, stream=stream)
241
+ return await self._httpx2_async_client.send(request, stream=stream)
242
242
 
243
- async def _clear_response_cookies(self, _response: httpx.Response) -> None:
244
- """Prevent HTTPX's shared cookie jar from leaking server cookies into later API requests."""
245
- self._httpx_async_client.cookies.clear()
243
+ async def _clear_response_cookies(self, _response: httpx2.Response) -> None:
244
+ """Prevent HTTPX2's shared cookie jar from leaking server cookies into later API requests."""
245
+ self._httpx2_async_client.cookies.clear()
246
246
 
247
247
 
248
- def _restore_explicit_cookie_header(request: httpx.Request, headers: dict[str, str]) -> None:
249
- """Keep only cookies explicitly supplied for this request, never cookies from HTTPX's shared jar.
248
+ def _restore_explicit_cookie_header(request: httpx2.Request, headers: dict[str, str]) -> None:
249
+ """Keep only cookies explicitly supplied for this request, never cookies from HTTPX2's shared jar.
250
250
 
251
- HTTPX drops the `Cookie` header when it builds a redirect request and rebuilds it from the jar, so an explicit
251
+ HTTPX2 drops the `Cookie` header when it builds a redirect request and rebuilds it from the jar, so an explicit
252
252
  cookie only reaches the first hop of a redirected request.
253
253
  """
254
254
  explicit_cookie = next((value for key, value in headers.items() if key.lower() == 'cookie'), None)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Mapping, Sequence
3
4
  from datetime import timedelta
4
5
  from typing import Literal
5
6
 
@@ -38,7 +39,7 @@ to ad-hoc webhooks (e.g. `condition`) are ignored at runtime. The `*CamelDict` v
38
39
  matching the Apify API spelling.
39
40
  """
40
41
 
41
- JsonSerializable = dict[str, 'JsonSerializable'] | list['JsonSerializable'] | str | int | float | bool | None
42
+ JsonSerializable = Mapping[str, 'JsonSerializable'] | Sequence['JsonSerializable'] | str | int | float | bool | None
42
43
  """Recursive type for JSON-serializable values - primitives plus objects and arrays with JSON-serializable contents.
43
44
 
44
45
  Based on the definition discussed in https://github.com/python/typing/issues/182.
File without changes