Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions docs/04_upgrading/upgrading_to_v3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,26 @@ except RateLimitError:
...
```

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

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`.
`.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.

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`.
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`:

```python
from apify_client import ApifyClient
from apify_client.errors import NotFoundError

client = ApifyClient(token='MY-APIFY-TOKEN')

try:
dataset = client.run('some-run-id').dataset().get()
except NotFoundError:
# Previously this returned `None`; now you must handle it explicitly.
dataset = None
```

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.

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

Expand Down
18 changes: 16 additions & 2 deletions src/apify_client/_resource_clients/_resource_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,12 @@ def __init__(
)

def _get(self, *, timeout: Timeout) -> dict | None:
"""Perform a GET request for this resource, returning the parsed response or None if not found."""
"""Perform a GET request for this resource, returning the parsed response or None if not found.

404s collapse to `None` only when this client targets a specific resource by ID. For chained clients
without a `resource_id` (e.g. `run.dataset()`), a 404 could mean either the parent or the default
sub-resource is missing and the API body cannot disambiguate, so `NotFoundError` propagates to the caller.
"""
try:
response = self._http_client.call(
url=self._build_url(),
Expand All @@ -204,6 +209,8 @@ def _get(self, *, timeout: Timeout) -> dict | None:
)
return response_to_dict(response)
except ApifyApiError as exc:
if self._resource_id is None:
raise
catch_not_found_or_throw(exc)
return None

Expand Down Expand Up @@ -374,7 +381,12 @@ def __init__(
)

async def _get(self, *, timeout: Timeout) -> dict | None:
"""Perform a GET request for this resource, returning the parsed response or None if not found."""
"""Perform a GET request for this resource, returning the parsed response or None if not found.

404s collapse to `None` only when this client targets a specific resource by ID. For chained clients
without a `resource_id` (e.g. `run.dataset()`), a 404 could mean either the parent or the default
sub-resource is missing and the API body cannot disambiguate, so `NotFoundError` propagates to the caller.
"""
try:
response = await self._http_client.call(
url=self._build_url(),
Expand All @@ -384,6 +396,8 @@ async def _get(self, *, timeout: Timeout) -> dict | None:
)
return response_to_dict(response)
except ApifyApiError as exc:
if self._resource_id is None:
raise
catch_not_found_or_throw(exc)
return None

Expand Down
39 changes: 39 additions & 0 deletions tests/unit/test_client_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
from werkzeug import Response

from apify_client import ApifyClient, ApifyClientAsync
from apify_client._http_clients import ImpitHttpClient, ImpitHttpClientAsync
from apify_client.errors import (
ApifyApiError,
Expand Down Expand Up @@ -211,3 +212,41 @@ def test_apify_api_error_falls_back_for_unparsable_body(httpserver: HTTPServer)

assert type(exc.value) is ApifyApiError
assert exc.value.type is None


def _not_found_body() -> dict:
return {'error': {'type': 'record-not-found', 'message': 'not found'}}


def test_direct_get_returns_none_on_404(httpserver: HTTPServer) -> None:
"""`client.dataset("X").get()` — a direct, ID-identified fetch — swallows 404 into `None`."""
httpserver.expect_request('/v2/datasets/missing').respond_with_json(_not_found_body(), status=404)
client = ApifyClient(token='test', api_url=httpserver.url_for('/').removesuffix('/'))

assert client.dataset('missing').get() is None


async def test_direct_get_returns_none_on_404_async(httpserver: HTTPServer) -> None:
"""Async mirror: direct `.get()` swallows 404."""
httpserver.expect_request('/v2/datasets/missing').respond_with_json(_not_found_body(), status=404)
client = ApifyClientAsync(token='test', api_url=httpserver.url_for('/').removesuffix('/'))

assert await client.dataset('missing').get() is None


def test_chained_get_raises_on_404(httpserver: HTTPServer) -> None:
"""`run("X").dataset().get()` is ambiguous on 404 (run or dataset missing) — propagate instead of `None`."""
httpserver.expect_request('/v2/actor-runs/missing-run/dataset').respond_with_json(_not_found_body(), status=404)
client = ApifyClient(token='test', api_url=httpserver.url_for('/').removesuffix('/'))

with pytest.raises(NotFoundError):
client.run('missing-run').dataset().get()


async def test_chained_get_raises_on_404_async(httpserver: HTTPServer) -> None:
"""Async mirror: chained `.get()` propagates NotFoundError."""
httpserver.expect_request('/v2/actor-runs/missing-run/dataset').respond_with_json(_not_found_body(), status=404)
client = ApifyClientAsync(token='test', api_url=httpserver.url_for('/').removesuffix('/'))

with pytest.raises(NotFoundError):
await client.run('missing-run').dataset().get()