Skip to content

Commit 95ff5b9

Browse files
committed
address feedback
1 parent 8b6773d commit 95ff5b9

10 files changed

Lines changed: 375 additions & 241 deletions

File tree

docs/04_upgrading/upgrading_to_v3.mdx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,15 @@ except RateLimitError:
218218
...
219219
```
220220

221-
### Behavior change: `.get()` on chained clients now raises on 404
221+
### Behavior change: 404 on ambiguous endpoints now raises `NotFoundError`
222222

223-
`.get()` continues to swallow 404 into `None` for direct, ID-identified fetches like `client.dataset(id).get()` or `client.run(id).get()` — a 404 there unambiguously means the named resource does not exist.
223+
Direct, ID-identified fetches like `client.dataset(id).get()` or `client.run(id).get()` continue to swallow 404 into `None` — a 404 there unambiguously means the named resource does not exist. Similarly, `.delete()` on an ID-identified client keeps its idempotent behavior (404 is silently swallowed).
224224

225-
For chained calls that target a default sub-resource without an ID — `run.dataset()`, `run.key_value_store()`, `run.request_queue()`, `run.log()` — a 404 is ambiguous (it could mean the parent run is missing OR the default sub-resource is missing, and the API body does not disambiguate). These now raise <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> instead of silently returning `None`:
225+
For calls where a 404 is *ambiguous*, the client now propagates <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> instead of returning `None` / silently succeeding. Three categories of endpoints are affected:
226+
227+
1. **Chained calls that target a default sub-resource without an ID**`run.dataset()`, `run.key_value_store()`, `run.request_queue()`, `run.log()`. A 404 here could mean the parent run is missing OR the default sub-resource is missing, and the API body does not disambiguate. Applies to both `.get()` and `.delete()`.
228+
2. **`.get()` / `.get_as_bytes()` / `.stream()` on a chained `LogClient`** — e.g. `run.log().get()`. Direct `client.log(build_or_run_id).get()` still returns `None` on 404.
229+
3. **Singleton sub-resource endpoints fetched via a fixed path** — <ApiLink to="class/ScheduleClient#get_log">`ScheduleClient.get_log()`</ApiLink>, <ApiLink to="class/TaskClient#get_input">`TaskClient.get_input()`</ApiLink>, <ApiLink to="class/DatasetClient#get_statistics">`DatasetClient.get_statistics()`</ApiLink>, <ApiLink to="class/UserClient#monthly_usage">`UserClient.monthly_usage()`</ApiLink>, <ApiLink to="class/UserClient#limits">`UserClient.limits()`</ApiLink>, <ApiLink to="class/WebhookClient#test">`WebhookClient.test()`</ApiLink>. These hit paths like `/schedules/{id}/log` or `/actor-tasks/{id}/input`, so a 404 effectively means the parent is missing. Return types moved from `T | None` to `T`.
226230

227231
```python
228232
from apify_client import ApifyClient
@@ -235,6 +239,12 @@ try:
235239
except NotFoundError:
236240
# Previously this returned `None`; now you must handle it explicitly.
237241
dataset = None
242+
243+
try:
244+
schedule_log = client.schedule('some-schedule-id').get_log()
245+
except NotFoundError:
246+
# `get_log()` previously returned `None` when the schedule was missing; now it raises.
247+
schedule_log = None
238248
```
239249

240250
Direct `.get()` also now swallows every 404 regardless of the `error.type` string in the response body (previously only `record-not-found` and `record-or-token-not-found` types were swallowed). If your code needs to distinguish between "resource missing" and "404 with an unexpected type", inspect `.type` on a caught <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> from a non-`.get()` call path.

src/apify_client/_resource_clients/_resource_client.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010
from apify_client._docs import docs_group
1111
from apify_client._logging import WithLogDetailsClient
1212
from apify_client._types import ActorJobResponse
13-
from apify_client._utils import catch_not_found_or_throw, response_to_dict, to_safe_id, to_seconds
13+
from apify_client._utils import (
14+
catch_not_found_for_resource_or_throw,
15+
catch_not_found_or_throw,
16+
response_to_dict,
17+
to_safe_id,
18+
to_seconds,
19+
)
1420
from apify_client.errors import ApifyApiError
1521

1622
if TYPE_CHECKING:
@@ -196,9 +202,8 @@ def __init__(
196202
def _get(self, *, timeout: Timeout) -> dict | None:
197203
"""Perform a GET request for this resource, returning the parsed response or None if not found.
198204
199-
404s collapse to `None` only when this client targets a specific resource by ID. For chained clients
200-
without a `resource_id` (e.g. `run.dataset()`), a 404 could mean either the parent or the default
201-
sub-resource is missing and the API body cannot disambiguate, so `NotFoundError` propagates to the caller.
205+
404s collapse to `None` only for ID-identified clients. Chained clients without a `resource_id`
206+
(e.g. `run.dataset()`) propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
202207
"""
203208
try:
204209
response = self._http_client.call(
@@ -209,9 +214,7 @@ def _get(self, *, timeout: Timeout) -> dict | None:
209214
)
210215
return response_to_dict(response)
211216
except ApifyApiError as exc:
212-
if self._resource_id is None:
213-
raise
214-
catch_not_found_or_throw(exc)
217+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
215218
return None
216219

217220
def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
@@ -226,7 +229,11 @@ def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
226229
return response_to_dict(response)
227230

228231
def _delete(self, *, timeout: Timeout) -> None:
229-
"""Perform a DELETE request to delete this resource, ignoring 404 errors."""
232+
"""Perform a DELETE request to delete this resource.
233+
234+
404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
235+
`resource_id` propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
236+
"""
230237
try:
231238
self._http_client.call(
232239
url=self._build_url(),
@@ -235,7 +242,7 @@ def _delete(self, *, timeout: Timeout) -> None:
235242
timeout=timeout,
236243
)
237244
except ApifyApiError as exc:
238-
catch_not_found_or_throw(exc)
245+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
239246

240247
def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
241248
"""Perform a GET request to list resources."""
@@ -383,9 +390,8 @@ def __init__(
383390
async def _get(self, *, timeout: Timeout) -> dict | None:
384391
"""Perform a GET request for this resource, returning the parsed response or None if not found.
385392
386-
404s collapse to `None` only when this client targets a specific resource by ID. For chained clients
387-
without a `resource_id` (e.g. `run.dataset()`), a 404 could mean either the parent or the default
388-
sub-resource is missing and the API body cannot disambiguate, so `NotFoundError` propagates to the caller.
393+
404s collapse to `None` only for ID-identified clients. Chained clients without a `resource_id`
394+
(e.g. `run.dataset()`) propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
389395
"""
390396
try:
391397
response = await self._http_client.call(
@@ -396,9 +402,7 @@ async def _get(self, *, timeout: Timeout) -> dict | None:
396402
)
397403
return response_to_dict(response)
398404
except ApifyApiError as exc:
399-
if self._resource_id is None:
400-
raise
401-
catch_not_found_or_throw(exc)
405+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
402406
return None
403407

404408
async def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
@@ -413,7 +417,11 @@ async def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
413417
return response_to_dict(response)
414418

415419
async def _delete(self, *, timeout: Timeout) -> None:
416-
"""Perform a DELETE request to delete this resource, ignoring 404 errors."""
420+
"""Perform a DELETE request to delete this resource.
421+
422+
404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
423+
`resource_id` propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
424+
"""
417425
try:
418426
await self._http_client.call(
419427
url=self._build_url(),
@@ -422,7 +430,7 @@ async def _delete(self, *, timeout: Timeout) -> None:
422430
timeout=timeout,
423431
)
424432
except ApifyApiError as exc:
425-
catch_not_found_or_throw(exc)
433+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
426434

427435
async def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
428436
"""Perform a GET request to list resources."""

src/apify_client/_resource_clients/dataset.py

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@
1010
from apify_client._models import Dataset, DatasetResponse, DatasetStatistics, DatasetStatisticsResponse
1111
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
1212
from apify_client._utils import (
13-
catch_not_found_or_throw,
1413
create_storage_content_signature,
1514
response_to_dict,
1615
response_to_list,
1716
)
18-
from apify_client.errors import ApifyApiError
1917

2018
if TYPE_CHECKING:
2119
from collections.abc import AsyncIterator, Iterator
@@ -628,7 +626,7 @@ def push_items(self, items: JsonSerializable, *, timeout: Timeout = 'medium') ->
628626
timeout=timeout,
629627
)
630628

631-
def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics | None:
629+
def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics:
632630
"""Get the dataset statistics.
633631
634632
https://docs.apify.com/api/v2#tag/DatasetsStatistics/operation/dataset_statistics_get
@@ -637,21 +635,19 @@ def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics | N
637635
timeout: Timeout for the API HTTP request.
638636
639637
Returns:
640-
The dataset statistics or None if the dataset does not exist.
641-
"""
642-
try:
643-
response = self._http_client.call(
644-
url=self._build_url('statistics'),
645-
method='GET',
646-
params=self._build_params(),
647-
timeout=timeout,
648-
)
649-
result = response_to_dict(response)
650-
return DatasetStatisticsResponse.model_validate(result).data
651-
except ApifyApiError as exc:
652-
catch_not_found_or_throw(exc)
638+
The dataset statistics.
653639
654-
return None
640+
Raises:
641+
NotFoundError: If the dataset does not exist.
642+
"""
643+
response = self._http_client.call(
644+
url=self._build_url('statistics'),
645+
method='GET',
646+
params=self._build_params(),
647+
timeout=timeout,
648+
)
649+
result = response_to_dict(response)
650+
return DatasetStatisticsResponse.model_validate(result).data
655651

656652
def create_items_public_url(
657653
self,
@@ -1208,7 +1204,7 @@ async def push_items(self, items: JsonSerializable, *, timeout: Timeout = 'mediu
12081204
timeout=timeout,
12091205
)
12101206

1211-
async def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics | None:
1207+
async def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics:
12121208
"""Get the dataset statistics.
12131209
12141210
https://docs.apify.com/api/v2#tag/DatasetsStatistics/operation/dataset_statistics_get
@@ -1217,21 +1213,19 @@ async def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatisti
12171213
timeout: Timeout for the API HTTP request.
12181214
12191215
Returns:
1220-
The dataset statistics or None if the dataset does not exist.
1221-
"""
1222-
try:
1223-
response = await self._http_client.call(
1224-
url=self._build_url('statistics'),
1225-
method='GET',
1226-
params=self._build_params(),
1227-
timeout=timeout,
1228-
)
1229-
result = response_to_dict(response)
1230-
return DatasetStatisticsResponse.model_validate(result).data
1231-
except ApifyApiError as exc:
1232-
catch_not_found_or_throw(exc)
1216+
The dataset statistics.
12331217
1234-
return None
1218+
Raises:
1219+
NotFoundError: If the dataset does not exist.
1220+
"""
1221+
response = await self._http_client.call(
1222+
url=self._build_url('statistics'),
1223+
method='GET',
1224+
params=self._build_params(),
1225+
timeout=timeout,
1226+
)
1227+
result = response_to_dict(response)
1228+
return DatasetStatisticsResponse.model_validate(result).data
12351229

12361230
async def create_items_public_url(
12371231
self,

src/apify_client/_resource_clients/log.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from apify_client._docs import docs_group
77
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
8-
from apify_client._utils import catch_not_found_or_throw
8+
from apify_client._utils import catch_not_found_for_resource_or_throw
99
from apify_client.errors import ApifyApiError
1010

1111
if TYPE_CHECKING:
@@ -39,6 +39,9 @@ def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | None:
3939
4040
https://docs.apify.com/api/v2#/reference/logs/log/get-log
4141
42+
404s collapse to `None` only when this client targets a specific log by ID (e.g. `client.log(run_id).get()`).
43+
For chained clients without a `resource_id` (e.g. `run.log().get()`), a 404 is ambiguous and propagates.
44+
4245
Args:
4346
raw: If true, the log will include formatting. For example, coloring character sequences.
4447
timeout: Timeout for the API HTTP request.
@@ -57,7 +60,7 @@ def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | None:
5760
return response.text # noqa: TRY300
5861

5962
except ApifyApiError as exc:
60-
catch_not_found_or_throw(exc)
63+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
6164

6265
return None
6366

@@ -84,7 +87,7 @@ def get_as_bytes(self, *, raw: bool = False, timeout: Timeout = 'long') -> bytes
8487
return response.content # noqa: TRY300
8588

8689
except ApifyApiError as exc:
87-
catch_not_found_or_throw(exc)
90+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
8891

8992
return None
9093

@@ -113,7 +116,7 @@ def stream(self, *, raw: bool = False, timeout: Timeout = 'long') -> Iterator[Ht
113116

114117
yield response
115118
except ApifyApiError as exc:
116-
catch_not_found_or_throw(exc)
119+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
117120
yield None
118121
finally:
119122
if response:
@@ -144,6 +147,9 @@ async def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | No
144147
145148
https://docs.apify.com/api/v2#/reference/logs/log/get-log
146149
150+
404s collapse to `None` only when this client targets a specific log by ID (e.g. `client.log(run_id).get()`).
151+
For chained clients without a `resource_id` (e.g. `run.log().get()`), a 404 is ambiguous and propagates.
152+
147153
Args:
148154
raw: If true, the log will include formatting. For example, coloring character sequences.
149155
timeout: Timeout for the API HTTP request.
@@ -162,7 +168,7 @@ async def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | No
162168
return response.text # noqa: TRY300
163169

164170
except ApifyApiError as exc:
165-
catch_not_found_or_throw(exc)
171+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
166172

167173
return None
168174

@@ -189,7 +195,7 @@ async def get_as_bytes(self, *, raw: bool = False, timeout: Timeout = 'long') ->
189195
return response.content # noqa: TRY300
190196

191197
except ApifyApiError as exc:
192-
catch_not_found_or_throw(exc)
198+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
193199

194200
return None
195201

@@ -218,7 +224,7 @@ async def stream(self, *, raw: bool = False, timeout: Timeout = 'long') -> Async
218224

219225
yield response
220226
except ApifyApiError as exc:
221-
catch_not_found_or_throw(exc)
227+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
222228
yield None
223229
finally:
224230
if response:

0 commit comments

Comments
 (0)