diff --git a/docs/04_upgrading/upgrading_to_v3.mdx b/docs/04_upgrading/upgrading_to_v3.mdx
index 58d6a0bb..b4d35b64 100644
--- a/docs/04_upgrading/upgrading_to_v3.mdx
+++ b/docs/04_upgrading/upgrading_to_v3.mdx
@@ -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 `NotFoundError` from non-`.get()` calls that do not use `catch_not_found_or_throw`.
+For calls where a 404 is *ambiguous*, the client now propagates `NotFoundError` 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** — `ScheduleClient.get_log()`, `TaskClient.get_input()`, `DatasetClient.get_statistics()`, `UserClient.monthly_usage()`, `UserClient.limits()`, `WebhookClient.test()`. 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 `NotFoundError` from a non-`.get()` call path.
## Snake_case `sort_by` values on `actors().list()`
diff --git a/src/apify_client/_resource_clients/_resource_client.py b/src/apify_client/_resource_clients/_resource_client.py
index 258c743b..f7b11d75 100644
--- a/src/apify_client/_resource_clients/_resource_client.py
+++ b/src/apify_client/_resource_clients/_resource_client.py
@@ -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:
@@ -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(),
@@ -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:
@@ -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(),
@@ -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."""
@@ -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(),
@@ -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:
@@ -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(),
@@ -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."""
diff --git a/src/apify_client/_resource_clients/dataset.py b/src/apify_client/_resource_clients/dataset.py
index f4e0e204..378bd6eb 100644
--- a/src/apify_client/_resource_clients/dataset.py
+++ b/src/apify_client/_resource_clients/dataset.py
@@ -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
@@ -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
@@ -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,
@@ -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
@@ -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,
diff --git a/src/apify_client/_resource_clients/log.py b/src/apify_client/_resource_clients/log.py
index 95e7cd72..e92bd037 100644
--- a/src/apify_client/_resource_clients/log.py
+++ b/src/apify_client/_resource_clients/log.py
@@ -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:
@@ -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.
@@ -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
@@ -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
@@ -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:
@@ -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.
@@ -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
@@ -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
@@ -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:
diff --git a/src/apify_client/_resource_clients/schedule.py b/src/apify_client/_resource_clients/schedule.py
index b0e2e87c..8eee629b 100644
--- a/src/apify_client/_resource_clients/schedule.py
+++ b/src/apify_client/_resource_clients/schedule.py
@@ -11,8 +11,7 @@
ScheduleResponse,
)
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
-from apify_client._utils import catch_not_found_or_throw, response_to_dict
-from apify_client.errors import ApifyApiError
+from apify_client._utils import response_to_dict
if TYPE_CHECKING:
from apify_client._types import Timeout
@@ -111,7 +110,7 @@ def delete(self, *, timeout: Timeout = 'short') -> None:
"""
self._delete(timeout=timeout)
- def get_log(self, *, timeout: Timeout = 'medium') -> list[ScheduleInvoked] | None:
+ def get_log(self, *, timeout: Timeout = 'medium') -> list[ScheduleInvoked]:
"""Return log for the given schedule.
https://docs.apify.com/api/v2#/reference/schedules/schedule-log/get-schedule-log
@@ -121,20 +120,18 @@ def get_log(self, *, timeout: Timeout = 'medium') -> list[ScheduleInvoked] | Non
Returns:
Retrieved log of the given schedule.
- """
- try:
- response = self._http_client.call(
- url=self._build_url('log'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- result = response_to_dict(response)
- return ScheduleLogResponse.model_validate(result).data
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
- return None
+ Raises:
+ NotFoundError: If the schedule does not exist.
+ """
+ response = self._http_client.call(
+ url=self._build_url('log'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ result = response_to_dict(response)
+ return ScheduleLogResponse.model_validate(result).data
@docs_group('Resource clients')
@@ -230,7 +227,7 @@ async def delete(self, *, timeout: Timeout = 'short') -> None:
"""
await self._delete(timeout=timeout)
- async def get_log(self, *, timeout: Timeout = 'medium') -> list[ScheduleInvoked] | None:
+ async def get_log(self, *, timeout: Timeout = 'medium') -> list[ScheduleInvoked]:
"""Return log for the given schedule.
https://docs.apify.com/api/v2#/reference/schedules/schedule-log/get-schedule-log
@@ -240,17 +237,15 @@ async def get_log(self, *, timeout: Timeout = 'medium') -> list[ScheduleInvoked]
Returns:
Retrieved log of the given schedule.
+
+ Raises:
+ NotFoundError: If the schedule does not exist.
"""
- try:
- response = await self._http_client.call(
- url=self._build_url('log'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- result = response_to_dict(response)
- return ScheduleLogResponse.model_validate(result).data
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
-
- return None
+ response = await self._http_client.call(
+ url=self._build_url('log'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ result = response_to_dict(response)
+ return ScheduleLogResponse.model_validate(result).data
diff --git a/src/apify_client/_resource_clients/task.py b/src/apify_client/_resource_clients/task.py
index 6e7cef43..17920fbd 100644
--- a/src/apify_client/_resource_clients/task.py
+++ b/src/apify_client/_resource_clients/task.py
@@ -17,8 +17,7 @@
)
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
from apify_client._types import WebhookRepresentationList
-from apify_client._utils import catch_not_found_or_throw, response_to_dict, to_seconds
-from apify_client.errors import ApifyApiError
+from apify_client._utils import response_to_dict, to_seconds
if TYPE_CHECKING:
from datetime import timedelta
@@ -280,7 +279,7 @@ def call(
)
return run_client.wait_for_finish(wait_duration=wait_duration)
- def get_input(self, *, timeout: Timeout = 'short') -> dict | None:
+ def get_input(self, *, timeout: Timeout = 'short') -> dict:
"""Retrieve the default input for this task.
https://docs.apify.com/api/v2#/reference/actor-tasks/task-input-object/get-task-input
@@ -290,18 +289,17 @@ def get_input(self, *, timeout: Timeout = 'short') -> dict | None:
Returns:
Retrieved task input.
+
+ Raises:
+ NotFoundError: If the task does not exist.
"""
- try:
- response = self._http_client.call(
- url=self._build_url('input'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- return response_to_dict(response)
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
- return None
+ response = self._http_client.call(
+ url=self._build_url('input'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ return response_to_dict(response)
def update_input(self, *, task_input: dict | TaskInput, timeout: Timeout = 'short') -> dict:
"""Update the default input for this task.
@@ -603,7 +601,7 @@ async def call(
)
return await run_client.wait_for_finish(wait_duration=wait_duration)
- async def get_input(self, *, timeout: Timeout = 'short') -> dict | None:
+ async def get_input(self, *, timeout: Timeout = 'short') -> dict:
"""Retrieve the default input for this task.
https://docs.apify.com/api/v2#/reference/actor-tasks/task-input-object/get-task-input
@@ -613,18 +611,17 @@ async def get_input(self, *, timeout: Timeout = 'short') -> dict | None:
Returns:
Retrieved task input.
+
+ Raises:
+ NotFoundError: If the task does not exist.
"""
- try:
- response = await self._http_client.call(
- url=self._build_url('input'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- return response_to_dict(response)
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
- return None
+ response = await self._http_client.call(
+ url=self._build_url('input'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ return response_to_dict(response)
async def update_input(self, *, task_input: dict | TaskInput, timeout: Timeout = 'short') -> dict:
"""Update the default input for this task.
diff --git a/src/apify_client/_resource_clients/user.py b/src/apify_client/_resource_clients/user.py
index 6fabab48..6703000f 100644
--- a/src/apify_client/_resource_clients/user.py
+++ b/src/apify_client/_resource_clients/user.py
@@ -16,8 +16,7 @@
UserPublicInfo,
)
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
-from apify_client._utils import catch_not_found_or_throw, response_to_dict
-from apify_client.errors import ApifyApiError
+from apify_client._utils import response_to_dict
if TYPE_CHECKING:
from apify_client._types import Timeout
@@ -65,7 +64,7 @@ def get(self, *, timeout: Timeout = 'short') -> UserPublicInfo | UserPrivateInfo
except ValidationError:
return PublicUserDataResponse.model_validate(result).data
- def monthly_usage(self, *, timeout: Timeout = 'short') -> MonthlyUsage | None:
+ def monthly_usage(self, *, timeout: Timeout = 'short') -> MonthlyUsage:
"""Return monthly usage of the user account.
This includes a complete usage summary for the current usage cycle, an overall sum, as well as a daily breakdown
@@ -78,24 +77,21 @@ def monthly_usage(self, *, timeout: Timeout = 'short') -> MonthlyUsage | None:
timeout: Timeout for the API HTTP request.
Returns:
- The retrieved request, or None, if it did not exist.
- """
- try:
- response = self._http_client.call(
- url=self._build_url('usage/monthly'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- result = response_to_dict(response)
- return MonthlyUsageResponse.model_validate(result).data
+ The retrieved monthly usage.
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
-
- return None
+ Raises:
+ NotFoundError: If the user does not exist.
+ """
+ response = self._http_client.call(
+ url=self._build_url('usage/monthly'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ result = response_to_dict(response)
+ return MonthlyUsageResponse.model_validate(result).data
- def limits(self, *, timeout: Timeout = 'short') -> AccountLimits | None:
+ def limits(self, *, timeout: Timeout = 'short') -> AccountLimits:
"""Return a complete summary of the user account's limits.
It is the same information which is available on the account's Limits page. The returned data includes
@@ -107,22 +103,19 @@ def limits(self, *, timeout: Timeout = 'short') -> AccountLimits | None:
timeout: Timeout for the API HTTP request.
Returns:
- The account limits, or None, if they could not be retrieved.
- """
- try:
- response = self._http_client.call(
- url=self._build_url('limits'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- result = response_to_dict(response)
- return LimitsResponse.model_validate(result).data
+ The account limits.
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
-
- return None
+ Raises:
+ NotFoundError: If the user does not exist.
+ """
+ response = self._http_client.call(
+ url=self._build_url('limits'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ result = response_to_dict(response)
+ return LimitsResponse.model_validate(result).data
def update_limits(
self,
@@ -194,7 +187,7 @@ async def get(self, *, timeout: Timeout = 'short') -> UserPublicInfo | UserPriva
except ValidationError:
return PublicUserDataResponse.model_validate(result).data
- async def monthly_usage(self, *, timeout: Timeout = 'short') -> MonthlyUsage | None:
+ async def monthly_usage(self, *, timeout: Timeout = 'short') -> MonthlyUsage:
"""Return monthly usage of the user account.
This includes a complete usage summary for the current usage cycle, an overall sum, as well as a daily breakdown
@@ -207,24 +200,21 @@ async def monthly_usage(self, *, timeout: Timeout = 'short') -> MonthlyUsage | N
timeout: Timeout for the API HTTP request.
Returns:
- The retrieved request, or None, if it did not exist.
- """
- try:
- response = await self._http_client.call(
- url=self._build_url('usage/monthly'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- result = response_to_dict(response)
- return MonthlyUsageResponse.model_validate(result).data
-
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
+ The retrieved monthly usage.
- return None
+ Raises:
+ NotFoundError: If the user does not exist.
+ """
+ response = await self._http_client.call(
+ url=self._build_url('usage/monthly'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ result = response_to_dict(response)
+ return MonthlyUsageResponse.model_validate(result).data
- async def limits(self, *, timeout: Timeout = 'short') -> AccountLimits | None:
+ async def limits(self, *, timeout: Timeout = 'short') -> AccountLimits:
"""Return a complete summary of the user account's limits.
It is the same information which is available on the account's Limits page. The returned data includes
@@ -236,22 +226,19 @@ async def limits(self, *, timeout: Timeout = 'short') -> AccountLimits | None:
timeout: Timeout for the API HTTP request.
Returns:
- The account limits, or None, if they could not be retrieved.
+ The account limits.
+
+ Raises:
+ NotFoundError: If the user does not exist.
"""
- try:
- response = await self._http_client.call(
- url=self._build_url('limits'),
- method='GET',
- params=self._build_params(),
- timeout=timeout,
- )
- result = response_to_dict(response)
- return LimitsResponse.model_validate(result).data
-
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
-
- return None
+ response = await self._http_client.call(
+ url=self._build_url('limits'),
+ method='GET',
+ params=self._build_params(),
+ timeout=timeout,
+ )
+ result = response_to_dict(response)
+ return LimitsResponse.model_validate(result).data
async def update_limits(
self,
diff --git a/src/apify_client/_resource_clients/webhook.py b/src/apify_client/_resource_clients/webhook.py
index 3067e42b..171364b6 100644
--- a/src/apify_client/_resource_clients/webhook.py
+++ b/src/apify_client/_resource_clients/webhook.py
@@ -14,8 +14,7 @@
WebhookUpdate,
)
from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync
-from apify_client._utils import catch_not_found_or_throw, response_to_dict
-from apify_client.errors import ApifyApiError
+from apify_client._utils import response_to_dict
if TYPE_CHECKING:
from apify_client._models import WebhookEventType
@@ -123,7 +122,7 @@ def delete(self, *, timeout: Timeout = 'short') -> None:
"""
self._delete(timeout=timeout)
- def test(self, *, timeout: Timeout = 'medium') -> WebhookDispatch | None:
+ def test(self, *, timeout: Timeout = 'medium') -> WebhookDispatch:
"""Test a webhook.
Creates a webhook dispatch with a dummy payload.
@@ -135,22 +134,19 @@ def test(self, *, timeout: Timeout = 'medium') -> WebhookDispatch | None:
Returns:
The webhook dispatch created by the test.
- """
- try:
- response = self._http_client.call(
- url=self._build_url('test'),
- method='POST',
- params=self._build_params(),
- timeout=timeout,
- )
-
- result = response_to_dict(response)
- return TestWebhookResponse.model_validate(result).data
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
+ Raises:
+ NotFoundError: If the webhook does not exist.
+ """
+ response = self._http_client.call(
+ url=self._build_url('test'),
+ method='POST',
+ params=self._build_params(),
+ timeout=timeout,
+ )
- return None
+ result = response_to_dict(response)
+ return TestWebhookResponse.model_validate(result).data
def dispatches(self) -> WebhookDispatchCollectionClient:
"""Get dispatches of the webhook.
@@ -266,7 +262,7 @@ async def delete(self, *, timeout: Timeout = 'short') -> None:
"""
await self._delete(timeout=timeout)
- async def test(self, *, timeout: Timeout = 'medium') -> WebhookDispatch | None:
+ async def test(self, *, timeout: Timeout = 'medium') -> WebhookDispatch:
"""Test a webhook.
Creates a webhook dispatch with a dummy payload.
@@ -278,22 +274,19 @@ async def test(self, *, timeout: Timeout = 'medium') -> WebhookDispatch | None:
Returns:
The webhook dispatch created by the test.
- """
- try:
- response = await self._http_client.call(
- url=self._build_url('test'),
- method='POST',
- params=self._build_params(),
- timeout=timeout,
- )
-
- result = response_to_dict(response)
- return TestWebhookResponse.model_validate(result).data
- except ApifyApiError as exc:
- catch_not_found_or_throw(exc)
+ Raises:
+ NotFoundError: If the webhook does not exist.
+ """
+ response = await self._http_client.call(
+ url=self._build_url('test'),
+ method='POST',
+ params=self._build_params(),
+ timeout=timeout,
+ )
- return None
+ result = response_to_dict(response)
+ return TestWebhookResponse.model_validate(result).data
def dispatches(self) -> WebhookDispatchCollectionClientAsync:
"""Get dispatches of the webhook.
diff --git a/src/apify_client/_utils.py b/src/apify_client/_utils.py
index 8ce9b171..d65ec93d 100644
--- a/src/apify_client/_utils.py
+++ b/src/apify_client/_utils.py
@@ -66,6 +66,18 @@ def catch_not_found_or_throw(exc: ApifyApiError) -> None:
raise exc
+def catch_not_found_for_resource_or_throw(exc: ApifyApiError, resource_id: str | None) -> None:
+ """Like `catch_not_found_or_throw`, but only suppress 404s when the client targets a specific resource by ID.
+
+ For chained clients without a `resource_id` (e.g. `run.dataset()`, `run.log()`), a 404 could mean either the
+ parent or the default sub-resource is missing — the API body cannot disambiguate — so the error propagates
+ rather than being swallowed.
+ """
+ if resource_id is None:
+ raise exc
+ catch_not_found_or_throw(exc)
+
+
def encode_key_value_store_record_value(value: Any, content_type: str | None = None) -> tuple[Any, str]:
"""Encode a value for storage in a key-value store record.
diff --git a/tests/unit/test_client_errors.py b/tests/unit/test_client_errors.py
index e7c87257..d86a476b 100644
--- a/tests/unit/test_client_errors.py
+++ b/tests/unit/test_client_errors.py
@@ -1,11 +1,12 @@
from __future__ import annotations
import json
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
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,
@@ -19,6 +20,8 @@
)
if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+
from pytest_httpserver import HTTPServer
from werkzeug import Request
@@ -39,13 +42,34 @@
b'}'
)
+_DATASET_FIXTURE = {
+ 'id': 'ds-1',
+ 'userId': 'u-1',
+ 'createdAt': '2026-01-01T00:00:00.000Z',
+ 'modifiedAt': '2026-01-01T00:00:00.000Z',
+ 'accessedAt': '2026-01-01T00:00:00.000Z',
+ 'itemCount': 0,
+ 'cleanItemCount': 0,
+ 'consoleUrl': 'https://console.apify.com/storage/datasets/ds-1',
+}
-@pytest.fixture
-def test_endpoint(httpserver: HTTPServer) -> str:
- httpserver.expect_request(_TEST_PATH).respond_with_json(
- {'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}}, status=400
- )
- return str(httpserver.url_for(_TEST_PATH))
+# Singleton sub-path endpoints — fetch a fixed path under an ID-identified parent (e.g. /schedules/{id}/log). A 404
+# always means the parent is missing, so `NotFoundError` propagates instead of collapsing to `None`.
+_SINGLETON_SUBPATH_404_CASES = [
+ pytest.param('/v2/schedules/missing/log', 'GET', lambda c: c.schedule('missing').get_log(), id='schedule_get_log'),
+ pytest.param('/v2/actor-tasks/missing/input', 'GET', lambda c: c.task('missing').get_input(), id='task_get_input'),
+ pytest.param(
+ '/v2/datasets/missing/statistics',
+ 'GET',
+ lambda c: c.dataset('missing').get_statistics(),
+ id='dataset_get_statistics',
+ ),
+ pytest.param('/v2/webhooks/missing/test', 'POST', lambda c: c.webhook('missing').test(), id='webhook_test'),
+]
+
+
+def _not_found_body() -> dict:
+ return {'error': {'type': 'record-not-found', 'message': 'not found'}}
def streaming_handler(_request: Request) -> Response:
@@ -58,6 +82,24 @@ def streaming_handler(_request: Request) -> Response:
)
+@pytest.fixture
+def sync_client(httpserver: HTTPServer) -> ApifyClient:
+ return ApifyClient(token='test', api_url=httpserver.url_for('/').removesuffix('/'))
+
+
+@pytest.fixture
+def async_client(httpserver: HTTPServer) -> ApifyClientAsync:
+ return ApifyClientAsync(token='test', api_url=httpserver.url_for('/').removesuffix('/'))
+
+
+@pytest.fixture
+def test_endpoint(httpserver: HTTPServer) -> str:
+ httpserver.expect_request(_TEST_PATH).respond_with_json(
+ {'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}}, status=400
+ )
+ return str(httpserver.url_for(_TEST_PATH))
+
+
def test_client_apify_api_error_with_data(test_endpoint: str) -> None:
"""Test that client correctly throws ApifyApiError with error data from response."""
client = ImpitHttpClient()
@@ -211,3 +253,132 @@ 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 test_direct_get_returns_none_on_404(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ httpserver.expect_request('/v2/datasets/missing').respond_with_json(_not_found_body(), status=404)
+ assert sync_client.dataset('missing').get() is None
+
+
+async def test_direct_get_returns_none_on_404_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
+ httpserver.expect_request('/v2/datasets/missing').respond_with_json(_not_found_body(), status=404)
+ assert await async_client.dataset('missing').get() is None
+
+
+def test_chained_get_raises_on_404(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ httpserver.expect_request('/v2/actor-runs/missing-run/dataset').respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ sync_client.run('missing-run').dataset().get()
+
+
+async def test_chained_get_raises_on_404_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
+ httpserver.expect_request('/v2/actor-runs/missing-run/dataset').respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ await async_client.run('missing-run').dataset().get()
+
+
+def test_actor_last_run_dataset_get_raises_on_404(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """404 covers missing actor, missing last_run, or missing dataset — all three are indistinguishable from the single
+ HTTP response (the client only hits the final URL), so `NotFoundError` propagates uniformly.
+ """
+ httpserver.expect_request('/v2/acts/actor-id/runs/last/dataset').respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ sync_client.actor('actor-id').last_run().dataset().get()
+
+
+async def test_actor_last_run_dataset_get_raises_on_404_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ httpserver.expect_request('/v2/acts/actor-id/runs/last/dataset').respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ await async_client.actor('actor-id').last_run().dataset().get()
+
+
+def test_actor_last_run_dataset_get_returns_dataset(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ httpserver.expect_request('/v2/acts/actor-id/runs/last/dataset').respond_with_json({'data': _DATASET_FIXTURE})
+ dataset = sync_client.actor('actor-id').last_run().dataset().get()
+ assert dataset is not None
+ assert dataset.id == 'ds-1'
+
+
+async def test_actor_last_run_dataset_get_returns_dataset_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ httpserver.expect_request('/v2/acts/actor-id/runs/last/dataset').respond_with_json({'data': _DATASET_FIXTURE})
+ dataset = await async_client.actor('actor-id').last_run().dataset().get()
+ assert dataset is not None
+ assert dataset.id == 'ds-1'
+
+
+def test_direct_delete_swallows_404(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ httpserver.expect_request('/v2/datasets/missing', method='DELETE').respond_with_json(_not_found_body(), status=404)
+ sync_client.dataset('missing').delete()
+
+
+async def test_direct_delete_swallows_404_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
+ httpserver.expect_request('/v2/datasets/missing', method='DELETE').respond_with_json(_not_found_body(), status=404)
+ await async_client.dataset('missing').delete()
+
+
+def test_chained_delete_raises_on_404(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ httpserver.expect_request('/v2/actor-runs/missing-run/dataset', method='DELETE').respond_with_json(
+ _not_found_body(), status=404
+ )
+ with pytest.raises(NotFoundError):
+ sync_client.run('missing-run').dataset().delete()
+
+
+async def test_chained_delete_raises_on_404_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
+ httpserver.expect_request('/v2/actor-runs/missing-run/dataset', method='DELETE').respond_with_json(
+ _not_found_body(), status=404
+ )
+ with pytest.raises(NotFoundError):
+ await async_client.run('missing-run').dataset().delete()
+
+
+def test_direct_log_get_returns_none_on_404(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ httpserver.expect_request('/v2/logs/missing').respond_with_json(_not_found_body(), status=404)
+ assert sync_client.log('missing').get() is None
+
+
+async def test_direct_log_get_returns_none_on_404_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
+ httpserver.expect_request('/v2/logs/missing').respond_with_json(_not_found_body(), status=404)
+ assert await async_client.log('missing').get() is None
+
+
+def test_chained_log_get_raises_on_404(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ httpserver.expect_request('/v2/actor-runs/missing-run/log').respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ sync_client.run('missing-run').log().get()
+
+
+async def test_chained_log_get_raises_on_404_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
+ httpserver.expect_request('/v2/actor-runs/missing-run/log').respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ await async_client.run('missing-run').log().get()
+
+
+@pytest.mark.parametrize(('path', 'method', 'call'), _SINGLETON_SUBPATH_404_CASES)
+def test_singleton_subpath_raises_on_404(
+ httpserver: HTTPServer,
+ sync_client: ApifyClient,
+ path: str,
+ method: str,
+ call: Callable[[ApifyClient], Any],
+) -> None:
+ httpserver.expect_request(path, method=method).respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ call(sync_client)
+
+
+@pytest.mark.parametrize(('path', 'method', 'call'), _SINGLETON_SUBPATH_404_CASES)
+async def test_singleton_subpath_raises_on_404_async(
+ httpserver: HTTPServer,
+ async_client: ApifyClientAsync,
+ path: str,
+ method: str,
+ call: Callable[[ApifyClientAsync], Awaitable[Any]],
+) -> None:
+ httpserver.expect_request(path, method=method).respond_with_json(_not_found_body(), status=404)
+ with pytest.raises(NotFoundError):
+ await call(async_client)