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)