Skip to content

Commit 84e57c0

Browse files
committed
fix: raise NotFoundError on 404 from chained .get() without ID
`.get()` previously collapsed every 404 into `None` via `catch_not_found_or_throw`. That works for direct, ID-identified fetches (`client.dataset(id).get()`), where a 404 unambiguously means the named resource is missing. It's misleading 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 there could mean the parent run is missing or the default sub-resource is missing, and the API body cannot disambiguate the two. This change keeps the `None` behavior for ID-identified clients and propagates `NotFoundError` from chained clients (`self._resource_id is None`). The v3 upgrade guide is updated to document the new semantics, and sync/async tests cover both code paths.
1 parent a6daff7 commit 84e57c0

3 files changed

Lines changed: 73 additions & 5 deletions

File tree

docs/04_upgrading/upgrading_to_v3.mdx

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

221-
### Behavior change: `.get()` now returns `None` on any 404
221+
### Behavior change: `.get()` on chained clients now raises on 404
222222

223-
As a consequence of the dispatch above, `.get()`-style convenience methods — which use `catch_not_found_or_throw` internally to swallow 404 responses and return `None` — now swallow **every** 404, regardless of the `error.type` string in the response body. Previously only 404 responses carrying the types `record-not-found` or `record-or-token-not-found` were swallowed; any other 404 was re-raised as `ApifyApiError`.
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.
224224

225-
In practice this matters only if you relied on a `.get()` call raising for a 404 with an unusual error type — such cases now return `None` instead. If your code needs to distinguish between "resource missing" and "404 with an unexpected type", inspect `.type` on the returned response or catch <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> from non-`.get()` calls that do not use `catch_not_found_or_throw`.
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`:
226+
227+
```python
228+
from apify_client import ApifyClient
229+
from apify_client.errors import NotFoundError
230+
231+
client = ApifyClient(token='MY-APIFY-TOKEN')
232+
233+
try:
234+
dataset = client.run('some-run-id').dataset().get()
235+
except NotFoundError:
236+
# Previously this returned `None`; now you must handle it explicitly.
237+
dataset = None
238+
```
239+
240+
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.
226241

227242
## Snake_case `sort_by` values on `actors().list()`
228243

src/apify_client/_resource_clients/_resource_client.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,12 @@ def __init__(
194194
)
195195

196196
def _get(self, *, timeout: Timeout) -> dict | None:
197-
"""Perform a GET request for this resource, returning the parsed response or None if not found."""
197+
"""Perform a GET request for this resource, returning the parsed response or None if not found.
198+
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.
202+
"""
198203
try:
199204
response = self._http_client.call(
200205
url=self._build_url(),
@@ -204,6 +209,8 @@ def _get(self, *, timeout: Timeout) -> dict | None:
204209
)
205210
return response_to_dict(response)
206211
except ApifyApiError as exc:
212+
if self._resource_id is None:
213+
raise
207214
catch_not_found_or_throw(exc)
208215
return None
209216

@@ -374,7 +381,12 @@ def __init__(
374381
)
375382

376383
async def _get(self, *, timeout: Timeout) -> dict | None:
377-
"""Perform a GET request for this resource, returning the parsed response or None if not found."""
384+
"""Perform a GET request for this resource, returning the parsed response or None if not found.
385+
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.
389+
"""
378390
try:
379391
response = await self._http_client.call(
380392
url=self._build_url(),
@@ -384,6 +396,8 @@ async def _get(self, *, timeout: Timeout) -> dict | None:
384396
)
385397
return response_to_dict(response)
386398
except ApifyApiError as exc:
399+
if self._resource_id is None:
400+
raise
387401
catch_not_found_or_throw(exc)
388402
return None
389403

tests/unit/test_client_errors.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77
from werkzeug import Response
88

9+
from apify_client import ApifyClient, ApifyClientAsync
910
from apify_client._http_clients import ImpitHttpClient, ImpitHttpClientAsync
1011
from apify_client.errors import (
1112
ApifyApiError,
@@ -211,3 +212,41 @@ def test_apify_api_error_falls_back_for_unparsable_body(httpserver: HTTPServer)
211212

212213
assert type(exc.value) is ApifyApiError
213214
assert exc.value.type is None
215+
216+
217+
def _not_found_body() -> dict:
218+
return {'error': {'type': 'record-not-found', 'message': 'not found'}}
219+
220+
221+
def test_direct_get_returns_none_on_404(httpserver: HTTPServer) -> None:
222+
"""`client.dataset("X").get()` — a direct, ID-identified fetch — swallows 404 into `None`."""
223+
httpserver.expect_request('/v2/datasets/missing').respond_with_json(_not_found_body(), status=404)
224+
client = ApifyClient(token='test', api_url=httpserver.url_for('/').removesuffix('/'))
225+
226+
assert client.dataset('missing').get() is None
227+
228+
229+
async def test_direct_get_returns_none_on_404_async(httpserver: HTTPServer) -> None:
230+
"""Async mirror: direct `.get()` swallows 404."""
231+
httpserver.expect_request('/v2/datasets/missing').respond_with_json(_not_found_body(), status=404)
232+
client = ApifyClientAsync(token='test', api_url=httpserver.url_for('/').removesuffix('/'))
233+
234+
assert await client.dataset('missing').get() is None
235+
236+
237+
def test_chained_get_raises_on_404(httpserver: HTTPServer) -> None:
238+
"""`run("X").dataset().get()` is ambiguous on 404 (run or dataset missing) — propagate instead of `None`."""
239+
httpserver.expect_request('/v2/actor-runs/missing-run/dataset').respond_with_json(_not_found_body(), status=404)
240+
client = ApifyClient(token='test', api_url=httpserver.url_for('/').removesuffix('/'))
241+
242+
with pytest.raises(NotFoundError):
243+
client.run('missing-run').dataset().get()
244+
245+
246+
async def test_chained_get_raises_on_404_async(httpserver: HTTPServer) -> None:
247+
"""Async mirror: chained `.get()` propagates NotFoundError."""
248+
httpserver.expect_request('/v2/actor-runs/missing-run/dataset').respond_with_json(_not_found_body(), status=404)
249+
client = ApifyClientAsync(token='test', api_url=httpserver.url_for('/').removesuffix('/'))
250+
251+
with pytest.raises(NotFoundError):
252+
await client.run('missing-run').dataset().get()

0 commit comments

Comments
 (0)