Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
31 changes: 28 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,36 @@ except RateLimitError:
...
```

### Behavior change: `.get()` now returns `None` on any 404
### Behavior change: 404 on ambiguous endpoints now raises `NotFoundError`

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

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

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

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

try:
schedule_log = client.schedule('some-schedule-id').get_log()
except NotFoundError:
# `get_log()` previously returned `None` when the schedule was missing; now it raises.
schedule_log = 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
40 changes: 31 additions & 9 deletions src/apify_client/_resource_clients/_resource_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
from apify_client._docs import docs_group
from apify_client._logging import WithLogDetailsClient
from apify_client._types import ActorJobResponse
from apify_client._utils import catch_not_found_or_throw, response_to_dict, to_safe_id, to_seconds
from apify_client._utils import (
catch_not_found_for_resource_or_throw,
catch_not_found_or_throw,
response_to_dict,
to_safe_id,
to_seconds,
)
from apify_client.errors import ApifyApiError

if TYPE_CHECKING:
Expand Down Expand Up @@ -194,7 +200,11 @@ 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 for ID-identified clients. Chained clients without a `resource_id`
(e.g. `run.dataset()`) propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
"""
try:
response = self._http_client.call(
url=self._build_url(),
Expand All @@ -204,7 +214,7 @@ def _get(self, *, timeout: Timeout) -> dict | None:
)
return response_to_dict(response)
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)
return None

def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
Expand All @@ -219,7 +229,11 @@ def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
return response_to_dict(response)

def _delete(self, *, timeout: Timeout) -> None:
"""Perform a DELETE request to delete this resource, ignoring 404 errors."""
"""Perform a DELETE request to delete this resource.

404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
`resource_id` propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
"""
try:
self._http_client.call(
url=self._build_url(),
Expand All @@ -228,7 +242,7 @@ def _delete(self, *, timeout: Timeout) -> None:
timeout=timeout,
)
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)

def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
"""Perform a GET request to list resources."""
Expand Down Expand Up @@ -374,7 +388,11 @@ 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 for ID-identified clients. Chained clients without a `resource_id`
(e.g. `run.dataset()`) propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
"""
try:
response = await self._http_client.call(
url=self._build_url(),
Expand All @@ -384,7 +402,7 @@ async def _get(self, *, timeout: Timeout) -> dict | None:
)
return response_to_dict(response)
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)
return None

async def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
Expand All @@ -399,7 +417,11 @@ async def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
return response_to_dict(response)

async def _delete(self, *, timeout: Timeout) -> None:
"""Perform a DELETE request to delete this resource, ignoring 404 errors."""
"""Perform a DELETE request to delete this resource.

404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
`resource_id` propagate `NotFoundError` — see `catch_not_found_for_resource_or_throw`.
"""
try:
await self._http_client.call(
url=self._build_url(),
Expand All @@ -408,7 +430,7 @@ async def _delete(self, *, timeout: Timeout) -> None:
timeout=timeout,
)
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)

async def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
"""Perform a GET request to list resources."""
Expand Down
58 changes: 26 additions & 32 deletions src/apify_client/_resource_clients/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@
from apify_client._models import Dataset, DatasetResponse, DatasetStatistics, DatasetStatisticsResponse
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
from apify_client._utils import (
catch_not_found_or_throw,
create_storage_content_signature,
response_to_dict,
response_to_list,
)
from apify_client.errors import ApifyApiError

if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
Expand Down Expand Up @@ -628,7 +626,7 @@ def push_items(self, items: JsonSerializable, *, timeout: Timeout = 'medium') ->
timeout=timeout,
)

def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics | None:
def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics:
"""Get the dataset statistics.

https://docs.apify.com/api/v2#tag/DatasetsStatistics/operation/dataset_statistics_get
Expand All @@ -637,21 +635,19 @@ def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics | N
timeout: Timeout for the API HTTP request.

Returns:
The dataset statistics or None if the dataset does not exist.
"""
try:
response = self._http_client.call(
url=self._build_url('statistics'),
method='GET',
params=self._build_params(),
timeout=timeout,
)
result = response_to_dict(response)
return DatasetStatisticsResponse.model_validate(result).data
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
The dataset statistics.

return None
Raises:
NotFoundError: If the dataset does not exist.
"""
response = self._http_client.call(
url=self._build_url('statistics'),
method='GET',
params=self._build_params(),
timeout=timeout,
)
result = response_to_dict(response)
return DatasetStatisticsResponse.model_validate(result).data

def create_items_public_url(
self,
Expand Down Expand Up @@ -1208,7 +1204,7 @@ async def push_items(self, items: JsonSerializable, *, timeout: Timeout = 'mediu
timeout=timeout,
)

async def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics | None:
async def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatistics:
"""Get the dataset statistics.

https://docs.apify.com/api/v2#tag/DatasetsStatistics/operation/dataset_statistics_get
Expand All @@ -1217,21 +1213,19 @@ async def get_statistics(self, *, timeout: Timeout = 'short') -> DatasetStatisti
timeout: Timeout for the API HTTP request.

Returns:
The dataset statistics or None if the dataset does not exist.
"""
try:
response = await self._http_client.call(
url=self._build_url('statistics'),
method='GET',
params=self._build_params(),
timeout=timeout,
)
result = response_to_dict(response)
return DatasetStatisticsResponse.model_validate(result).data
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
The dataset statistics.

return None
Raises:
NotFoundError: If the dataset does not exist.
"""
response = await self._http_client.call(
url=self._build_url('statistics'),
method='GET',
params=self._build_params(),
timeout=timeout,
)
result = response_to_dict(response)
return DatasetStatisticsResponse.model_validate(result).data

async def create_items_public_url(
self,
Expand Down
20 changes: 13 additions & 7 deletions src/apify_client/_resource_clients/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from apify_client._docs import docs_group
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
from apify_client._utils import catch_not_found_or_throw
from apify_client._utils import catch_not_found_for_resource_or_throw
from apify_client.errors import ApifyApiError

if TYPE_CHECKING:
Expand Down Expand Up @@ -39,6 +39,9 @@ def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | None:

https://docs.apify.com/api/v2#/reference/logs/log/get-log

404s collapse to `None` only when this client targets a specific log by ID (e.g. `client.log(run_id).get()`).
For chained clients without a `resource_id` (e.g. `run.log().get()`), a 404 is ambiguous and propagates.

Args:
raw: If true, the log will include formatting. For example, coloring character sequences.
timeout: Timeout for the API HTTP request.
Expand All @@ -57,7 +60,7 @@ def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | None:
return response.text # noqa: TRY300

except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)

return None

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

except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)

return None

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

yield response
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)
yield None
finally:
if response:
Expand Down Expand Up @@ -144,6 +147,9 @@ async def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | No

https://docs.apify.com/api/v2#/reference/logs/log/get-log

404s collapse to `None` only when this client targets a specific log by ID (e.g. `client.log(run_id).get()`).
For chained clients without a `resource_id` (e.g. `run.log().get()`), a 404 is ambiguous and propagates.

Args:
raw: If true, the log will include formatting. For example, coloring character sequences.
timeout: Timeout for the API HTTP request.
Expand All @@ -162,7 +168,7 @@ async def get(self, *, raw: bool = False, timeout: Timeout = 'long') -> str | No
return response.text # noqa: TRY300

except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)

return None

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

except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)

return None

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

yield response
except ApifyApiError as exc:
catch_not_found_or_throw(exc)
catch_not_found_for_resource_or_throw(exc, self._resource_id)
yield None
finally:
if response:
Expand Down
Loading