From d912c738b644f7926cf9f63d146ac59a28f1668e Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 19 Oct 2026 16:31:01 +0200 Subject: [PATCH 01/36] feat: add `standby_request` for calling Standby Actors --- docs/02_concepts/14_standby_actors.mdx | 45 +++++ docs/02_concepts/code/14_standby_async.py | 20 +++ docs/02_concepts/code/14_standby_sync.py | 20 +++ src/apify_client/_resource_clients/actor.py | 130 +++++++++++++- tests/unit/test_actor_standby_request.py | 190 ++++++++++++++++++++ 5 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 docs/02_concepts/14_standby_actors.mdx create mode 100644 docs/02_concepts/code/14_standby_async.py create mode 100644 docs/02_concepts/code/14_standby_sync.py create mode 100644 tests/unit/test_actor_standby_request.py diff --git a/docs/02_concepts/14_standby_actors.mdx b/docs/02_concepts/14_standby_actors.mdx new file mode 100644 index 00000000..3a8afdaf --- /dev/null +++ b/docs/02_concepts/14_standby_actors.mdx @@ -0,0 +1,45 @@ +--- +id: standby-actors +title: Standby Actors +description: Send HTTP requests to Actors running in Standby mode. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CodeBlock from '@theme/CodeBlock'; +import ApiLink from '@theme/ApiLink'; + +import StandbyAsyncExample from '!!raw-loader!./code/14_standby_async.py'; +import StandbySyncExample from '!!raw-loader!./code/14_standby_sync.py'; + +An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.standby_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: + + + + + {StandbyAsyncExample} + + + + + {StandbySyncExample} + + + + +The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store) or its README. + +## How the request is sent + +Note that: + +- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor doesn't have Standby mode enabled, the method raises an `ApifyClientError`. +- The path is always resolved against that URL, so a path can't send the request to another host. +- The request carries your API token in the `Authorization` header. The Standby router consumes the header, so the token doesn't reach the Actor's code. +- The request body is sent uncompressed, since the Actor's server may not decode a compressed body. + +## Timeouts, retries, and errors + +When no Standby run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). + +An error status from the Actor's server raises an `ApifyApiError`, like an error from the API. Server errors and rate-limit responses are retried with the client's retry settings, so a request that the server failed to answer may reach it more than once. If your endpoint isn't safe to call twice, lower `max_retries` on the client. For details, see [Retries](./05_retries.mdx) and [Error handling](./04_error_handling.mdx). diff --git a/docs/02_concepts/code/14_standby_async.py b/docs/02_concepts/code/14_standby_async.py new file mode 100644 index 00000000..c3fa0ab3 --- /dev/null +++ b/docs/02_concepts/code/14_standby_async.py @@ -0,0 +1,20 @@ +from apify_client import ApifyClientAsync + +TOKEN = 'MY-APIFY-TOKEN' + + +async def main() -> None: + apify_client = ApifyClientAsync(TOKEN) + actor_client = apify_client.actor('username/actor-name') + + # Send a GET request to the /search endpoint of the Actor's Standby server. + response = await actor_client.standby_request('/search', params={'q': 'apify'}) + print(response.json()) + + # Send a POST request with a JSON body. + response = await actor_client.standby_request( + '/items', + method='POST', + json={'url': 'https://apify.com'}, + ) + print(response.status_code) diff --git a/docs/02_concepts/code/14_standby_sync.py b/docs/02_concepts/code/14_standby_sync.py new file mode 100644 index 00000000..5aca6151 --- /dev/null +++ b/docs/02_concepts/code/14_standby_sync.py @@ -0,0 +1,20 @@ +from apify_client import ApifyClient + +TOKEN = 'MY-APIFY-TOKEN' + + +def main() -> None: + apify_client = ApifyClient(TOKEN) + actor_client = apify_client.actor('username/actor-name') + + # Send a GET request to the /search endpoint of the Actor's Standby server. + response = actor_client.standby_request('/search', params={'q': 'apify'}) + print(response.json()) + + # Send a POST request with a JSON body. + response = actor_client.standby_request( + '/items', + method='POST', + json={'url': 'https://apify.com'}, + ) + print(response.status_code) diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 6bd06fbb..f9dc59db 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -32,6 +32,7 @@ start_waiting_for_resources, start_waiting_for_resources_async, ) +from apify_client.errors import ApifyClientError if TYPE_CHECKING: from datetime import timedelta @@ -55,7 +56,8 @@ WebhookCollectionClient, WebhookCollectionClientAsync, ) - from apify_client.types import Timeout, WebhooksList + from apify_client.http_clients import HttpResponse + from apify_client.types import JsonSerializable, Timeout, WebhooksList _PricingInfo = ( PayPerEventActorPricingInfo @@ -86,6 +88,7 @@ def __init__( resource_path=resource_path, **kwargs, ) + self._standby_url: str | None = None def get(self, *, timeout: Timeout = 'short') -> ActorResource | None: """Retrieve the Actor. @@ -625,6 +628,68 @@ def validate_input( return True + def standby_request( + self, + path: str = '/', + *, + method: str = 'GET', + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + data: str | bytes | None = None, + json: JsonSerializable | None = None, + timeout: Timeout = 'long', + ) -> HttpResponse: + """Send an HTTP request to the Actor's Standby server. + + An Actor in Standby mode runs a web server that is called over HTTP, without starting a run for each call. The + server's URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on + this client. The request carries the client's token, which the Standby router consumes, so it never reaches + the Actor's code. The first request to an idle Actor waits for the platform to start a Standby run, which is + why the timeout defaults to `long`. + + A server error or a rate-limit response is retried like an API call, so the server may receive the same + request more than once. Request bodies are sent uncompressed. + + https://docs.apify.com/platform/actors/running/standby + + Args: + path: The path on the Actor's server, for example `/search`. It is always resolved against the Standby URL, + so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. + method: HTTP method of the request. + params: Query parameters of the request. + headers: Additional headers of the request. + data: Raw request body. Cannot be used together with `json`. + json: JSON-serializable request body. Cannot be used together with `data`. + timeout: Timeout for the HTTP request. + + Returns: + The response of the Actor's server. + + Raises: + ApifyClientError: If the Actor does not have Standby mode enabled. + ApifyApiError: If the Actor does not exist, or the server responds with an error status. + """ + if self._standby_url is None: + response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} does not have Standby mode enabled.') + self._standby_url = actor.standby_url + + # The client compresses large bodies by default, and a Standby server is not expected to decode them. + if data is not None or json is not None: + headers = self._http_client._merge_headers({'Content-Encoding': 'identity'}, headers) # noqa: SLF001 + + return self._http_client.call( + url=f'{self._standby_url.rstrip("/")}/{path.lstrip("/")}', + method=method, + params=params, + headers=headers, + data=data, + json=json, + timeout=timeout, + ) + @docs_group('Resource clients') class ActorClientAsync(ResourceClientAsync): @@ -646,6 +711,7 @@ def __init__( resource_path=resource_path, **kwargs, ) + self._standby_url: str | None = None async def get(self, *, timeout: Timeout = 'short') -> ActorResource | None: """Retrieve the Actor. @@ -1185,3 +1251,65 @@ async def validate_input( ) return True + + async def standby_request( + self, + path: str = '/', + *, + method: str = 'GET', + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + data: str | bytes | None = None, + json: JsonSerializable | None = None, + timeout: Timeout = 'long', + ) -> HttpResponse: + """Send an HTTP request to the Actor's Standby server. + + An Actor in Standby mode runs a web server that is called over HTTP, without starting a run for each call. The + server's URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on + this client. The request carries the client's token, which the Standby router consumes, so it never reaches + the Actor's code. The first request to an idle Actor waits for the platform to start a Standby run, which is + why the timeout defaults to `long`. + + A server error or a rate-limit response is retried like an API call, so the server may receive the same + request more than once. Request bodies are sent uncompressed. + + https://docs.apify.com/platform/actors/running/standby + + Args: + path: The path on the Actor's server, for example `/search`. It is always resolved against the Standby URL, + so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. + method: HTTP method of the request. + params: Query parameters of the request. + headers: Additional headers of the request. + data: Raw request body. Cannot be used together with `json`. + json: JSON-serializable request body. Cannot be used together with `data`. + timeout: Timeout for the HTTP request. + + Returns: + The response of the Actor's server. + + Raises: + ApifyClientAsyncError: If the Actor does not have Standby mode enabled. + ApifyApiError: If the Actor does not exist, or the server responds with an error status. + """ + if self._standby_url is None: + response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} does not have Standby mode enabled.') + self._standby_url = actor.standby_url + + # The client compresses large bodies by default, and a Standby server is not expected to decode them. + if data is not None or json is not None: + headers = self._http_client._merge_headers({'Content-Encoding': 'identity'}, headers) # noqa: SLF001 + + return await self._http_client.call( + url=f'{self._standby_url.rstrip("/")}/{path.lstrip("/")}', + method=method, + params=params, + headers=headers, + data=data, + json=json, + timeout=timeout, + ) diff --git a/tests/unit/test_actor_standby_request.py b/tests/unit/test_actor_standby_request.py new file mode 100644 index 00000000..c9567260 --- /dev/null +++ b/tests/unit/test_actor_standby_request.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest +from werkzeug import Request, Response + +from apify_client.errors import ApifyClientError, NotFoundError + +if TYPE_CHECKING: + from pytest_httpserver import HTTPServer + + from apify_client import ApifyClient, ApifyClientAsync + +pytestmark = pytest.mark.usefixtures('http_client_classes') + +_MOCKED_ACTOR_ID = 'test_actor_id' +_ACTOR_PATH = f'/v2/actors/{_MOCKED_ACTOR_ID}' +_STANDBY_PREFIX = '/standby' + + +def actor_response(standby_url: str | None) -> dict[str, Any]: + return { + 'data': { + 'id': _MOCKED_ACTOR_ID, + 'userId': 'test_user_id', + 'name': 'test-actor', + 'username': 'test-user', + 'isPublic': False, + 'createdAt': '2026-08-01T10:00:00.000Z', + 'modifiedAt': '2026-08-01T10:00:00.000Z', + 'stats': {}, + 'versions': [], + 'defaultRunOptions': {}, + 'standbyUrl': standby_url, + } + } + + +def expect_standby_actor(httpserver: HTTPServer) -> None: + standby_url = httpserver.url_for(_STANDBY_PREFIX) + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(standby_url)) + + +def capture(captured: list[Request]) -> Any: + def handler(request: Request) -> Response: + captured.append(request) + return Response(json.dumps({'ok': True}), status=200, mimetype='application/json') + + return handler + + +def test_standby_request_sends_request_to_standby_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """The request goes to the Standby URL with the method, path, query, body, and the client's token.""" + expect_standby_actor(httpserver) + captured: list[Request] = [] + httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_handler(capture(captured)) + + response = sync_client.actor(_MOCKED_ACTOR_ID).standby_request( + '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} + ) + + assert response.status_code == 200 + assert response.json() == {'ok': True} + assert len(captured) == 1 + assert captured[0].args.to_dict() == {'q': 'apify'} + assert json.loads(captured[0].get_data()) == {'limit': 5} + assert captured[0].headers['Authorization'] == 'Bearer test' + + +async def test_standby_request_sends_request_to_standby_url_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_standby_request_sends_request_to_standby_url`.""" + expect_standby_actor(httpserver) + captured: list[Request] = [] + httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_handler(capture(captured)) + + response = await async_client.actor(_MOCKED_ACTOR_ID).standby_request( + '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} + ) + + assert response.status_code == 200 + assert response.json() == {'ok': True} + assert len(captured) == 1 + assert captured[0].args.to_dict() == {'q': 'apify'} + assert json.loads(captured[0].get_data()) == {'limit': 5} + assert captured[0].headers['Authorization'] == 'Bearer test' + + +def test_standby_request_fetches_standby_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """Later calls on the same client reuse the Standby URL fetched by the first one.""" + expect_standby_actor(httpserver) + httpserver.expect_request(f'{_STANDBY_PREFIX}/', method='GET').respond_with_json({'ok': True}) + actor_client = sync_client.actor(_MOCKED_ACTOR_ID) + + actor_client.standby_request() + actor_client.standby_request() + + actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] + assert len(actor_gets) == 1 + + +async def test_standby_request_fetches_standby_url_once_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_standby_request_fetches_standby_url_once`.""" + expect_standby_actor(httpserver) + httpserver.expect_request(f'{_STANDBY_PREFIX}/', method='GET').respond_with_json({'ok': True}) + actor_client = async_client.actor(_MOCKED_ACTOR_ID) + + await actor_client.standby_request() + await actor_client.standby_request() + + actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] + assert len(actor_gets) == 1 + + +@pytest.mark.parametrize( + 'path', + [ + pytest.param('//evil.example/steal', id='protocol-relative'), + pytest.param('@evil.example/steal', id='userinfo'), + pytest.param('https://evil.example/steal', id='absolute url'), + ], +) +def test_standby_request_path_stays_on_standby_host( + httpserver: HTTPServer, sync_client: ApifyClient, path: str +) -> None: + """A path that looks like another host is still sent to the Standby server, as a path on it.""" + expect_standby_actor(httpserver) + httpserver.no_handler_status_code = 200 + + response = sync_client.actor(_MOCKED_ACTOR_ID).standby_request(path) + + assert response.status_code == 200 + standby_requests = [request for request, _ in httpserver.log if request.path.startswith(f'{_STANDBY_PREFIX}/')] + assert len(standby_requests) == 1 + + +def test_standby_request_sends_body_uncompressed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A body large enough for the client to compress is sent as is, since Standby servers may not decode it.""" + expect_standby_actor(httpserver) + captured: list[Request] = [] + httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_handler(capture(captured)) + payload = {'items': ['x' * 100] * 100} + + sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', json=payload) + + assert len(captured) == 1 + assert captured[0].headers.get('Content-Encoding') == 'identity' + assert json.loads(captured[0].get_data()) == payload + + +def test_standby_request_without_standby_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An Actor without Standby mode enabled raises an error before any request to a Standby server.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) + + with pytest.raises(ApifyClientError, match='does not have Standby mode enabled'): + sync_client.actor(_MOCKED_ACTOR_ID).standby_request() + + +async def test_standby_request_without_standby_mode_raises_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_standby_request_without_standby_mode_raises`.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) + + with pytest.raises(ApifyClientError, match='does not have Standby mode enabled'): + await async_client.actor(_MOCKED_ACTOR_ID).standby_request() + + +def test_standby_request_missing_actor_raises_not_found(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A missing Actor raises `NotFoundError`.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json( + {'error': {'type': 'record-not-found', 'message': 'Actor was not found'}}, status=404 + ) + + with pytest.raises(NotFoundError): + sync_client.actor(_MOCKED_ACTOR_ID).standby_request() + + +def test_standby_request_error_status_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An error status from the Standby server raises the matching `ApifyApiError` subclass.""" + expect_standby_actor(httpserver) + httpserver.expect_request(f'{_STANDBY_PREFIX}/missing', method='GET').respond_with_data('Not here', status=404) + + with pytest.raises(NotFoundError, match='Not here'): + sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/missing') From 7b85f2578c2a20a20bc7a0ee9bdba419171f8025 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 19 Oct 2026 16:49:28 +0200 Subject: [PATCH 02/36] chore: keep error class names intact when syncing async docstrings --- scripts/_utils.py | 2 +- src/apify_client/_resource_clients/actor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/_utils.py b/scripts/_utils.py index 48612c54..5be77090 100644 --- a/scripts/_utils.py +++ b/scripts/_utils.py @@ -20,7 +20,7 @@ """Path to the source code of the apify_client package.""" _SUBSTITUTIONS = [ - (re.compile(r'Client'), 'ClientAsync'), + (re.compile(r'Client(?!Error)'), 'ClientAsync'), (re.compile(r'\bsynchronously\b'), 'asynchronously'), (re.compile(r'\bSynchronously\b'), 'Asynchronously'), (re.compile(r'\bsynchronous\b'), 'asynchronous'), diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index f9dc59db..40f46314 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -1290,7 +1290,7 @@ async def standby_request( The response of the Actor's server. Raises: - ApifyClientAsyncError: If the Actor does not have Standby mode enabled. + ApifyClientError: If the Actor does not have Standby mode enabled. ApifyApiError: If the Actor does not exist, or the server responds with an error status. """ if self._standby_url is None: From f7f6d0a30a8665e569887c5c2d971762e8e87ab0 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 19 Oct 2026 16:49:30 +0200 Subject: [PATCH 03/36] docs: document param conversion and ValueError in `standby_request` --- src/apify_client/_resource_clients/actor.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 40f46314..fa2d938a 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -656,7 +656,9 @@ def standby_request( path: The path on the Actor's server, for example `/search`. It is always resolved against the Standby URL, so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. method: HTTP method of the request. - params: Query parameters of the request. + params: Query parameters of the request, converted the same way as for API calls: booleans become `true` + or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string + in UTC, and `None` values are left out. headers: Additional headers of the request. data: Raw request body. Cannot be used together with `json`. json: JSON-serializable request body. Cannot be used together with `data`. @@ -668,6 +670,7 @@ def standby_request( Raises: ApifyClientError: If the Actor does not have Standby mode enabled. ApifyApiError: If the Actor does not exist, or the server responds with an error status. + ValueError: If both `data` and `json` are passed. """ if self._standby_url is None: response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') @@ -1280,7 +1283,9 @@ async def standby_request( path: The path on the Actor's server, for example `/search`. It is always resolved against the Standby URL, so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. method: HTTP method of the request. - params: Query parameters of the request. + params: Query parameters of the request, converted the same way as for API calls: booleans become `true` + or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string + in UTC, and `None` values are left out. headers: Additional headers of the request. data: Raw request body. Cannot be used together with `json`. json: JSON-serializable request body. Cannot be used together with `data`. @@ -1292,6 +1297,7 @@ async def standby_request( Raises: ApifyClientError: If the Actor does not have Standby mode enabled. ApifyApiError: If the Actor does not exist, or the server responds with an error status. + ValueError: If both `data` and `json` are passed. """ if self._standby_url is None: response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') From 181ac08464860023901ba0c876375a5a99c108db Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 19 Oct 2026 16:49:31 +0200 Subject: [PATCH 04/36] test: cover more `standby_request` body and header cases --- tests/unit/test_actor_standby_request.py | 72 +++++++++++++++++++----- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_actor_standby_request.py b/tests/unit/test_actor_standby_request.py index c9567260..84f42944 100644 --- a/tests/unit/test_actor_standby_request.py +++ b/tests/unit/test_actor_standby_request.py @@ -1,15 +1,16 @@ from __future__ import annotations +import gzip import json from typing import TYPE_CHECKING, Any import pytest -from werkzeug import Request, Response from apify_client.errors import ApifyClientError, NotFoundError if TYPE_CHECKING: from pytest_httpserver import HTTPServer + from werkzeug import Request from apify_client import ApifyClient, ApifyClientAsync @@ -43,19 +44,14 @@ def expect_standby_actor(httpserver: HTTPServer) -> None: httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(standby_url)) -def capture(captured: list[Request]) -> Any: - def handler(request: Request) -> Response: - captured.append(request) - return Response(json.dumps({'ok': True}), status=200, mimetype='application/json') - - return handler +def standby_requests(httpserver: HTTPServer) -> list[Request]: + return [request for request, _ in httpserver.log if request.path.startswith(f'{_STANDBY_PREFIX}/')] def test_standby_request_sends_request_to_standby_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """The request goes to the Standby URL with the method, path, query, body, and the client's token.""" expect_standby_actor(httpserver) - captured: list[Request] = [] - httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_handler(capture(captured)) + httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_json({'ok': True}) response = sync_client.actor(_MOCKED_ACTOR_ID).standby_request( '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} @@ -63,6 +59,7 @@ def test_standby_request_sends_request_to_standby_url(httpserver: HTTPServer, sy assert response.status_code == 200 assert response.json() == {'ok': True} + captured = standby_requests(httpserver) assert len(captured) == 1 assert captured[0].args.to_dict() == {'q': 'apify'} assert json.loads(captured[0].get_data()) == {'limit': 5} @@ -74,8 +71,7 @@ async def test_standby_request_sends_request_to_standby_url_async( ) -> None: """Async variant of `test_standby_request_sends_request_to_standby_url`.""" expect_standby_actor(httpserver) - captured: list[Request] = [] - httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_handler(capture(captured)) + httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_json({'ok': True}) response = await async_client.actor(_MOCKED_ACTOR_ID).standby_request( '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} @@ -83,6 +79,7 @@ async def test_standby_request_sends_request_to_standby_url_async( assert response.status_code == 200 assert response.json() == {'ok': True} + captured = standby_requests(httpserver) assert len(captured) == 1 assert captured[0].args.to_dict() == {'q': 'apify'} assert json.loads(captured[0].get_data()) == {'limit': 5} @@ -135,24 +132,69 @@ def test_standby_request_path_stays_on_standby_host( response = sync_client.actor(_MOCKED_ACTOR_ID).standby_request(path) assert response.status_code == 200 - standby_requests = [request for request, _ in httpserver.log if request.path.startswith(f'{_STANDBY_PREFIX}/')] - assert len(standby_requests) == 1 + assert len(standby_requests(httpserver)) == 1 def test_standby_request_sends_body_uncompressed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """A body large enough for the client to compress is sent as is, since Standby servers may not decode it.""" expect_standby_actor(httpserver) - captured: list[Request] = [] - httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_handler(capture(captured)) + httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) payload = {'items': ['x' * 100] * 100} sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', json=payload) + captured = standby_requests(httpserver) assert len(captured) == 1 assert captured[0].headers.get('Content-Encoding') == 'identity' assert json.loads(captured[0].get_data()) == payload +async def test_standby_request_sends_body_uncompressed_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_standby_request_sends_body_uncompressed`.""" + expect_standby_actor(httpserver) + httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + payload = {'items': ['x' * 100] * 100} + + await async_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', json=payload) + + captured = standby_requests(httpserver) + assert len(captured) == 1 + assert captured[0].headers.get('Content-Encoding') == 'identity' + assert json.loads(captured[0].get_data()) == payload + + +def test_standby_request_without_body_sends_no_content_encoding( + httpserver: HTTPServer, sync_client: ApifyClient +) -> None: + """A request without a body carries no `Content-Encoding` header.""" + expect_standby_actor(httpserver) + httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='GET').respond_with_json({'ok': True}) + + sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/search') + + captured = standby_requests(httpserver) + assert len(captured) == 1 + assert 'Content-Encoding' not in captured[0].headers + + +def test_standby_request_keeps_caller_content_encoding(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A `Content-Encoding` header passed by the caller is sent as given, with the body untouched.""" + expect_standby_actor(httpserver) + httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + body = gzip.compress(b'x' * 2000) + + sync_client.actor(_MOCKED_ACTOR_ID).standby_request( + '/items', method='POST', headers={'content-encoding': 'gzip'}, data=body + ) + + captured = standby_requests(httpserver) + assert len(captured) == 1 + assert captured[0].headers.get('Content-Encoding') == 'gzip' + assert captured[0].get_data() == body + + def test_standby_request_without_standby_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """An Actor without Standby mode enabled raises an error before any request to a Standby server.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) From 3f3d397f009a68e92a9c00020b8799a8754f79a9 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 19 Oct 2026 20:33:45 +0200 Subject: [PATCH 05/36] docs: shorten the Standby Actors concept page --- docs/02_concepts/14_standby_actors.mdx | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/docs/02_concepts/14_standby_actors.mdx b/docs/02_concepts/14_standby_actors.mdx index 3a8afdaf..36af4121 100644 --- a/docs/02_concepts/14_standby_actors.mdx +++ b/docs/02_concepts/14_standby_actors.mdx @@ -12,7 +12,7 @@ import ApiLink from '@theme/ApiLink'; import StandbyAsyncExample from '!!raw-loader!./code/14_standby_async.py'; import StandbySyncExample from '!!raw-loader!./code/14_standby_sync.py'; -An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.standby_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: +An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standby) runs a web server that you call over HTTP. To send it a request, use `ActorClient.standby_request` with a path on the Actor's server: @@ -27,19 +27,6 @@ An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standb -The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store) or its README. +The client looks up the Actor's Standby URL and authenticates the request with your API token. For how the platform handles the token, see [How do I authenticate my requests](https://docs.apify.com/platform/actors/running/standby#how-do-i-authenticate-my-requests). -## How the request is sent - -Note that: - -- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor doesn't have Standby mode enabled, the method raises an `ApifyClientError`. -- The path is always resolved against that URL, so a path can't send the request to another host. -- The request carries your API token in the `Authorization` header. The Standby router consumes the header, so the token doesn't reach the Actor's code. -- The request body is sent uncompressed, since the Actor's server may not decode a compressed body. - -## Timeouts, retries, and errors - -When no Standby run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). - -An error status from the Actor's server raises an `ApifyApiError`, like an error from the API. Server errors and rate-limit responses are retried with the client's retry settings, so a request that the server failed to answer may reach it more than once. If your endpoint isn't safe to call twice, lower `max_retries` on the client. For details, see [Retries](./05_retries.mdx) and [Error handling](./04_error_handling.mdx). +The first request to an idle Actor can take a few minutes, so the method uses the `long` [timeout](./11_timeouts.mdx) tier. Server errors and rate-limit responses are [retried](./05_retries.mdx), so an endpoint that isn't safe to call twice may get the same request more than once. From 8fc1b166ca7b0caa763322c35e3af0e555bb4305 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 19 Oct 2026 20:37:41 +0200 Subject: [PATCH 06/36] docs: link the platform docs for Standby authentication --- docs/02_concepts/14_standby_actors.mdx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/02_concepts/14_standby_actors.mdx b/docs/02_concepts/14_standby_actors.mdx index 36af4121..604ae36a 100644 --- a/docs/02_concepts/14_standby_actors.mdx +++ b/docs/02_concepts/14_standby_actors.mdx @@ -12,7 +12,7 @@ import ApiLink from '@theme/ApiLink'; import StandbyAsyncExample from '!!raw-loader!./code/14_standby_async.py'; import StandbySyncExample from '!!raw-loader!./code/14_standby_sync.py'; -An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standby) runs a web server that you call over HTTP. To send it a request, use `ActorClient.standby_request` with a path on the Actor's server: +An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.standby_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: @@ -27,6 +27,19 @@ An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standb -The client looks up the Actor's Standby URL and authenticates the request with your API token. For how the platform handles the token, see [How do I authenticate my requests](https://docs.apify.com/platform/actors/running/standby#how-do-i-authenticate-my-requests). +The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store) or its README. -The first request to an idle Actor can take a few minutes, so the method uses the `long` [timeout](./11_timeouts.mdx) tier. Server errors and rate-limit responses are [retried](./05_retries.mdx), so an endpoint that isn't safe to call twice may get the same request more than once. +## How the request is sent + +Note that: + +- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor doesn't have Standby mode enabled, the method raises an `ApifyClientError`. +- The path is always resolved against that URL, so a path can't send the request to another host. +- The request carries your API token in the `Authorization` header. For how the platform handles it, see [How do I authenticate my requests](https://docs.apify.com/platform/actors/running/standby#how-do-i-authenticate-my-requests). +- The request body is sent uncompressed, since the Actor's server may not decode a compressed body. + +## Timeouts, retries, and errors + +When no Standby run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). + +An error status from the Actor's server raises an `ApifyApiError`, like an error from the API. Server errors and rate-limit responses are retried with the client's retry settings, so a request that the server failed to answer may reach it more than once. If your endpoint isn't safe to call twice, lower `max_retries` on the client. For details, see [Retries](./05_retries.mdx) and [Error handling](./04_error_handling.mdx). From 4b61d8760a21cb2d2af3aa20f97b1a0c846647a6 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 20 Oct 2026 09:06:02 +0200 Subject: [PATCH 07/36] feat: add per-request `compression` argument to HTTP clients --- src/apify_client/http_clients/_base.py | 26 +++++++++++----- tests/unit/test_http_clients.py | 38 ++++++++++++++++++++++++ tests/unit/test_pluggable_http_client.py | 5 ++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index b06d542c..9f8616f4 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -312,6 +312,7 @@ def _prepare_request_call( params: dict[str, Any] | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: JsonSerializable | None = None, + compression: HttpCompressor | bool = True, ) -> tuple[dict[str, str], dict[str, Any] | None, bytes | StreamedRequestBody | None]: """Prepare headers, params, and body for an HTTP request. @@ -319,11 +320,11 @@ def _prepare_request_call( JSON body. Header names are treated case-insensitively and per-request values win over the client defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one. - The body is compressed unless a `Content-Encoding` header is already set, the body is smaller than + The body is compressed with the client's compressor, or with the one passed in `compression`, unless + `compression` is `False`, a `Content-Encoding` header is already set, the body is smaller than `MIN_COMPRESSION_SIZE`, or its content type says the payload is already compressed. A caller-supplied `Content-Encoding` is forwarded verbatim, which is how a pre-encoded body is uploaded - including one in - an encoding the client ships no compressor for. `Content-Encoding: identity` therefore opts a single - request out of compression. + an encoding the client ships no compressor for. A body streamed from a file-like object, an iterable of byte chunks, or a streamed response is wrapped in `StreamedRequestBody` and never compressed, since its chunks go out as they are produced. A caller-supplied @@ -353,13 +354,15 @@ def _prepare_request_call( # A caller-supplied encoding says the body arrives already encoded, so compressing it here would # both mislabel it and waste the work. + compressor = self._http_compressor if compression is True else compression if ( - self._get_header(headers, 'content-encoding') is None + compressor is not False + and self._get_header(headers, 'content-encoding') is None and len(content) >= MIN_COMPRESSION_SIZE and is_compressible_content_type(self._get_header(headers, 'content-type')) ): - content = self._http_compressor.compress(content) - headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding}) + content = compressor.compress(content) + headers = self._merge_headers(headers, {'Content-Encoding': compressor.content_encoding}) elif data is not None: # Without this the request would go out with no body at all, losing the payload without a word. @@ -509,6 +512,7 @@ def call( json: JsonSerializable | None = None, stream: bool | None = None, timeout: Timeout = 'medium', + compression: HttpCompressor | bool = True, ) -> HttpResponse: """Make an HTTP request with automatic retry and exponential backoff. @@ -526,6 +530,8 @@ def call( timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for preconfigured timeouts. A `timedelta` overrides it for this call (capped at `timeout_max`), and `no_timeout` disables the timeout entirely. + compression: How to compress the request body. `True` uses the client's compressor, an `HttpCompressor` + is used for this request only, and `False` sends the body uncompressed. Returns: The HTTP response object. @@ -545,6 +551,7 @@ def call( params=params, data=data, json=json, + compression=compression, ) return self._retry_with_exp_backoff( @@ -720,6 +727,7 @@ async def call( json: JsonSerializable | None = None, stream: bool | None = None, timeout: Timeout = 'medium', + compression: HttpCompressor | bool = True, ) -> HttpResponse: """Make an HTTP request with automatic retry and exponential backoff. @@ -737,6 +745,8 @@ async def call( timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for preconfigured timeouts. A `timedelta` overrides it for this call (capped at `timeout_max`), and `no_timeout` disables the timeout entirely. + compression: How to compress the request body. `True` uses the client's compressor, an `HttpCompressor` + is used for this request only, and `False` sends the body uncompressed. Returns: The HTTP response object. @@ -755,13 +765,14 @@ async def call( # offload preparation to a worker thread whenever there is something to compress. A body the # client sends as it is costs less to prepare inline than the hop itself. A `json` body always # hops, as its size is only known once serialized. - if json is not None or self._is_body_worth_compressing(data): + if json is not None or (compression is not False and self._is_body_worth_compressing(data)): prepared_headers, prepared_params, content = await asyncio.to_thread( self._prepare_request_call, headers=headers, params=params, data=data, json=json, + compression=compression, ) else: prepared_headers, prepared_params, content = self._prepare_request_call( @@ -769,6 +780,7 @@ async def call( params=params, data=data, json=json, + compression=compression, ) return await self._retry_with_exp_backoff( diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 00d07b9a..778651a5 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -783,6 +783,28 @@ def test_prepare_request_call_skips_compression_below_threshold(compressor_case: assert not any(key.lower() == 'content-encoding' for key in headers) +def test_prepare_request_call_skips_compression_when_disabled() -> None: + """With `compression=False`, a body above the threshold is sent verbatim with no `Content-Encoding`.""" + client = ConcreteHttpClient() + body = b'x' * MIN_COMPRESSION_SIZE + + headers, _params, data = client._prepare_request_call(data=body, compression=False) + + assert data == body + assert not any(key.lower() == 'content-encoding' for key in headers) + + +def test_prepare_request_call_uses_per_request_compressor() -> None: + """A compressor passed in `compression` is used for the request in place of the client's compressor.""" + client = ConcreteHttpClient(http_compressor=GzipHttpCompressor()) + body = b'x' * MIN_COMPRESSION_SIZE + + headers, _params, data = client._prepare_request_call(data=body, compression=BrotliHttpCompressor()) + + assert headers['Content-Encoding'] == 'br' + assert brotli.decompress(data) == body + + def test_prepare_request_call_compresses_bytearray_data(compressor_case: tuple) -> None: """A `bytearray` body above the threshold is compressed without error (regression: needs bytes conversion).""" compressor, content_encoding, decompress = compressor_case @@ -1219,6 +1241,22 @@ async def test_async_call_skips_thread_offload_for_a_body_it_cannot_compress( spy.assert_not_called() +async def test_async_call_skips_thread_offload_when_compression_is_disabled( + http_client_async_class: type[HttpClientAsync], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A raw body is prepared inline when compression is disabled, whatever its size.""" + client = http_client_async_class(token='test_token') + monkeypatch.setattr(client, 'send_request', AsyncMock(return_value=Mock(status_code=200))) + spy = _to_thread_spy(monkeypatch) + + await client.call( + method='PUT', url='https://api.test.com/endpoint', data=b'x' * MIN_COMPRESSION_SIZE, compression=False + ) + + spy.assert_not_called() + + class StreamingTransport(HttpClient): """A transport that drains every body it is handed and answers from a script of statuses and exceptions.""" diff --git a/tests/unit/test_pluggable_http_client.py b/tests/unit/test_pluggable_http_client.py index edbc6c0a..75387db4 100644 --- a/tests/unit/test_pluggable_http_client.py +++ b/tests/unit/test_pluggable_http_client.py @@ -35,6 +35,7 @@ from pytest_httpserver import HTTPServer + from apify_client.http_compressors import HttpCompressor from apify_client.types import StreamedBodySource, Timeout @@ -99,6 +100,7 @@ def call( json: Any = None, stream: bool | None = None, timeout: Timeout = 'medium', + compression: HttpCompressor | bool = True, ) -> HttpResponse: self.calls.append( { @@ -110,6 +112,7 @@ def call( 'json': json, 'stream': stream, 'timeout': timeout, + 'compression': compression, } ) return _make_fake_response() @@ -133,6 +136,7 @@ async def call( json: Any = None, stream: bool | None = None, timeout: Timeout = 'medium', + compression: HttpCompressor | bool = True, ) -> HttpResponse: self.calls.append( { @@ -144,6 +148,7 @@ async def call( 'json': json, 'stream': stream, 'timeout': timeout, + 'compression': compression, } ) return _make_fake_response() From daabf01db15a93623c956273166f749655aa1543 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 20 Oct 2026 09:06:04 +0200 Subject: [PATCH 08/36] feat: add opt-in body compression to `standby_request` --- src/apify_client/_resource_clients/actor.py | 34 ++++++++----- tests/unit/test_actor_standby_request.py | 56 ++++++++++++++++++--- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index fa2d938a..08fb9ecf 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -33,6 +33,7 @@ start_waiting_for_resources_async, ) from apify_client.errors import ApifyClientError +from apify_client.http_compressors._resolve import resolve_compressor if TYPE_CHECKING: from datetime import timedelta @@ -57,7 +58,8 @@ WebhookCollectionClientAsync, ) from apify_client.http_clients import HttpResponse - from apify_client.types import JsonSerializable, Timeout, WebhooksList + from apify_client.http_compressors import HttpCompressor + from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, Timeout, WebhooksList _PricingInfo = ( PayPerEventActorPricingInfo @@ -637,6 +639,7 @@ def standby_request( headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, + compression: HttpCompressionAlgorithm | HttpCompressor | None = None, timeout: Timeout = 'long', ) -> HttpResponse: """Send an HTTP request to the Actor's Standby server. @@ -648,7 +651,8 @@ def standby_request( why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same - request more than once. Request bodies are sent uncompressed. + request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's + server may not decode a compressed body. https://docs.apify.com/platform/actors/running/standby @@ -662,6 +666,9 @@ def standby_request( headers: Additional headers of the request. data: Raw request body. Cannot be used together with `json`. json: JSON-serializable request body. Cannot be used together with `data`. + compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor` + instance. The body is compressed under the same conditions as for API calls. Set it only if the + Actor's server decodes the resulting `Content-Encoding`. timeout: Timeout for the HTTP request. Returns: @@ -670,7 +677,8 @@ def standby_request( Raises: ApifyClientError: If the Actor does not have Standby mode enabled. ApifyApiError: If the Actor does not exist, or the server responds with an error status. - ValueError: If both `data` and `json` are passed. + ImportError: If `compression` needs an optional extra that is not installed. + ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ if self._standby_url is None: response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') @@ -679,10 +687,6 @@ def standby_request( raise ApifyClientError(f'Actor {self.resource_id} does not have Standby mode enabled.') self._standby_url = actor.standby_url - # The client compresses large bodies by default, and a Standby server is not expected to decode them. - if data is not None or json is not None: - headers = self._http_client._merge_headers({'Content-Encoding': 'identity'}, headers) # noqa: SLF001 - return self._http_client.call( url=f'{self._standby_url.rstrip("/")}/{path.lstrip("/")}', method=method, @@ -691,6 +695,7 @@ def standby_request( data=data, json=json, timeout=timeout, + compression=resolve_compressor(compression) if compression is not None else False, ) @@ -1264,6 +1269,7 @@ async def standby_request( headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, + compression: HttpCompressionAlgorithm | HttpCompressor | None = None, timeout: Timeout = 'long', ) -> HttpResponse: """Send an HTTP request to the Actor's Standby server. @@ -1275,7 +1281,8 @@ async def standby_request( why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same - request more than once. Request bodies are sent uncompressed. + request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's + server may not decode a compressed body. https://docs.apify.com/platform/actors/running/standby @@ -1289,6 +1296,9 @@ async def standby_request( headers: Additional headers of the request. data: Raw request body. Cannot be used together with `json`. json: JSON-serializable request body. Cannot be used together with `data`. + compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor` + instance. The body is compressed under the same conditions as for API calls. Set it only if the + Actor's server decodes the resulting `Content-Encoding`. timeout: Timeout for the HTTP request. Returns: @@ -1297,7 +1307,8 @@ async def standby_request( Raises: ApifyClientError: If the Actor does not have Standby mode enabled. ApifyApiError: If the Actor does not exist, or the server responds with an error status. - ValueError: If both `data` and `json` are passed. + ImportError: If `compression` needs an optional extra that is not installed. + ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ if self._standby_url is None: response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') @@ -1306,10 +1317,6 @@ async def standby_request( raise ApifyClientError(f'Actor {self.resource_id} does not have Standby mode enabled.') self._standby_url = actor.standby_url - # The client compresses large bodies by default, and a Standby server is not expected to decode them. - if data is not None or json is not None: - headers = self._http_client._merge_headers({'Content-Encoding': 'identity'}, headers) # noqa: SLF001 - return await self._http_client.call( url=f'{self._standby_url.rstrip("/")}/{path.lstrip("/")}', method=method, @@ -1318,4 +1325,5 @@ async def standby_request( data=data, json=json, timeout=timeout, + compression=resolve_compressor(compression) if compression is not None else False, ) diff --git a/tests/unit/test_actor_standby_request.py b/tests/unit/test_actor_standby_request.py index 84f42944..9c3ead09 100644 --- a/tests/unit/test_actor_standby_request.py +++ b/tests/unit/test_actor_standby_request.py @@ -7,12 +7,15 @@ import pytest from apify_client.errors import ApifyClientError, NotFoundError +from apify_client.http_compressors import GzipHttpCompressor if TYPE_CHECKING: from pytest_httpserver import HTTPServer from werkzeug import Request from apify_client import ApifyClient, ApifyClientAsync + from apify_client.http_compressors import HttpCompressor + from apify_client.types import HttpCompressionAlgorithm pytestmark = pytest.mark.usefixtures('http_client_classes') @@ -145,7 +148,7 @@ def test_standby_request_sends_body_uncompressed(httpserver: HTTPServer, sync_cl captured = standby_requests(httpserver) assert len(captured) == 1 - assert captured[0].headers.get('Content-Encoding') == 'identity' + assert 'Content-Encoding' not in captured[0].headers assert json.loads(captured[0].get_data()) == payload @@ -161,22 +164,59 @@ async def test_standby_request_sends_body_uncompressed_async( captured = standby_requests(httpserver) assert len(captured) == 1 - assert captured[0].headers.get('Content-Encoding') == 'identity' + assert 'Content-Encoding' not in captured[0].headers assert json.loads(captured[0].get_data()) == payload -def test_standby_request_without_body_sends_no_content_encoding( - httpserver: HTTPServer, sync_client: ApifyClient +@pytest.mark.parametrize( + 'compression', + [ + pytest.param('gzip', id='algorithm name'), + pytest.param(GzipHttpCompressor(quality=1), id='compressor instance'), + ], +) +def test_standby_request_compresses_body_when_asked( + httpserver: HTTPServer, sync_client: ApifyClient, compression: HttpCompressionAlgorithm | HttpCompressor ) -> None: - """A request without a body carries no `Content-Encoding` header.""" + """With `compression` set, a body large enough to compress is sent compressed and labeled with its encoding.""" expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='GET').respond_with_json({'ok': True}) + httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + payload = {'items': ['x' * 100] * 100} - sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/search') + sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', json=payload, compression=compression) captured = standby_requests(httpserver) assert len(captured) == 1 - assert 'Content-Encoding' not in captured[0].headers + assert captured[0].headers.get('Content-Encoding') == 'gzip' + assert json.loads(gzip.decompress(captured[0].get_data())) == payload + + +async def test_standby_request_compresses_body_when_asked_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_standby_request_compresses_body_when_asked`.""" + expect_standby_actor(httpserver) + httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + payload = {'items': ['x' * 100] * 100} + + await async_client.actor(_MOCKED_ACTOR_ID).standby_request( + '/items', method='POST', json=payload, compression='gzip' + ) + + captured = standby_requests(httpserver) + assert len(captured) == 1 + assert captured[0].headers.get('Content-Encoding') == 'gzip' + assert json.loads(gzip.decompress(captured[0].get_data())) == payload + + +def test_standby_request_rejects_unknown_compression(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An unsupported compression algorithm raises a `ValueError` without sending a request to the Standby server.""" + expect_standby_actor(httpserver) + + with pytest.raises(ValueError, match='Unsupported compression algorithm'): + sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', data='x', compression='zip') # ty: ignore[invalid-argument-type] + + assert standby_requests(httpserver) == [] def test_standby_request_keeps_caller_content_encoding(httpserver: HTTPServer, sync_client: ApifyClient) -> None: From b3995a6444ef6f19896308929898e4a9e8890f04 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 20 Oct 2026 09:06:06 +0200 Subject: [PATCH 09/36] docs: document Standby authentication and body compression --- docs/02_concepts/14_standby_actors.mdx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/02_concepts/14_standby_actors.mdx b/docs/02_concepts/14_standby_actors.mdx index 604ae36a..c30ffff9 100644 --- a/docs/02_concepts/14_standby_actors.mdx +++ b/docs/02_concepts/14_standby_actors.mdx @@ -35,8 +35,15 @@ Note that: - The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor doesn't have Standby mode enabled, the method raises an `ApifyClientError`. - The path is always resolved against that URL, so a path can't send the request to another host. -- The request carries your API token in the `Authorization` header. For how the platform handles it, see [How do I authenticate my requests](https://docs.apify.com/platform/actors/running/standby#how-do-i-authenticate-my-requests). -- The request body is sent uncompressed, since the Actor's server may not decode a compressed body. +- The request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. + +## Authentication + +The request carries the client's API token in the `Authorization` header. The platform removes the token before it forwards the request, so the token doesn't reach the Actor's code. + +If the Actor reads the `Authorization` header for its own authentication, pass that header in `headers`, and send your Apify token in the `X-Apify-Authorization` header instead. A header you pass in `headers` replaces the client's `Authorization` header for that request. + +For details, see [How do I authenticate my requests](https://docs.apify.com/platform/actors/running/standby#how-do-i-authenticate-my-requests) in the platform documentation. ## Timeouts, retries, and errors From 5f04b111458bd4a78c7f24ee9c02f62977e06daa Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 23 Oct 2026 20:05:35 +0200 Subject: [PATCH 10/36] refactor: rename `standby_request` to `server_request` --- ...tandby_actors.mdx => 14_server_actors.mdx} | 20 +- ...14_standby_async.py => 14_server_async.py} | 6 +- .../{14_standby_sync.py => 14_server_sync.py} | 6 +- src/apify_client/_resource_clients/actor.py | 56 ++-- tests/unit/test_actor_server_request.py | 268 +++++++++++++++++ tests/unit/test_actor_standby_request.py | 272 ------------------ 6 files changed, 312 insertions(+), 316 deletions(-) rename docs/02_concepts/{14_standby_actors.mdx => 14_server_actors.mdx} (67%) rename docs/02_concepts/code/{14_standby_async.py => 14_server_async.py} (75%) rename docs/02_concepts/code/{14_standby_sync.py => 14_server_sync.py} (76%) create mode 100644 tests/unit/test_actor_server_request.py delete mode 100644 tests/unit/test_actor_standby_request.py diff --git a/docs/02_concepts/14_standby_actors.mdx b/docs/02_concepts/14_server_actors.mdx similarity index 67% rename from docs/02_concepts/14_standby_actors.mdx rename to docs/02_concepts/14_server_actors.mdx index c30ffff9..dd28af55 100644 --- a/docs/02_concepts/14_standby_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -1,7 +1,7 @@ --- -id: standby-actors -title: Standby Actors -description: Send HTTP requests to Actors running in Standby mode. +id: server-actors +title: Server Actors +description: Send HTTP requests to Server Actors. --- import Tabs from '@theme/Tabs'; @@ -9,20 +9,20 @@ import TabItem from '@theme/TabItem'; import CodeBlock from '@theme/CodeBlock'; import ApiLink from '@theme/ApiLink'; -import StandbyAsyncExample from '!!raw-loader!./code/14_standby_async.py'; -import StandbySyncExample from '!!raw-loader!./code/14_standby_sync.py'; +import ServerAsyncExample from '!!raw-loader!./code/14_server_async.py'; +import ServerSyncExample from '!!raw-loader!./code/14_server_sync.py'; -An Actor in [Standby mode](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.standby_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: +A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.server_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: - {StandbyAsyncExample} + {ServerAsyncExample} - {StandbySyncExample} + {ServerSyncExample} @@ -33,7 +33,7 @@ The method returns the server's response. To find out which endpoints an Actor s Note that: -- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor doesn't have Standby mode enabled, the method raises an `ApifyClientError`. +- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor isn't a Server Actor, the method raises an `ApifyClientError`. - The path is always resolved against that URL, so a path can't send the request to another host. - The request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. @@ -47,6 +47,6 @@ For details, see [How do I authenticate my requests](https://docs.apify.com/plat ## Timeouts, retries, and errors -When no Standby run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). +When no server run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). An error status from the Actor's server raises an `ApifyApiError`, like an error from the API. Server errors and rate-limit responses are retried with the client's retry settings, so a request that the server failed to answer may reach it more than once. If your endpoint isn't safe to call twice, lower `max_retries` on the client. For details, see [Retries](./05_retries.mdx) and [Error handling](./04_error_handling.mdx). diff --git a/docs/02_concepts/code/14_standby_async.py b/docs/02_concepts/code/14_server_async.py similarity index 75% rename from docs/02_concepts/code/14_standby_async.py rename to docs/02_concepts/code/14_server_async.py index c3fa0ab3..4d8edb47 100644 --- a/docs/02_concepts/code/14_standby_async.py +++ b/docs/02_concepts/code/14_server_async.py @@ -7,12 +7,12 @@ async def main() -> None: apify_client = ApifyClientAsync(TOKEN) actor_client = apify_client.actor('username/actor-name') - # Send a GET request to the /search endpoint of the Actor's Standby server. - response = await actor_client.standby_request('/search', params={'q': 'apify'}) + # Send a GET request to the /search endpoint of the Actor's server. + response = await actor_client.server_request('/search', params={'q': 'apify'}) print(response.json()) # Send a POST request with a JSON body. - response = await actor_client.standby_request( + response = await actor_client.server_request( '/items', method='POST', json={'url': 'https://apify.com'}, diff --git a/docs/02_concepts/code/14_standby_sync.py b/docs/02_concepts/code/14_server_sync.py similarity index 76% rename from docs/02_concepts/code/14_standby_sync.py rename to docs/02_concepts/code/14_server_sync.py index 5aca6151..c56e06bf 100644 --- a/docs/02_concepts/code/14_standby_sync.py +++ b/docs/02_concepts/code/14_server_sync.py @@ -7,12 +7,12 @@ def main() -> None: apify_client = ApifyClient(TOKEN) actor_client = apify_client.actor('username/actor-name') - # Send a GET request to the /search endpoint of the Actor's Standby server. - response = actor_client.standby_request('/search', params={'q': 'apify'}) + # Send a GET request to the /search endpoint of the Actor's server. + response = actor_client.server_request('/search', params={'q': 'apify'}) print(response.json()) # Send a POST request with a JSON body. - response = actor_client.standby_request( + response = actor_client.server_request( '/items', method='POST', json={'url': 'https://apify.com'}, diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 08fb9ecf..dd383327 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -90,7 +90,7 @@ def __init__( resource_path=resource_path, **kwargs, ) - self._standby_url: str | None = None + self._server_url: str | None = None def get(self, *, timeout: Timeout = 'short') -> ActorResource | None: """Retrieve the Actor. @@ -630,7 +630,7 @@ def validate_input( return True - def standby_request( + def server_request( self, path: str = '/', *, @@ -642,13 +642,13 @@ def standby_request( compression: HttpCompressionAlgorithm | HttpCompressor | None = None, timeout: Timeout = 'long', ) -> HttpResponse: - """Send an HTTP request to the Actor's Standby server. + """Send an HTTP request to the Actor's server. - An Actor in Standby mode runs a web server that is called over HTTP, without starting a run for each call. The - server's URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on - this client. The request carries the client's token, which the Standby router consumes, so it never reaches - the Actor's code. The first request to an idle Actor waits for the platform to start a Standby run, which is - why the timeout defaults to `long`. + A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The server's + URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this + client. The request carries the client's token, which the platform consumes, so it never reaches the Actor's + code. The first request to an idle Actor waits for the platform to start a server run, which is why the timeout + defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -657,7 +657,7 @@ def standby_request( https://docs.apify.com/platform/actors/running/standby Args: - path: The path on the Actor's server, for example `/search`. It is always resolved against the Standby URL, + path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL, so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. method: HTTP method of the request. params: Query parameters of the request, converted the same way as for API calls: booleans become `true` @@ -675,20 +675,20 @@ def standby_request( The response of the Actor's server. Raises: - ApifyClientError: If the Actor does not have Standby mode enabled. + ApifyClientError: If the Actor is not a Server Actor. ApifyApiError: If the Actor does not exist, or the server responds with an error status. ImportError: If `compression` needs an optional extra that is not installed. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ - if self._standby_url is None: + if self._server_url is None: response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} does not have Standby mode enabled.') - self._standby_url = actor.standby_url + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._server_url = actor.standby_url return self._http_client.call( - url=f'{self._standby_url.rstrip("/")}/{path.lstrip("/")}', + url=f'{self._server_url.rstrip("/")}/{path.lstrip("/")}', method=method, params=params, headers=headers, @@ -719,7 +719,7 @@ def __init__( resource_path=resource_path, **kwargs, ) - self._standby_url: str | None = None + self._server_url: str | None = None async def get(self, *, timeout: Timeout = 'short') -> ActorResource | None: """Retrieve the Actor. @@ -1260,7 +1260,7 @@ async def validate_input( return True - async def standby_request( + async def server_request( self, path: str = '/', *, @@ -1272,13 +1272,13 @@ async def standby_request( compression: HttpCompressionAlgorithm | HttpCompressor | None = None, timeout: Timeout = 'long', ) -> HttpResponse: - """Send an HTTP request to the Actor's Standby server. + """Send an HTTP request to the Actor's server. - An Actor in Standby mode runs a web server that is called over HTTP, without starting a run for each call. The - server's URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on - this client. The request carries the client's token, which the Standby router consumes, so it never reaches - the Actor's code. The first request to an idle Actor waits for the platform to start a Standby run, which is - why the timeout defaults to `long`. + A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The server's + URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this + client. The request carries the client's token, which the platform consumes, so it never reaches the Actor's + code. The first request to an idle Actor waits for the platform to start a server run, which is why the timeout + defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -1287,7 +1287,7 @@ async def standby_request( https://docs.apify.com/platform/actors/running/standby Args: - path: The path on the Actor's server, for example `/search`. It is always resolved against the Standby URL, + path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL, so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. method: HTTP method of the request. params: Query parameters of the request, converted the same way as for API calls: booleans become `true` @@ -1305,20 +1305,20 @@ async def standby_request( The response of the Actor's server. Raises: - ApifyClientError: If the Actor does not have Standby mode enabled. + ApifyClientError: If the Actor is not a Server Actor. ApifyApiError: If the Actor does not exist, or the server responds with an error status. ImportError: If `compression` needs an optional extra that is not installed. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ - if self._standby_url is None: + if self._server_url is None: response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} does not have Standby mode enabled.') - self._standby_url = actor.standby_url + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._server_url = actor.standby_url return await self._http_client.call( - url=f'{self._standby_url.rstrip("/")}/{path.lstrip("/")}', + url=f'{self._server_url.rstrip("/")}/{path.lstrip("/")}', method=method, params=params, headers=headers, diff --git a/tests/unit/test_actor_server_request.py b/tests/unit/test_actor_server_request.py new file mode 100644 index 00000000..e43952f1 --- /dev/null +++ b/tests/unit/test_actor_server_request.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import gzip +import json +from typing import TYPE_CHECKING, Any + +import pytest + +from apify_client.errors import ApifyClientError, NotFoundError +from apify_client.http_compressors import GzipHttpCompressor + +if TYPE_CHECKING: + from pytest_httpserver import HTTPServer + from werkzeug import Request + + from apify_client import ApifyClient, ApifyClientAsync + from apify_client.http_compressors import HttpCompressor + from apify_client.types import HttpCompressionAlgorithm + +pytestmark = pytest.mark.usefixtures('http_client_classes') + +_MOCKED_ACTOR_ID = 'test_actor_id' +_ACTOR_PATH = f'/v2/actors/{_MOCKED_ACTOR_ID}' +_SERVER_PREFIX = '/server' + + +def actor_response(server_url: str | None) -> dict[str, Any]: + return { + 'data': { + 'id': _MOCKED_ACTOR_ID, + 'userId': 'test_user_id', + 'name': 'test-actor', + 'username': 'test-user', + 'isPublic': False, + 'createdAt': '2026-08-01T10:00:00.000Z', + 'modifiedAt': '2026-08-01T10:00:00.000Z', + 'stats': {}, + 'versions': [], + 'defaultRunOptions': {}, + 'standbyUrl': server_url, + } + } + + +def expect_server_actor(httpserver: HTTPServer) -> None: + server_url = httpserver.url_for(_SERVER_PREFIX) + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(server_url)) + + +def server_requests(httpserver: HTTPServer) -> list[Request]: + return [request for request, _ in httpserver.log if request.path.startswith(f'{_SERVER_PREFIX}/')] + + +def test_server_request_sends_request_to_server_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """The request goes to the server URL with the method, path, query, body, and the client's token.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='POST').respond_with_json({'ok': True}) + + response = sync_client.actor(_MOCKED_ACTOR_ID).server_request( + '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} + ) + + assert response.status_code == 200 + assert response.json() == {'ok': True} + captured = server_requests(httpserver) + assert len(captured) == 1 + assert captured[0].args.to_dict() == {'q': 'apify'} + assert json.loads(captured[0].get_data()) == {'limit': 5} + assert captured[0].headers['Authorization'] == 'Bearer test' + + +async def test_server_request_sends_request_to_server_url_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_server_request_sends_request_to_server_url`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='POST').respond_with_json({'ok': True}) + + response = await async_client.actor(_MOCKED_ACTOR_ID).server_request( + '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} + ) + + assert response.status_code == 200 + assert response.json() == {'ok': True} + captured = server_requests(httpserver) + assert len(captured) == 1 + assert captured[0].args.to_dict() == {'q': 'apify'} + assert json.loads(captured[0].get_data()) == {'limit': 5} + assert captured[0].headers['Authorization'] == 'Bearer test' + + +def test_server_request_fetches_server_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """Later calls on the same client reuse the server URL fetched by the first one.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) + actor_client = sync_client.actor(_MOCKED_ACTOR_ID) + + actor_client.server_request() + actor_client.server_request() + + actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] + assert len(actor_gets) == 1 + + +async def test_server_request_fetches_server_url_once_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_server_request_fetches_server_url_once`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) + actor_client = async_client.actor(_MOCKED_ACTOR_ID) + + await actor_client.server_request() + await actor_client.server_request() + + actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] + assert len(actor_gets) == 1 + + +@pytest.mark.parametrize( + 'path', + [ + pytest.param('//evil.example/steal', id='protocol-relative'), + pytest.param('@evil.example/steal', id='userinfo'), + pytest.param('https://evil.example/steal', id='absolute url'), + ], +) +def test_server_request_path_stays_on_server_host(httpserver: HTTPServer, sync_client: ApifyClient, path: str) -> None: + """A path that looks like another host is still sent to the server, as a path on it.""" + expect_server_actor(httpserver) + httpserver.no_handler_status_code = 200 + + response = sync_client.actor(_MOCKED_ACTOR_ID).server_request(path) + + assert response.status_code == 200 + assert len(server_requests(httpserver)) == 1 + + +def test_server_request_sends_body_uncompressed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A body large enough for the client to compress is sent as is, since servers may not decode it.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + payload = {'items': ['x' * 100] * 100} + + sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload) + + captured = server_requests(httpserver) + assert len(captured) == 1 + assert 'Content-Encoding' not in captured[0].headers + assert json.loads(captured[0].get_data()) == payload + + +async def test_server_request_sends_body_uncompressed_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_server_request_sends_body_uncompressed`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + payload = {'items': ['x' * 100] * 100} + + await async_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload) + + captured = server_requests(httpserver) + assert len(captured) == 1 + assert 'Content-Encoding' not in captured[0].headers + assert json.loads(captured[0].get_data()) == payload + + +@pytest.mark.parametrize( + 'compression', + [ + pytest.param('gzip', id='algorithm name'), + pytest.param(GzipHttpCompressor(quality=1), id='compressor instance'), + ], +) +def test_server_request_compresses_body_when_asked( + httpserver: HTTPServer, sync_client: ApifyClient, compression: HttpCompressionAlgorithm | HttpCompressor +) -> None: + """With `compression` set, a body large enough to compress is sent compressed and labeled with its encoding.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + payload = {'items': ['x' * 100] * 100} + + sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload, compression=compression) + + captured = server_requests(httpserver) + assert len(captured) == 1 + assert captured[0].headers.get('Content-Encoding') == 'gzip' + assert json.loads(gzip.decompress(captured[0].get_data())) == payload + + +async def test_server_request_compresses_body_when_asked_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_server_request_compresses_body_when_asked`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + payload = {'items': ['x' * 100] * 100} + + await async_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload, compression='gzip') + + captured = server_requests(httpserver) + assert len(captured) == 1 + assert captured[0].headers.get('Content-Encoding') == 'gzip' + assert json.loads(gzip.decompress(captured[0].get_data())) == payload + + +def test_server_request_rejects_unknown_compression(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An unsupported compression algorithm raises a `ValueError` without sending a request to the server.""" + expect_server_actor(httpserver) + + with pytest.raises(ValueError, match='Unsupported compression algorithm'): + sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', data='x', compression='zip') # ty: ignore[invalid-argument-type] + + assert server_requests(httpserver) == [] + + +def test_server_request_keeps_caller_content_encoding(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A `Content-Encoding` header passed by the caller is sent as given, with the body untouched.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) + body = gzip.compress(b'x' * 2000) + + sync_client.actor(_MOCKED_ACTOR_ID).server_request( + '/items', method='POST', headers={'content-encoding': 'gzip'}, data=body + ) + + captured = server_requests(httpserver) + assert len(captured) == 1 + assert captured[0].headers.get('Content-Encoding') == 'gzip' + assert captured[0].get_data() == body + + +def test_server_request_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An Actor without Server mode enabled raises an error before any request to a server.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) + + with pytest.raises(ApifyClientError, match='is not a Server Actor'): + sync_client.actor(_MOCKED_ACTOR_ID).server_request() + + +async def test_server_request_without_server_mode_raises_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_server_request_without_server_mode_raises`.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) + + with pytest.raises(ApifyClientError, match='is not a Server Actor'): + await async_client.actor(_MOCKED_ACTOR_ID).server_request() + + +def test_server_request_missing_actor_raises_not_found(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A missing Actor raises `NotFoundError`.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json( + {'error': {'type': 'record-not-found', 'message': 'Actor was not found'}}, status=404 + ) + + with pytest.raises(NotFoundError): + sync_client.actor(_MOCKED_ACTOR_ID).server_request() + + +def test_server_request_error_status_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An error status from the server raises the matching `ApifyApiError` subclass.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/missing', method='GET').respond_with_data('Not here', status=404) + + with pytest.raises(NotFoundError, match='Not here'): + sync_client.actor(_MOCKED_ACTOR_ID).server_request('/missing') diff --git a/tests/unit/test_actor_standby_request.py b/tests/unit/test_actor_standby_request.py deleted file mode 100644 index 9c3ead09..00000000 --- a/tests/unit/test_actor_standby_request.py +++ /dev/null @@ -1,272 +0,0 @@ -from __future__ import annotations - -import gzip -import json -from typing import TYPE_CHECKING, Any - -import pytest - -from apify_client.errors import ApifyClientError, NotFoundError -from apify_client.http_compressors import GzipHttpCompressor - -if TYPE_CHECKING: - from pytest_httpserver import HTTPServer - from werkzeug import Request - - from apify_client import ApifyClient, ApifyClientAsync - from apify_client.http_compressors import HttpCompressor - from apify_client.types import HttpCompressionAlgorithm - -pytestmark = pytest.mark.usefixtures('http_client_classes') - -_MOCKED_ACTOR_ID = 'test_actor_id' -_ACTOR_PATH = f'/v2/actors/{_MOCKED_ACTOR_ID}' -_STANDBY_PREFIX = '/standby' - - -def actor_response(standby_url: str | None) -> dict[str, Any]: - return { - 'data': { - 'id': _MOCKED_ACTOR_ID, - 'userId': 'test_user_id', - 'name': 'test-actor', - 'username': 'test-user', - 'isPublic': False, - 'createdAt': '2026-08-01T10:00:00.000Z', - 'modifiedAt': '2026-08-01T10:00:00.000Z', - 'stats': {}, - 'versions': [], - 'defaultRunOptions': {}, - 'standbyUrl': standby_url, - } - } - - -def expect_standby_actor(httpserver: HTTPServer) -> None: - standby_url = httpserver.url_for(_STANDBY_PREFIX) - httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(standby_url)) - - -def standby_requests(httpserver: HTTPServer) -> list[Request]: - return [request for request, _ in httpserver.log if request.path.startswith(f'{_STANDBY_PREFIX}/')] - - -def test_standby_request_sends_request_to_standby_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """The request goes to the Standby URL with the method, path, query, body, and the client's token.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_json({'ok': True}) - - response = sync_client.actor(_MOCKED_ACTOR_ID).standby_request( - '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} - ) - - assert response.status_code == 200 - assert response.json() == {'ok': True} - captured = standby_requests(httpserver) - assert len(captured) == 1 - assert captured[0].args.to_dict() == {'q': 'apify'} - assert json.loads(captured[0].get_data()) == {'limit': 5} - assert captured[0].headers['Authorization'] == 'Bearer test' - - -async def test_standby_request_sends_request_to_standby_url_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_standby_request_sends_request_to_standby_url`.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/search', method='POST').respond_with_json({'ok': True}) - - response = await async_client.actor(_MOCKED_ACTOR_ID).standby_request( - '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} - ) - - assert response.status_code == 200 - assert response.json() == {'ok': True} - captured = standby_requests(httpserver) - assert len(captured) == 1 - assert captured[0].args.to_dict() == {'q': 'apify'} - assert json.loads(captured[0].get_data()) == {'limit': 5} - assert captured[0].headers['Authorization'] == 'Bearer test' - - -def test_standby_request_fetches_standby_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """Later calls on the same client reuse the Standby URL fetched by the first one.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/', method='GET').respond_with_json({'ok': True}) - actor_client = sync_client.actor(_MOCKED_ACTOR_ID) - - actor_client.standby_request() - actor_client.standby_request() - - actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] - assert len(actor_gets) == 1 - - -async def test_standby_request_fetches_standby_url_once_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_standby_request_fetches_standby_url_once`.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/', method='GET').respond_with_json({'ok': True}) - actor_client = async_client.actor(_MOCKED_ACTOR_ID) - - await actor_client.standby_request() - await actor_client.standby_request() - - actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] - assert len(actor_gets) == 1 - - -@pytest.mark.parametrize( - 'path', - [ - pytest.param('//evil.example/steal', id='protocol-relative'), - pytest.param('@evil.example/steal', id='userinfo'), - pytest.param('https://evil.example/steal', id='absolute url'), - ], -) -def test_standby_request_path_stays_on_standby_host( - httpserver: HTTPServer, sync_client: ApifyClient, path: str -) -> None: - """A path that looks like another host is still sent to the Standby server, as a path on it.""" - expect_standby_actor(httpserver) - httpserver.no_handler_status_code = 200 - - response = sync_client.actor(_MOCKED_ACTOR_ID).standby_request(path) - - assert response.status_code == 200 - assert len(standby_requests(httpserver)) == 1 - - -def test_standby_request_sends_body_uncompressed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """A body large enough for the client to compress is sent as is, since Standby servers may not decode it.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) - payload = {'items': ['x' * 100] * 100} - - sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', json=payload) - - captured = standby_requests(httpserver) - assert len(captured) == 1 - assert 'Content-Encoding' not in captured[0].headers - assert json.loads(captured[0].get_data()) == payload - - -async def test_standby_request_sends_body_uncompressed_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_standby_request_sends_body_uncompressed`.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) - payload = {'items': ['x' * 100] * 100} - - await async_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', json=payload) - - captured = standby_requests(httpserver) - assert len(captured) == 1 - assert 'Content-Encoding' not in captured[0].headers - assert json.loads(captured[0].get_data()) == payload - - -@pytest.mark.parametrize( - 'compression', - [ - pytest.param('gzip', id='algorithm name'), - pytest.param(GzipHttpCompressor(quality=1), id='compressor instance'), - ], -) -def test_standby_request_compresses_body_when_asked( - httpserver: HTTPServer, sync_client: ApifyClient, compression: HttpCompressionAlgorithm | HttpCompressor -) -> None: - """With `compression` set, a body large enough to compress is sent compressed and labeled with its encoding.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) - payload = {'items': ['x' * 100] * 100} - - sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', json=payload, compression=compression) - - captured = standby_requests(httpserver) - assert len(captured) == 1 - assert captured[0].headers.get('Content-Encoding') == 'gzip' - assert json.loads(gzip.decompress(captured[0].get_data())) == payload - - -async def test_standby_request_compresses_body_when_asked_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_standby_request_compresses_body_when_asked`.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) - payload = {'items': ['x' * 100] * 100} - - await async_client.actor(_MOCKED_ACTOR_ID).standby_request( - '/items', method='POST', json=payload, compression='gzip' - ) - - captured = standby_requests(httpserver) - assert len(captured) == 1 - assert captured[0].headers.get('Content-Encoding') == 'gzip' - assert json.loads(gzip.decompress(captured[0].get_data())) == payload - - -def test_standby_request_rejects_unknown_compression(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """An unsupported compression algorithm raises a `ValueError` without sending a request to the Standby server.""" - expect_standby_actor(httpserver) - - with pytest.raises(ValueError, match='Unsupported compression algorithm'): - sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/items', method='POST', data='x', compression='zip') # ty: ignore[invalid-argument-type] - - assert standby_requests(httpserver) == [] - - -def test_standby_request_keeps_caller_content_encoding(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """A `Content-Encoding` header passed by the caller is sent as given, with the body untouched.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/items', method='POST').respond_with_json({'ok': True}) - body = gzip.compress(b'x' * 2000) - - sync_client.actor(_MOCKED_ACTOR_ID).standby_request( - '/items', method='POST', headers={'content-encoding': 'gzip'}, data=body - ) - - captured = standby_requests(httpserver) - assert len(captured) == 1 - assert captured[0].headers.get('Content-Encoding') == 'gzip' - assert captured[0].get_data() == body - - -def test_standby_request_without_standby_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """An Actor without Standby mode enabled raises an error before any request to a Standby server.""" - httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) - - with pytest.raises(ApifyClientError, match='does not have Standby mode enabled'): - sync_client.actor(_MOCKED_ACTOR_ID).standby_request() - - -async def test_standby_request_without_standby_mode_raises_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_standby_request_without_standby_mode_raises`.""" - httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) - - with pytest.raises(ApifyClientError, match='does not have Standby mode enabled'): - await async_client.actor(_MOCKED_ACTOR_ID).standby_request() - - -def test_standby_request_missing_actor_raises_not_found(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """A missing Actor raises `NotFoundError`.""" - httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json( - {'error': {'type': 'record-not-found', 'message': 'Actor was not found'}}, status=404 - ) - - with pytest.raises(NotFoundError): - sync_client.actor(_MOCKED_ACTOR_ID).standby_request() - - -def test_standby_request_error_status_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """An error status from the Standby server raises the matching `ApifyApiError` subclass.""" - expect_standby_actor(httpserver) - httpserver.expect_request(f'{_STANDBY_PREFIX}/missing', method='GET').respond_with_data('Not here', status=404) - - with pytest.raises(NotFoundError, match='Not here'): - sync_client.actor(_MOCKED_ACTOR_ID).standby_request('/missing') From 4b3334365eb47da3d43bdfc37ed2baad19de7621 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 08:53:34 +0100 Subject: [PATCH 11/36] docs: say Server Actor request bodies are uncompressed by default --- docs/02_concepts/14_server_actors.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index dd28af55..23196c78 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -35,7 +35,7 @@ Note that: - The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor isn't a Server Actor, the method raises an `ApifyClientError`. - The path is always resolved against that URL, so a path can't send the request to another host. -- The request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. +- By default, the request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. ## Authentication From e22661ce3185cc779fd2a681ecca09ad1b35c5f9 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 11:23:33 +0100 Subject: [PATCH 12/36] refactor: narrow server_request method and params types --- src/apify_client/_resource_clients/actor.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index dd383327..f68e0cbb 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -36,11 +36,12 @@ from apify_client.http_compressors._resolve import resolve_compressor if TYPE_CHECKING: - from datetime import timedelta + from collections.abc import Mapping + from datetime import datetime, timedelta from decimal import Decimal from logging import Logger - from apify_client._literals import ActorJobStatus, ActorPermissionLevel, RunOrigin + from apify_client._literals import ActorJobStatus, ActorPermissionLevel, HttpMethod, RunOrigin from apify_client._resource_clients import ( ActorVersionClient, ActorVersionClientAsync, @@ -634,8 +635,8 @@ def server_request( self, path: str = '/', *, - method: str = 'GET', - params: dict[str, Any] | None = None, + method: HttpMethod = 'GET', + params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, @@ -690,7 +691,7 @@ def server_request( return self._http_client.call( url=f'{self._server_url.rstrip("/")}/{path.lstrip("/")}', method=method, - params=params, + params=dict(params) if params is not None else None, headers=headers, data=data, json=json, @@ -1264,8 +1265,8 @@ async def server_request( self, path: str = '/', *, - method: str = 'GET', - params: dict[str, Any] | None = None, + method: HttpMethod = 'GET', + params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, @@ -1320,7 +1321,7 @@ async def server_request( return await self._http_client.call( url=f'{self._server_url.rstrip("/")}/{path.lstrip("/")}', method=method, - params=params, + params=dict(params) if params is not None else None, headers=headers, data=data, json=json, From 5fe9c3ccb1069b0855d70382e526409ba0b0a8f6 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 11:23:37 +0100 Subject: [PATCH 13/36] feat: add get_server_url to Actor clients --- docs/02_concepts/14_server_actors.mdx | 1 + src/apify_client/_resource_clients/actor.py | 86 ++++++++++++++------- tests/unit/test_actor_server_request.py | 32 ++++++++ 3 files changed, 93 insertions(+), 26 deletions(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 23196c78..dcbe2378 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -35,6 +35,7 @@ Note that: - The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor isn't a Server Actor, the method raises an `ApifyClientError`. - The path is always resolved against that URL, so a path can't send the request to another host. +- To call the server with another HTTP client, for example one generated from the server's OpenAPI specification, get its URL with `ActorClient.get_server_url`. It fetches and reuses the URL the same way. - By default, the request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. ## Authentication diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index f68e0cbb..b8430393 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -631,6 +631,30 @@ def validate_input( return True + def get_server_url(self) -> str: + """Return the URL of the Actor's server. + + The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this + client. Use it to call the server with another HTTP client, for example one generated from the server's + OpenAPI specification. + + https://docs.apify.com/platform/actors/running/standby + + Returns: + The URL of the Actor's server. + + Raises: + ApifyClientError: If the Actor is not a Server Actor. + ApifyApiError: If the Actor does not exist. + """ + if self._server_url is None: + response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._server_url = actor.standby_url + return self._server_url + def server_request( self, path: str = '/', @@ -645,11 +669,10 @@ def server_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The server's - URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this - client. The request carries the client's token, which the platform consumes, so it never reaches the Actor's - code. The first request to an idle Actor waits for the platform to start a server run, which is why the timeout - defaults to `long`. + A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The request + goes to the URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it + never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server + run, which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -681,15 +704,9 @@ def server_request( ImportError: If `compression` needs an optional extra that is not installed. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ - if self._server_url is None: - response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') - actor = ActorResponse.model_validate(response_to_dict(response)).data - if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') - self._server_url = actor.standby_url - + server_url = self.get_server_url() return self._http_client.call( - url=f'{self._server_url.rstrip("/")}/{path.lstrip("/")}', + url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', method=method, params=dict(params) if params is not None else None, headers=headers, @@ -1261,6 +1278,30 @@ async def validate_input( return True + async def get_server_url(self) -> str: + """Return the URL of the Actor's server. + + The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this + client. Use it to call the server with another HTTP client, for example one generated from the server's + OpenAPI specification. + + https://docs.apify.com/platform/actors/running/standby + + Returns: + The URL of the Actor's server. + + Raises: + ApifyClientError: If the Actor is not a Server Actor. + ApifyApiError: If the Actor does not exist. + """ + if self._server_url is None: + response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._server_url = actor.standby_url + return self._server_url + async def server_request( self, path: str = '/', @@ -1275,11 +1316,10 @@ async def server_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The server's - URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this - client. The request carries the client's token, which the platform consumes, so it never reaches the Actor's - code. The first request to an idle Actor waits for the platform to start a server run, which is why the timeout - defaults to `long`. + A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The request + goes to the URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it + never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server + run, which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -1311,15 +1351,9 @@ async def server_request( ImportError: If `compression` needs an optional extra that is not installed. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ - if self._server_url is None: - response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') - actor = ActorResponse.model_validate(response_to_dict(response)).data - if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') - self._server_url = actor.standby_url - + server_url = await self.get_server_url() return await self._http_client.call( - url=f'{self._server_url.rstrip("/")}/{path.lstrip("/")}', + url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', method=method, params=dict(params) if params is not None else None, headers=headers, diff --git a/tests/unit/test_actor_server_request.py b/tests/unit/test_actor_server_request.py index e43952f1..c7c03bd9 100644 --- a/tests/unit/test_actor_server_request.py +++ b/tests/unit/test_actor_server_request.py @@ -117,6 +117,38 @@ async def test_server_request_fetches_server_url_once_async( assert len(actor_gets) == 1 +def test_get_server_url_returns_standby_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`get_server_url` returns the Actor's `standby_url` and fetches it from the API only once.""" + expect_server_actor(httpserver) + actor_client = sync_client.actor(_MOCKED_ACTOR_ID) + + assert actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) + assert actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) + + actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] + assert len(actor_gets) == 1 + + +async def test_get_server_url_returns_standby_url_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_get_server_url_returns_standby_url`.""" + expect_server_actor(httpserver) + actor_client = async_client.actor(_MOCKED_ACTOR_ID) + + assert await actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) + assert await actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) + + actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] + assert len(actor_gets) == 1 + + +def test_get_server_url_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`get_server_url` raises for an Actor without Server mode enabled.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) + + with pytest.raises(ApifyClientError, match='is not a Server Actor'): + sync_client.actor(_MOCKED_ACTOR_ID).get_server_url() + + @pytest.mark.parametrize( 'path', [ From f986ba28d26f30fc4802d5ef7b733276f3f1abf8 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 15:01:22 +0100 Subject: [PATCH 14/36] feat: add get_server_openapi_spec to Actor clients --- docs/02_concepts/14_server_actors.mdx | 1 + src/apify_client/_resource_clients/actor.py | 102 ++++++++++++++ tests/unit/test_actor_server_request.py | 146 +++++++++++++++++++- 3 files changed, 248 insertions(+), 1 deletion(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index dcbe2378..55a8d7c6 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -36,6 +36,7 @@ Note that: - The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor isn't a Server Actor, the method raises an `ApifyClientError`. - The path is always resolved against that URL, so a path can't send the request to another host. - To call the server with another HTTP client, for example one generated from the server's OpenAPI specification, get its URL with `ActorClient.get_server_url`. It fetches and reuses the URL the same way. +- To get that specification, use `ActorClient.get_server_openapi_spec`. It returns the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the build the Actor runs in Server mode, or `None` if the Actor doesn't define one. - By default, the request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. ## Authentication diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index b8430393..cf9d4969 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -655,6 +655,57 @@ def get_server_url(self) -> str: self._server_url = actor.standby_url return self._server_url + def get_server_openapi_spec(self) -> dict[str, Any] | None: + """Return the OpenAPI specification of the Actor's server. + + The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in + Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP + client for the server, and point that client at the URL returned by `get_server_url`. + + https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + + Returns: + The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. + + Raises: + ApifyClientError: If the Actor is not a Server Actor, or its Server mode build doesn't exist. + ApifyApiError: If the Actor does not exist. + """ + response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + + build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' + + build_id = None + tagged_build = (actor.tagged_builds or {}).get(build_ref) + if tagged_build is not None: + build_id = tagged_build.build_id + else: + # Not a tag, so `build_ref` is a build number, which the API can't look up directly. + for build_item in self.builds().iterate(desc=True): + if build_item.build_number == build_ref: + build_id = build_item.id + break + + build = None + if build_id is not None: + build_client = self._client_registry.build_client( + resource_id=build_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + build = build_client.get() + if build is None: + raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') + + if build.actor_definition is None: + return None + return build.actor_definition.web_server_schema + def server_request( self, path: str = '/', @@ -1302,6 +1353,57 @@ async def get_server_url(self) -> str: self._server_url = actor.standby_url return self._server_url + async def get_server_openapi_spec(self) -> dict[str, Any] | None: + """Return the OpenAPI specification of the Actor's server. + + The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in + Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP + client for the server, and point that client at the URL returned by `get_server_url`. + + https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + + Returns: + The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. + + Raises: + ApifyClientError: If the Actor is not a Server Actor, or its Server mode build doesn't exist. + ApifyApiError: If the Actor does not exist. + """ + response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + + build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' + + build_id = None + tagged_build = (actor.tagged_builds or {}).get(build_ref) + if tagged_build is not None: + build_id = tagged_build.build_id + else: + # Not a tag, so `build_ref` is a build number, which the API can't look up directly. + async for build_item in self.builds().iterate(desc=True): + if build_item.build_number == build_ref: + build_id = build_item.id + break + + build = None + if build_id is not None: + build_client = self._client_registry.build_client( + resource_id=build_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + build = await build_client.get() + if build is None: + raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') + + if build.actor_definition is None: + return None + return build.actor_definition.web_server_schema + async def server_request( self, path: str = '/', diff --git a/tests/unit/test_actor_server_request.py b/tests/unit/test_actor_server_request.py index c7c03bd9..c9a9b205 100644 --- a/tests/unit/test_actor_server_request.py +++ b/tests/unit/test_actor_server_request.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any import pytest +from werkzeug import Response from apify_client.errors import ApifyClientError, NotFoundError from apify_client.http_compressors import GzipHttpCompressor @@ -24,7 +25,7 @@ _SERVER_PREFIX = '/server' -def actor_response(server_url: str | None) -> dict[str, Any]: +def actor_response(server_url: str | None, **extra_fields: Any) -> dict[str, Any]: return { 'data': { 'id': _MOCKED_ACTOR_ID, @@ -38,6 +39,7 @@ def actor_response(server_url: str | None) -> dict[str, Any]: 'versions': [], 'defaultRunOptions': {}, 'standbyUrl': server_url, + **extra_fields, } } @@ -298,3 +300,145 @@ def test_server_request_error_status_raises(httpserver: HTTPServer, sync_client: with pytest.raises(NotFoundError, match='Not here'): sync_client.actor(_MOCKED_ACTOR_ID).server_request('/missing') + + +_OPENAPI_SPEC = {'openapi': '3.0.0', 'info': {'title': 'Test server', 'version': '1.0.0'}, 'paths': {}} +_TAGGED_BUILDS = { + 'latest': {'buildId': 'latest_build_id', 'buildNumber': '0.0.2'}, + 'beta': {'buildId': 'beta_build_id', 'buildNumber': '0.0.3'}, +} + + +def build_response(build_id: str, actor_definition: dict[str, Any] | None) -> dict[str, Any]: + return { + 'data': { + 'id': build_id, + 'actId': _MOCKED_ACTOR_ID, + 'userId': 'test_user_id', + 'startedAt': '2026-08-01T10:00:00.000Z', + 'status': 'SUCCEEDED', + 'meta': {'origin': 'API'}, + 'buildNumber': '0.0.1', + 'actorDefinition': actor_definition, + } + } + + +def expect_builds_list(httpserver: HTTPServer, builds: dict[str, str]) -> None: + """Serve the Actor's builds list as one page, so a request for a later offset gets no items.""" + items = [ + { + 'id': build_id, + 'status': 'SUCCEEDED', + 'startedAt': '2026-08-01T10:00:00.000Z', + 'usageTotalUsd': 0.01, + 'buildNumber': build_number, + } + for build_id, build_number in builds.items() + ] + + def handler(request: Request) -> Response: + offset = int(request.args.get('offset', 0)) + page = items[offset:] + data = {'total': len(items), 'offset': offset, 'limit': 1000, 'desc': True, 'count': len(page), 'items': page} + return Response(json.dumps({'data': data}), content_type='application/json') + + httpserver.expect_request(f'{_ACTOR_PATH}/builds', method='GET').respond_with_handler(handler) + + +def expect_server_actor_with_build( + httpserver: HTTPServer, *, standby_build: str | None, build_id: str, actor_definition: dict[str, Any] | None +) -> None: + actor_standby = {'isEnabled': True, 'build': standby_build} + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json( + actor_response(httpserver.url_for(_SERVER_PREFIX), taggedBuilds=_TAGGED_BUILDS, actorStandby=actor_standby) + ) + httpserver.expect_request(f'/v2/actor-builds/{build_id}', method='GET').respond_with_json( + build_response(build_id, actor_definition) + ) + + +@pytest.mark.parametrize( + ('standby_build', 'build_id'), + [ + pytest.param(None, 'latest_build_id', id='no build set uses latest'), + pytest.param('beta', 'beta_build_id', id='build tag'), + ], +) +def test_get_server_openapi_spec_from_tagged_build( + httpserver: HTTPServer, sync_client: ApifyClient, standby_build: str | None, build_id: str +) -> None: + """The specification comes from the web server schema of the build tagged for Server mode.""" + expect_server_actor_with_build( + httpserver, standby_build=standby_build, build_id=build_id, actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + + assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + + +async def test_get_server_openapi_spec_from_tagged_build_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_get_server_openapi_spec_from_tagged_build`.""" + expect_server_actor_with_build( + httpserver, standby_build='beta', build_id='beta_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + + assert await async_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + + +def test_get_server_openapi_spec_from_build_number(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A build number set for Server mode is looked up in the Actor's builds.""" + expect_server_actor_with_build( + httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + expect_builds_list(httpserver, {'latest_build_id': '0.0.2', 'old_build_id': '0.0.1'}) + + assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + + +async def test_get_server_openapi_spec_from_build_number_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_get_server_openapi_spec_from_build_number`.""" + expect_server_actor_with_build( + httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + expect_builds_list(httpserver, {'latest_build_id': '0.0.2', 'old_build_id': '0.0.1'}) + + assert await async_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + + +@pytest.mark.parametrize( + 'actor_definition', + [ + pytest.param({'name': 'test-actor'}, id='no web server schema'), + pytest.param(None, id='no actor definition'), + ], +) +def test_get_server_openapi_spec_without_schema_returns_none( + httpserver: HTTPServer, sync_client: ApifyClient, actor_definition: dict[str, Any] | None +) -> None: + """A build without a web server schema has no specification to return.""" + expect_server_actor_with_build( + httpserver, standby_build=None, build_id='latest_build_id', actor_definition=actor_definition + ) + + assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() is None + + +def test_get_server_openapi_spec_unknown_build_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A Server mode build that is neither a tag nor a listed build number raises an error.""" + expect_server_actor_with_build(httpserver, standby_build='9.9.9', build_id='unused_build_id', actor_definition=None) + expect_builds_list(httpserver, {'latest_build_id': '0.0.2'}) + + with pytest.raises(ApifyClientError, match='does not exist'): + sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() + + +def test_get_server_openapi_spec_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`get_server_openapi_spec` raises for an Actor without Server mode enabled.""" + httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) + + with pytest.raises(ApifyClientError, match='is not a Server Actor'): + sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() From 9514c19ecaa6cc4ab334477dc0e8f4f93cdb1bbd Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 15:06:41 +0100 Subject: [PATCH 15/36] docs: document get_server_url and get_server_openapi_spec --- docs/02_concepts/14_server_actors.mdx | 32 +++++++++++++++++-- .../code/14_server_other_client_async.py | 25 +++++++++++++++ .../code/14_server_other_client_sync.py | 25 +++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 docs/02_concepts/code/14_server_other_client_async.py create mode 100644 docs/02_concepts/code/14_server_other_client_sync.py diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 55a8d7c6..191bffc4 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -1,7 +1,7 @@ --- id: server-actors title: Server Actors -description: Send HTTP requests to Server Actors. +description: Send HTTP requests to Server Actors, and get their URL and OpenAPI specification. --- import Tabs from '@theme/Tabs'; @@ -11,6 +11,8 @@ import ApiLink from '@theme/ApiLink'; import ServerAsyncExample from '!!raw-loader!./code/14_server_async.py'; import ServerSyncExample from '!!raw-loader!./code/14_server_sync.py'; +import ServerOtherClientAsyncExample from '!!raw-loader!./code/14_server_other_client_async.py'; +import ServerOtherClientSyncExample from '!!raw-loader!./code/14_server_other_client_sync.py'; A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.server_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: @@ -35,10 +37,34 @@ Note that: - The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor isn't a Server Actor, the method raises an `ApifyClientError`. - The path is always resolved against that URL, so a path can't send the request to another host. -- To call the server with another HTTP client, for example one generated from the server's OpenAPI specification, get its URL with `ActorClient.get_server_url`. It fetches and reuses the URL the same way. -- To get that specification, use `ActorClient.get_server_openapi_spec`. It returns the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the build the Actor runs in Server mode, or `None` if the Actor doesn't define one. - By default, the request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. +## Calling the server with another HTTP client + +For an Actor with many endpoints, you may prefer a client generated from the server's OpenAPI specification, or an HTTP client you already use. Two methods give such a client what it needs: + +- `ActorClient.get_server_url` returns the server's URL. It fetches and reuses the URL the same way as `server_request`. +- `ActorClient.get_server_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build the Actor runs in Server mode. That's the build set in the Actor's Standby settings, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. + +A request from another client needs your Apify token in the `Authorization` header, and a timeout long enough for a server run to start. For details, see [Authentication](#authentication) and [Timeouts, retries, and errors](#timeouts-retries-and-errors). The following example uses [HTTPX2](https://github.com/pydantic/httpx2), which you can install with the `httpx2` extra: + +```sh +pip install "apify-client[httpx2]" +``` + + + + + {ServerOtherClientAsyncExample} + + + + + {ServerOtherClientSyncExample} + + + + ## Authentication The request carries the client's API token in the `Authorization` header. The platform removes the token before it forwards the request, so the token doesn't reach the Actor's code. diff --git a/docs/02_concepts/code/14_server_other_client_async.py b/docs/02_concepts/code/14_server_other_client_async.py new file mode 100644 index 00000000..72166e00 --- /dev/null +++ b/docs/02_concepts/code/14_server_other_client_async.py @@ -0,0 +1,25 @@ +import httpx2 + +from apify_client import ApifyClientAsync + +TOKEN = 'MY-APIFY-TOKEN' + + +async def main() -> None: + apify_client = ApifyClientAsync(TOKEN) + actor_client = apify_client.actor('username/actor-name') + + # Get the server's URL and the OpenAPI specification of its endpoints. + server_url = await actor_client.get_server_url() + spec = await actor_client.get_server_openapi_spec() + if spec is not None: + print(list(spec['paths'])) + + # The first request to an idle Actor waits for a server run to start. + async with httpx2.AsyncClient( + base_url=server_url, + headers={'Authorization': f'Bearer {TOKEN}'}, + timeout=360, + ) as http_client: + response = await http_client.get('/search', params={'q': 'apify'}) + print(response.json()) diff --git a/docs/02_concepts/code/14_server_other_client_sync.py b/docs/02_concepts/code/14_server_other_client_sync.py new file mode 100644 index 00000000..a0cc7fca --- /dev/null +++ b/docs/02_concepts/code/14_server_other_client_sync.py @@ -0,0 +1,25 @@ +import httpx2 + +from apify_client import ApifyClient + +TOKEN = 'MY-APIFY-TOKEN' + + +def main() -> None: + apify_client = ApifyClient(TOKEN) + actor_client = apify_client.actor('username/actor-name') + + # Get the server's URL and the OpenAPI specification of its endpoints. + server_url = actor_client.get_server_url() + spec = actor_client.get_server_openapi_spec() + if spec is not None: + print(list(spec['paths'])) + + # The first request to an idle Actor waits for a server run to start. + with httpx2.Client( + base_url=server_url, + headers={'Authorization': f'Bearer {TOKEN}'}, + timeout=360, + ) as http_client: + response = http_client.get('/search', params={'q': 'apify'}) + print(response.json()) From 1524fbabe84868dc6ac9559367983163fd44d057 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 15:15:09 +0100 Subject: [PATCH 16/36] docs: note that a replaced HttpClient.call must accept compression --- src/apify_client/http_clients/_base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 9f8616f4..9410bf71 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -486,6 +486,9 @@ class HttpClient(HttpClientBase): The client's default headers from `self._headers` have to go out with every request, otherwise the `Authorization` header never reaches the API. The inherited `call` merges them into the per-request headers through `_prepare_request_call`, so only a client that replaces `call` has to do it itself. + + A replaced `call` has to accept every argument of the inherited one, including `compression`, which + `ActorClient.server_request` always passes, as `False` unless its caller asks for compression. """ def __enter__(self) -> Self: From 4eaa7f089db28dd5deeca72da2484b2ab0002c6f Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 15:15:11 +0100 Subject: [PATCH 17/36] style: keep the Content-Encoding comment next to its check --- src/apify_client/http_clients/_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 9410bf71..b8cccebe 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -352,9 +352,10 @@ def _prepare_request_call( if isinstance(data, (str, bytes, bytearray)): content = data.encode('utf-8') if isinstance(data, str) else bytes(data) + compressor = self._http_compressor if compression is True else compression + # A caller-supplied encoding says the body arrives already encoded, so compressing it here would # both mislabel it and waste the work. - compressor = self._http_compressor if compression is True else compression if ( compressor is not False and self._get_header(headers, 'content-encoding') is None From 0addab8e4c288908465cede1ad89beac848f81ca Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 26 Oct 2026 15:15:12 +0100 Subject: [PATCH 18/36] docs: clarify the build-number lookup comment in get_server_openapi_spec --- src/apify_client/_resource_clients/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index cf9d4969..8e6216c6 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -683,7 +683,7 @@ def get_server_openapi_spec(self) -> dict[str, Any] | None: if tagged_build is not None: build_id = tagged_build.build_id else: - # Not a tag, so `build_ref` is a build number, which the API can't look up directly. + # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly. for build_item in self.builds().iterate(desc=True): if build_item.build_number == build_ref: build_id = build_item.id @@ -1381,7 +1381,7 @@ async def get_server_openapi_spec(self) -> dict[str, Any] | None: if tagged_build is not None: build_id = tagged_build.build_id else: - # Not a tag, so `build_ref` is a build number, which the API can't look up directly. + # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly. async for build_item in self.builds().iterate(desc=True): if build_item.build_number == build_ref: build_id = build_item.id From 2b9c4784fe641700867299996792b4c96e904408 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 28 Oct 2026 11:13:52 +0100 Subject: [PATCH 19/36] feat: add deprecated Standby-named aliases of the Server Actor methods --- README.md | 1 + docs/02_concepts/14_server_actors.mdx | 17 ++- src/apify_client/_resource_clients/actor.py | 158 ++++++++++++++++++-- tests/unit/test_actor_server_request.py | 46 ++++++ 4 files changed, 209 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9cb58d86..839d5c8e 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r - **Convenience methods** — `call()`, `wait_for_finish()`, nested resource access, and other shortcuts that hide platform quirks ([Convenience methods](https://docs.apify.com/api/client/python/docs/concepts/convenience-methods)). - **Pluggable HTTP layer** — use the default [Impit](https://github.com/apify/impit)-based client, opt in to the built-in [HTTPX2](https://github.com/pydantic/httpx2) client, or plug in any custom implementation ([HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)). - **Structured errors** — every API error surfaces as an [`ApifyApiError`](https://docs.apify.com/api/client/python/reference/class/ApifyApiError) with HTTP-specific subclasses for precise handling ([Error handling](https://docs.apify.com/api/client/python/docs/concepts/error-handling)). +- **Server Actors** — send HTTP requests to Actors running a web server in Server mode, also called Actor Standby, and get the server's URL and OpenAPI specification ([Server Actors](https://docs.apify.com/api/client/python/docs/concepts/server-actors)). - **Debug logging** — opt-in structured logging on the `apify_client` logger captures request URLs, status codes, retry attempts, and more ([Logging](https://docs.apify.com/api/client/python/docs/concepts/logging)). ## Usage examples diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 191bffc4..00b874d8 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -1,7 +1,8 @@ --- id: server-actors title: Server Actors -description: Send HTTP requests to Server Actors, and get their URL and OpenAPI specification. +description: Send HTTP requests to Server Actors (Actor Standby), and get their URL and OpenAPI specification. +keywords: [server actor, server mode, standby, actor standby, standby_request, standby_url] --- import Tabs from '@theme/Tabs'; @@ -31,6 +32,20 @@ A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store) or its README. +## Server mode and Actor Standby + +Server mode is the same feature as [Actor Standby](https://docs.apify.com/platform/actors/running/standby). The platform documentation and the API use the Standby name, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. If you're looking for Standby support in the client, use the methods on this page. + +The client also has the Standby-named methods. They're deprecated aliases that call the Server methods and emit a `DeprecationWarning`: + +| Deprecated Standby method | Server method | +| --- | --- | +| `ActorClient.standby_request` | `ActorClient.server_request` | +| `ActorClient.get_standby_url` | `ActorClient.get_server_url` | +| `ActorClient.get_standby_openapi_spec` | `ActorClient.get_server_openapi_spec` | + +The same aliases exist on `ActorClientAsync`. They take the same arguments and return the same values, so to switch, rename the call. + ## How the request is sent Note that: diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 8e6216c6..9c366cf9 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -636,7 +636,8 @@ def get_server_url(self) -> str: The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. + OpenAPI specification. Server mode is also called Actor Standby, and `get_standby_url` is a deprecated alias of + this method. https://docs.apify.com/platform/actors/running/standby @@ -660,7 +661,8 @@ def get_server_openapi_spec(self) -> dict[str, Any] | None: The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP - client for the server, and point that client at the URL returned by `get_server_url`. + client for the server, and point that client at the URL returned by `get_server_url`. Server mode is also + called Actor Standby, and `get_standby_openapi_spec` is a deprecated alias of this method. https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema @@ -720,10 +722,11 @@ def server_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The request - goes to the URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it - never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server - run, which is why the timeout defaults to `long`. + A Server Actor runs a web server that is called over HTTP, without starting a run for each call. Server mode is + also called Actor Standby, and `standby_request` is a deprecated alias of this method. The request goes to the + URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it never + reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server run, + which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -767,6 +770,70 @@ def server_request( compression=resolve_compressor(compression) if compression is not None else False, ) + def get_standby_url(self) -> str: + """Return the URL of the Actor's server in Standby mode. + + Deprecated alias of `get_server_url`, named after Actor Standby. Use `get_server_url` instead. + + https://docs.apify.com/platform/actors/running/standby + """ + warnings.warn( + '`get_standby_url` is deprecated. Use `get_server_url` instead.', + DeprecationWarning, + stacklevel=3, + ) + return self.get_server_url() + + def get_standby_openapi_spec(self) -> dict[str, Any] | None: + """Return the OpenAPI specification of the Actor's server in Standby mode. + + Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Use `get_server_openapi_spec` + instead. + + https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + """ + warnings.warn( + '`get_standby_openapi_spec` is deprecated. Use `get_server_openapi_spec` instead.', + DeprecationWarning, + stacklevel=3, + ) + return self.get_server_openapi_spec() + + def standby_request( + self, + path: str = '/', + *, + method: HttpMethod = 'GET', + params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, + headers: dict[str, str] | None = None, + data: str | bytes | None = None, + json: JsonSerializable | None = None, + compression: HttpCompressionAlgorithm | HttpCompressor | None = None, + timeout: Timeout = 'long', + ) -> HttpResponse: + """Send an HTTP request to the Actor's server in Standby mode. + + Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Use + `server_request` instead. + + https://docs.apify.com/platform/actors/running/standby + """ + warnings.warn( + '`standby_request` is deprecated. Use `server_request` instead.', + DeprecationWarning, + stacklevel=3, + ) + return self.server_request( + path, + method=method, + params=params, + headers=headers, + data=data, + json=json, + compression=compression, + timeout=timeout, + ) + @docs_group('Resource clients') class ActorClientAsync(ResourceClientAsync): @@ -1334,7 +1401,8 @@ async def get_server_url(self) -> str: The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. + OpenAPI specification. Server mode is also called Actor Standby, and `get_standby_url` is a deprecated alias of + this method. https://docs.apify.com/platform/actors/running/standby @@ -1358,7 +1426,8 @@ async def get_server_openapi_spec(self) -> dict[str, Any] | None: The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP - client for the server, and point that client at the URL returned by `get_server_url`. + client for the server, and point that client at the URL returned by `get_server_url`. Server mode is also + called Actor Standby, and `get_standby_openapi_spec` is a deprecated alias of this method. https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema @@ -1418,10 +1487,11 @@ async def server_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The request - goes to the URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it - never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server - run, which is why the timeout defaults to `long`. + A Server Actor runs a web server that is called over HTTP, without starting a run for each call. Server mode is + also called Actor Standby, and `standby_request` is a deprecated alias of this method. The request goes to the + URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it never + reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server run, + which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -1464,3 +1534,67 @@ async def server_request( timeout=timeout, compression=resolve_compressor(compression) if compression is not None else False, ) + + async def get_standby_url(self) -> str: + """Return the URL of the Actor's server in Standby mode. + + Deprecated alias of `get_server_url`, named after Actor Standby. Use `get_server_url` instead. + + https://docs.apify.com/platform/actors/running/standby + """ + warnings.warn( + '`get_standby_url` is deprecated. Use `get_server_url` instead.', + DeprecationWarning, + stacklevel=3, + ) + return await self.get_server_url() + + async def get_standby_openapi_spec(self) -> dict[str, Any] | None: + """Return the OpenAPI specification of the Actor's server in Standby mode. + + Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Use `get_server_openapi_spec` + instead. + + https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + """ + warnings.warn( + '`get_standby_openapi_spec` is deprecated. Use `get_server_openapi_spec` instead.', + DeprecationWarning, + stacklevel=3, + ) + return await self.get_server_openapi_spec() + + async def standby_request( + self, + path: str = '/', + *, + method: HttpMethod = 'GET', + params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, + headers: dict[str, str] | None = None, + data: str | bytes | None = None, + json: JsonSerializable | None = None, + compression: HttpCompressionAlgorithm | HttpCompressor | None = None, + timeout: Timeout = 'long', + ) -> HttpResponse: + """Send an HTTP request to the Actor's server in Standby mode. + + Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Use + `server_request` instead. + + https://docs.apify.com/platform/actors/running/standby + """ + warnings.warn( + '`standby_request` is deprecated. Use `server_request` instead.', + DeprecationWarning, + stacklevel=3, + ) + return await self.server_request( + path, + method=method, + params=params, + headers=headers, + data=data, + json=json, + compression=compression, + timeout=timeout, + ) diff --git a/tests/unit/test_actor_server_request.py b/tests/unit/test_actor_server_request.py index c9a9b205..04e730f9 100644 --- a/tests/unit/test_actor_server_request.py +++ b/tests/unit/test_actor_server_request.py @@ -442,3 +442,49 @@ def test_get_server_openapi_spec_without_server_mode_raises(httpserver: HTTPServ with pytest.raises(ApifyClientError, match='is not a Server Actor'): sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() + + +def test_standby_aliases_warn_and_delegate(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """The deprecated Standby-named methods emit a `DeprecationWarning` pointing at the caller and return the same.""" + expect_server_actor_with_build( + httpserver, standby_build=None, build_id='latest_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='GET').respond_with_json({'ok': True}) + actor_client = sync_client.actor(_MOCKED_ACTOR_ID) + + with pytest.warns(DeprecationWarning, match='`get_standby_url` is deprecated') as record: + assert actor_client.get_standby_url() == httpserver.url_for(_SERVER_PREFIX) + assert record[0].filename == __file__ + + with pytest.warns(DeprecationWarning, match='`get_standby_openapi_spec` is deprecated') as record: + assert actor_client.get_standby_openapi_spec() == _OPENAPI_SPEC + assert record[0].filename == __file__ + + with pytest.warns(DeprecationWarning, match='`standby_request` is deprecated') as record: + response = actor_client.standby_request('/search', params={'q': 'apify'}) + assert record[0].filename == __file__ + assert response.json() == {'ok': True} + assert server_requests(httpserver)[0].args.to_dict() == {'q': 'apify'} + + +async def test_standby_aliases_warn_and_delegate_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_standby_aliases_warn_and_delegate`.""" + expect_server_actor_with_build( + httpserver, standby_build=None, build_id='latest_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='GET').respond_with_json({'ok': True}) + actor_client = async_client.actor(_MOCKED_ACTOR_ID) + + with pytest.warns(DeprecationWarning, match='`get_standby_url` is deprecated') as record: + assert await actor_client.get_standby_url() == httpserver.url_for(_SERVER_PREFIX) + assert record[0].filename == __file__ + + with pytest.warns(DeprecationWarning, match='`get_standby_openapi_spec` is deprecated') as record: + assert await actor_client.get_standby_openapi_spec() == _OPENAPI_SPEC + assert record[0].filename == __file__ + + with pytest.warns(DeprecationWarning, match='`standby_request` is deprecated') as record: + response = await actor_client.standby_request('/search', params={'q': 'apify'}) + assert record[0].filename == __file__ + assert response.json() == {'ok': True} + assert server_requests(httpserver)[0].args.to_dict() == {'q': 'apify'} From a39c78f372ccf6f923da4419443374ab1ef04d50 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 28 Oct 2026 11:30:41 +0100 Subject: [PATCH 20/36] docs: use "server interface" terminology for Server Actors, matching the CLI and MCP server --- README.md | 2 +- docs/02_concepts/14_server_actors.mdx | 8 ++-- src/apify_client/_resource_clients/actor.py | 50 +++++++++++---------- tests/unit/test_actor_server_request.py | 12 ++--- 4 files changed, 37 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 839d5c8e..d3f78354 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r - **Convenience methods** — `call()`, `wait_for_finish()`, nested resource access, and other shortcuts that hide platform quirks ([Convenience methods](https://docs.apify.com/api/client/python/docs/concepts/convenience-methods)). - **Pluggable HTTP layer** — use the default [Impit](https://github.com/apify/impit)-based client, opt in to the built-in [HTTPX2](https://github.com/pydantic/httpx2) client, or plug in any custom implementation ([HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)). - **Structured errors** — every API error surfaces as an [`ApifyApiError`](https://docs.apify.com/api/client/python/reference/class/ApifyApiError) with HTTP-specific subclasses for precise handling ([Error handling](https://docs.apify.com/api/client/python/docs/concepts/error-handling)). -- **Server Actors** — send HTTP requests to Actors running a web server in Server mode, also called Actor Standby, and get the server's URL and OpenAPI specification ([Server Actors](https://docs.apify.com/api/client/python/docs/concepts/server-actors)). +- **Server Actors** — send HTTP requests to an Actor's server interface (Actor Standby), and get the server's URL and OpenAPI specification ([Server Actors](https://docs.apify.com/api/client/python/docs/concepts/server-actors)). - **Debug logging** — opt-in structured logging on the `apify_client` logger captures request URLs, status codes, retry attempts, and more ([Logging](https://docs.apify.com/api/client/python/docs/concepts/logging)). ## Usage examples diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 00b874d8..7511ae03 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -2,7 +2,7 @@ id: server-actors title: Server Actors description: Send HTTP requests to Server Actors (Actor Standby), and get their URL and OpenAPI specification. -keywords: [server actor, server mode, standby, actor standby, standby_request, standby_url] +keywords: [server actor, server interface, standby, actor standby, standby_request, standby_url] --- import Tabs from '@theme/Tabs'; @@ -32,9 +32,9 @@ A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store) or its README. -## Server mode and Actor Standby +## Server Actors and Actor Standby -Server mode is the same feature as [Actor Standby](https://docs.apify.com/platform/actors/running/standby). The platform documentation and the API use the Standby name, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. If you're looking for Standby support in the client, use the methods on this page. +Server Actors are Actors with [Actor Standby](https://docs.apify.com/platform/actors/running/standby) enabled, and their web server is the Actor's server interface. The platform documentation and the API use the Standby name, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. If you're looking for Standby support in the client, use the methods on this page. The client also has the Standby-named methods. They're deprecated aliases that call the Server methods and emit a `DeprecationWarning`: @@ -59,7 +59,7 @@ Note that: For an Actor with many endpoints, you may prefer a client generated from the server's OpenAPI specification, or an HTTP client you already use. Two methods give such a client what it needs: - `ActorClient.get_server_url` returns the server's URL. It fetches and reuses the URL the same way as `server_request`. -- `ActorClient.get_server_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build the Actor runs in Server mode. That's the build set in the Actor's Standby settings, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. +- `ActorClient.get_server_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build that serves the server interface. That's the build set in the Actor's Standby settings, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. A request from another client needs your Apify token in the `Authorization` header, and a timeout long enough for a server run to start. For details, see [Authentication](#authentication) and [Timeouts, retries, and errors](#timeouts-retries-and-errors). The following example uses [HTTPX2](https://github.com/pydantic/httpx2), which you can install with the `httpx2` extra: diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 9c366cf9..6db8e8d1 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -636,8 +636,8 @@ def get_server_url(self) -> str: The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. Server mode is also called Actor Standby, and `get_standby_url` is a deprecated alias of - this method. + OpenAPI specification. Server Actors are also called Standby Actors, and `get_standby_url` is a deprecated alias + of this method. https://docs.apify.com/platform/actors/running/standby @@ -659,10 +659,10 @@ def get_server_url(self) -> str: def get_server_openapi_spec(self) -> dict[str, Any] | None: """Return the OpenAPI specification of the Actor's server. - The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in - Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP - client for the server, and point that client at the URL returned by `get_server_url`. Server mode is also - called Actor Standby, and `get_standby_openapi_spec` is a deprecated alias of this method. + The specification is the web server schema from the Actor's definition, taken from the build that serves the + server interface: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP + client for the server, and point that client at the URL returned by `get_server_url`. Server Actors are also + called Standby Actors, and `get_standby_openapi_spec` is a deprecated alias of this method. https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema @@ -670,7 +670,8 @@ def get_server_openapi_spec(self) -> dict[str, Any] | None: The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. Raises: - ApifyClientError: If the Actor is not a Server Actor, or its Server mode build doesn't exist. + ApifyClientError: If the Actor is not a Server Actor, or the build that serves its server interface doesn't + exist. ApifyApiError: If the Actor does not exist. """ response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') @@ -722,11 +723,11 @@ def server_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - A Server Actor runs a web server that is called over HTTP, without starting a run for each call. Server mode is - also called Actor Standby, and `standby_request` is a deprecated alias of this method. The request goes to the - URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it never - reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server run, - which is why the timeout defaults to `long`. + A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each + call. Server Actors are also called Standby Actors, and `standby_request` is a deprecated alias of this method. + The request goes to the URL returned by `get_server_url` and carries the client's token, which the platform + consumes, so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to + start a server run, which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -1401,8 +1402,8 @@ async def get_server_url(self) -> str: The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. Server mode is also called Actor Standby, and `get_standby_url` is a deprecated alias of - this method. + OpenAPI specification. Server Actors are also called Standby Actors, and `get_standby_url` is a deprecated alias + of this method. https://docs.apify.com/platform/actors/running/standby @@ -1424,10 +1425,10 @@ async def get_server_url(self) -> str: async def get_server_openapi_spec(self) -> dict[str, Any] | None: """Return the OpenAPI specification of the Actor's server. - The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in - Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP - client for the server, and point that client at the URL returned by `get_server_url`. Server mode is also - called Actor Standby, and `get_standby_openapi_spec` is a deprecated alias of this method. + The specification is the web server schema from the Actor's definition, taken from the build that serves the + server interface: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP + client for the server, and point that client at the URL returned by `get_server_url`. Server Actors are also + called Standby Actors, and `get_standby_openapi_spec` is a deprecated alias of this method. https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema @@ -1435,7 +1436,8 @@ async def get_server_openapi_spec(self) -> dict[str, Any] | None: The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. Raises: - ApifyClientError: If the Actor is not a Server Actor, or its Server mode build doesn't exist. + ApifyClientError: If the Actor is not a Server Actor, or the build that serves its server interface doesn't + exist. ApifyApiError: If the Actor does not exist. """ response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') @@ -1487,11 +1489,11 @@ async def server_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - A Server Actor runs a web server that is called over HTTP, without starting a run for each call. Server mode is - also called Actor Standby, and `standby_request` is a deprecated alias of this method. The request goes to the - URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it never - reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server run, - which is why the timeout defaults to `long`. + A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each + call. Server Actors are also called Standby Actors, and `standby_request` is a deprecated alias of this method. + The request goes to the URL returned by `get_server_url` and carries the client's token, which the platform + consumes, so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to + start a server run, which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's diff --git a/tests/unit/test_actor_server_request.py b/tests/unit/test_actor_server_request.py index 04e730f9..7ad9a203 100644 --- a/tests/unit/test_actor_server_request.py +++ b/tests/unit/test_actor_server_request.py @@ -144,7 +144,7 @@ async def test_get_server_url_returns_standby_url_async(httpserver: HTTPServer, def test_get_server_url_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """`get_server_url` raises for an Actor without Server mode enabled.""" + """`get_server_url` raises for an Actor without a server interface.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) with pytest.raises(ApifyClientError, match='is not a Server Actor'): @@ -266,7 +266,7 @@ def test_server_request_keeps_caller_content_encoding(httpserver: HTTPServer, sy def test_server_request_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """An Actor without Server mode enabled raises an error before any request to a server.""" + """An Actor without a server interface raises an error before any request to a server.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) with pytest.raises(ApifyClientError, match='is not a Server Actor'): @@ -368,7 +368,7 @@ def expect_server_actor_with_build( def test_get_server_openapi_spec_from_tagged_build( httpserver: HTTPServer, sync_client: ApifyClient, standby_build: str | None, build_id: str ) -> None: - """The specification comes from the web server schema of the build tagged for Server mode.""" + """The specification comes from the web server schema of the tagged build that serves the server interface.""" expect_server_actor_with_build( httpserver, standby_build=standby_build, build_id=build_id, actor_definition={'webServerSchema': _OPENAPI_SPEC} ) @@ -388,7 +388,7 @@ async def test_get_server_openapi_spec_from_tagged_build_async( def test_get_server_openapi_spec_from_build_number(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """A build number set for Server mode is looked up in the Actor's builds.""" + """A build number set for the server interface is looked up in the Actor's builds.""" expect_server_actor_with_build( httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} ) @@ -428,7 +428,7 @@ def test_get_server_openapi_spec_without_schema_returns_none( def test_get_server_openapi_spec_unknown_build_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """A Server mode build that is neither a tag nor a listed build number raises an error.""" + """A server interface build that is neither a tag nor a listed build number raises an error.""" expect_server_actor_with_build(httpserver, standby_build='9.9.9', build_id='unused_build_id', actor_definition=None) expect_builds_list(httpserver, {'latest_build_id': '0.0.2'}) @@ -437,7 +437,7 @@ def test_get_server_openapi_spec_unknown_build_raises(httpserver: HTTPServer, sy def test_get_server_openapi_spec_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """`get_server_openapi_spec` raises for an Actor without Server mode enabled.""" + """`get_server_openapi_spec` raises for an Actor without a server interface.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) with pytest.raises(ApifyClientError, match='is not a Server Actor'): From 9d40e33ffefccd4b29d9f80e48426e0a4b2895af Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 28 Oct 2026 11:41:10 +0100 Subject: [PATCH 21/36] refactor: say in the Standby alias deprecations that they will be removed in v4 --- docs/02_concepts/14_server_actors.mdx | 2 +- src/apify_client/_resource_clients/actor.py | 36 ++++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 7511ae03..31ad0c9f 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -36,7 +36,7 @@ The method returns the server's response. To find out which endpoints an Actor s Server Actors are Actors with [Actor Standby](https://docs.apify.com/platform/actors/running/standby) enabled, and their web server is the Actor's server interface. The platform documentation and the API use the Standby name, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. If you're looking for Standby support in the client, use the methods on this page. -The client also has the Standby-named methods. They're deprecated aliases that call the Server methods and emit a `DeprecationWarning`: +The client also has the Standby-named methods. They're deprecated aliases that call the Server methods and emit a `DeprecationWarning`, and they'll be removed in v4: | Deprecated Standby method | Server method | | --- | --- | diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 6db8e8d1..cd15d19a 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -774,12 +774,13 @@ def server_request( def get_standby_url(self) -> str: """Return the URL of the Actor's server in Standby mode. - Deprecated alias of `get_server_url`, named after Actor Standby. Use `get_server_url` instead. + Deprecated alias of `get_server_url`, named after Actor Standby. Will be removed in v4. Use `get_server_url` + instead. https://docs.apify.com/platform/actors/running/standby """ warnings.warn( - '`get_standby_url` is deprecated. Use `get_server_url` instead.', + '`get_standby_url` is deprecated and will be removed in v4. Use `get_server_url` instead.', DeprecationWarning, stacklevel=3, ) @@ -788,13 +789,14 @@ def get_standby_url(self) -> str: def get_standby_openapi_spec(self) -> dict[str, Any] | None: """Return the OpenAPI specification of the Actor's server in Standby mode. - Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Use `get_server_openapi_spec` - instead. + Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Will be removed in v4. Use + `get_server_openapi_spec` instead. https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema """ warnings.warn( - '`get_standby_openapi_spec` is deprecated. Use `get_server_openapi_spec` instead.', + '`get_standby_openapi_spec` is deprecated and will be removed in v4. ' + 'Use `get_server_openapi_spec` instead.', DeprecationWarning, stacklevel=3, ) @@ -814,13 +816,13 @@ def standby_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server in Standby mode. - Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Use - `server_request` instead. + Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Will be removed in + v4. Use `server_request` instead. https://docs.apify.com/platform/actors/running/standby """ warnings.warn( - '`standby_request` is deprecated. Use `server_request` instead.', + '`standby_request` is deprecated and will be removed in v4. Use `server_request` instead.', DeprecationWarning, stacklevel=3, ) @@ -1540,12 +1542,13 @@ async def server_request( async def get_standby_url(self) -> str: """Return the URL of the Actor's server in Standby mode. - Deprecated alias of `get_server_url`, named after Actor Standby. Use `get_server_url` instead. + Deprecated alias of `get_server_url`, named after Actor Standby. Will be removed in v4. Use `get_server_url` + instead. https://docs.apify.com/platform/actors/running/standby """ warnings.warn( - '`get_standby_url` is deprecated. Use `get_server_url` instead.', + '`get_standby_url` is deprecated and will be removed in v4. Use `get_server_url` instead.', DeprecationWarning, stacklevel=3, ) @@ -1554,13 +1557,14 @@ async def get_standby_url(self) -> str: async def get_standby_openapi_spec(self) -> dict[str, Any] | None: """Return the OpenAPI specification of the Actor's server in Standby mode. - Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Use `get_server_openapi_spec` - instead. + Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Will be removed in v4. Use + `get_server_openapi_spec` instead. https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema """ warnings.warn( - '`get_standby_openapi_spec` is deprecated. Use `get_server_openapi_spec` instead.', + '`get_standby_openapi_spec` is deprecated and will be removed in v4. ' + 'Use `get_server_openapi_spec` instead.', DeprecationWarning, stacklevel=3, ) @@ -1580,13 +1584,13 @@ async def standby_request( ) -> HttpResponse: """Send an HTTP request to the Actor's server in Standby mode. - Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Use - `server_request` instead. + Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Will be removed in + v4. Use `server_request` instead. https://docs.apify.com/platform/actors/running/standby """ warnings.warn( - '`standby_request` is deprecated. Use `server_request` instead.', + '`standby_request` is deprecated and will be removed in v4. Use `server_request` instead.', DeprecationWarning, stacklevel=3, ) From 11fea02d89fb539c1567d23b90e0dc9fd3c44753 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 09:38:17 +0100 Subject: [PATCH 22/36] refactor: move the Server Actor methods to an ActorServerClient sub-client --- docs/02_concepts/14_server_actors.mdx | 22 +- docs/02_concepts/code/14_server_async.py | 6 +- .../code/14_server_other_client_async.py | 6 +- .../code/14_server_other_client_sync.py | 6 +- docs/02_concepts/code/14_server_sync.py | 6 +- src/apify_client/_apify_client.py | 4 + src/apify_client/_client_registry.py | 4 + .../_resource_clients/__init__.py | 3 + src/apify_client/_resource_clients/actor.py | 421 ++---------------- .../_resource_clients/actor_server.py | 345 ++++++++++++++ src/apify_client/http_clients/_base.py | 2 +- ...server_request.py => test_actor_server.py} | 199 ++++----- 12 files changed, 506 insertions(+), 518 deletions(-) create mode 100644 src/apify_client/_resource_clients/actor_server.py rename tests/unit/{test_actor_server_request.py => test_actor_server.py} (64%) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 31ad0c9f..77bf6cfd 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -2,7 +2,7 @@ id: server-actors title: Server Actors description: Send HTTP requests to Server Actors (Actor Standby), and get their URL and OpenAPI specification. -keywords: [server actor, server interface, standby, actor standby, standby_request, standby_url] +keywords: [server actor, server interface, standby, actor standby, standby_url, ActorServerClient] --- import Tabs from '@theme/Tabs'; @@ -15,7 +15,7 @@ import ServerSyncExample from '!!raw-loader!./code/14_server_sync.py'; import ServerOtherClientAsyncExample from '!!raw-loader!./code/14_server_other_client_async.py'; import ServerOtherClientSyncExample from '!!raw-loader!./code/14_server_other_client_sync.py'; -A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.server_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: +A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To work with such an Actor, get its server client with `ActorClient.server`. To send a request, use `ActorServerClient.request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: @@ -34,23 +34,15 @@ The method returns the server's response. To find out which endpoints an Actor s ## Server Actors and Actor Standby -Server Actors are Actors with [Actor Standby](https://docs.apify.com/platform/actors/running/standby) enabled, and their web server is the Actor's server interface. The platform documentation and the API use the Standby name, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. If you're looking for Standby support in the client, use the methods on this page. +Server Actors are Actors with [Actor Standby](https://docs.apify.com/platform/actors/running/standby) enabled, and their web server is the Actor's server interface. The platform documentation and the API use the Standby name, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. If you're looking for Standby support in the client, use the server client described on this page. -The client also has the Standby-named methods. They're deprecated aliases that call the Server methods and emit a `DeprecationWarning`, and they'll be removed in v4: - -| Deprecated Standby method | Server method | -| --- | --- | -| `ActorClient.standby_request` | `ActorClient.server_request` | -| `ActorClient.get_standby_url` | `ActorClient.get_server_url` | -| `ActorClient.get_standby_openapi_spec` | `ActorClient.get_server_openapi_spec` | - -The same aliases exist on `ActorClientAsync`. They take the same arguments and return the same values, so to switch, rename the call. +The client also has a Standby-named method. `ActorClient.standby` is a deprecated alias of `ActorClient.server`. It returns the same server client, emits a `DeprecationWarning`, and will be removed in v4. To switch, rename the call, for example `actor_client.standby().request('/search')` to `actor_client.server().request('/search')`. The same alias exists on `ActorClientAsync`. ## How the request is sent Note that: -- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor isn't a Server Actor, the method raises an `ApifyClientError`. +- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls through the same `ActorClient`. If the Actor isn't a Server Actor, the server client raises an `ApifyClientError`. - The path is always resolved against that URL, so a path can't send the request to another host. - By default, the request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. @@ -58,8 +50,8 @@ Note that: For an Actor with many endpoints, you may prefer a client generated from the server's OpenAPI specification, or an HTTP client you already use. Two methods give such a client what it needs: -- `ActorClient.get_server_url` returns the server's URL. It fetches and reuses the URL the same way as `server_request`. -- `ActorClient.get_server_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build that serves the server interface. That's the build set in the Actor's Standby settings, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. +- `ActorServerClient.get_url` returns the server's URL. It fetches and reuses the URL the same way as `request`. +- `ActorServerClient.get_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build that serves the server interface. That's the build set in the Actor's Standby settings, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. A request from another client needs your Apify token in the `Authorization` header, and a timeout long enough for a server run to start. For details, see [Authentication](#authentication) and [Timeouts, retries, and errors](#timeouts-retries-and-errors). The following example uses [HTTPX2](https://github.com/pydantic/httpx2), which you can install with the `httpx2` extra: diff --git a/docs/02_concepts/code/14_server_async.py b/docs/02_concepts/code/14_server_async.py index 4d8edb47..cd98ad2e 100644 --- a/docs/02_concepts/code/14_server_async.py +++ b/docs/02_concepts/code/14_server_async.py @@ -5,14 +5,14 @@ async def main() -> None: apify_client = ApifyClientAsync(TOKEN) - actor_client = apify_client.actor('username/actor-name') + server_client = apify_client.actor('username/actor-name').server() # Send a GET request to the /search endpoint of the Actor's server. - response = await actor_client.server_request('/search', params={'q': 'apify'}) + response = await server_client.request('/search', params={'q': 'apify'}) print(response.json()) # Send a POST request with a JSON body. - response = await actor_client.server_request( + response = await server_client.request( '/items', method='POST', json={'url': 'https://apify.com'}, diff --git a/docs/02_concepts/code/14_server_other_client_async.py b/docs/02_concepts/code/14_server_other_client_async.py index 72166e00..9d3c4b35 100644 --- a/docs/02_concepts/code/14_server_other_client_async.py +++ b/docs/02_concepts/code/14_server_other_client_async.py @@ -7,11 +7,11 @@ async def main() -> None: apify_client = ApifyClientAsync(TOKEN) - actor_client = apify_client.actor('username/actor-name') + server_client = apify_client.actor('username/actor-name').server() # Get the server's URL and the OpenAPI specification of its endpoints. - server_url = await actor_client.get_server_url() - spec = await actor_client.get_server_openapi_spec() + server_url = await server_client.get_url() + spec = await server_client.get_openapi_spec() if spec is not None: print(list(spec['paths'])) diff --git a/docs/02_concepts/code/14_server_other_client_sync.py b/docs/02_concepts/code/14_server_other_client_sync.py index a0cc7fca..28225d13 100644 --- a/docs/02_concepts/code/14_server_other_client_sync.py +++ b/docs/02_concepts/code/14_server_other_client_sync.py @@ -7,11 +7,11 @@ def main() -> None: apify_client = ApifyClient(TOKEN) - actor_client = apify_client.actor('username/actor-name') + server_client = apify_client.actor('username/actor-name').server() # Get the server's URL and the OpenAPI specification of its endpoints. - server_url = actor_client.get_server_url() - spec = actor_client.get_server_openapi_spec() + server_url = server_client.get_url() + spec = server_client.get_openapi_spec() if spec is not None: print(list(spec['paths'])) diff --git a/docs/02_concepts/code/14_server_sync.py b/docs/02_concepts/code/14_server_sync.py index c56e06bf..474fc34f 100644 --- a/docs/02_concepts/code/14_server_sync.py +++ b/docs/02_concepts/code/14_server_sync.py @@ -5,14 +5,14 @@ def main() -> None: apify_client = ApifyClient(TOKEN) - actor_client = apify_client.actor('username/actor-name') + server_client = apify_client.actor('username/actor-name').server() # Send a GET request to the /search endpoint of the Actor's server. - response = actor_client.server_request('/search', params={'q': 'apify'}) + response = server_client.request('/search', params={'q': 'apify'}) print(response.json()) # Send a POST request with a JSON body. - response = actor_client.server_request( + response = server_client.request( '/items', method='POST', json={'url': 'https://apify.com'}, diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index c15add93..aac0da66 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -23,6 +23,8 @@ ActorEnvVarClientAsync, ActorEnvVarCollectionClient, ActorEnvVarCollectionClientAsync, + ActorServerClient, + ActorServerClientAsync, ActorVersionClient, ActorVersionClientAsync, ActorVersionCollectionClient, @@ -188,6 +190,7 @@ def __init__( actor_collection_client=ActorCollectionClient, actor_env_var_client=ActorEnvVarClient, actor_env_var_collection_client=ActorEnvVarCollectionClient, + actor_server_client=ActorServerClient, actor_version_client=ActorVersionClient, actor_version_collection_client=ActorVersionCollectionClient, build_client=BuildClient, @@ -567,6 +570,7 @@ def __init__( actor_collection_client=ActorCollectionClientAsync, actor_env_var_client=ActorEnvVarClientAsync, actor_env_var_collection_client=ActorEnvVarCollectionClientAsync, + actor_server_client=ActorServerClientAsync, actor_version_client=ActorVersionClientAsync, actor_version_collection_client=ActorVersionCollectionClientAsync, build_client=BuildClientAsync, diff --git a/src/apify_client/_client_registry.py b/src/apify_client/_client_registry.py index 5f3c7994..a99d3008 100644 --- a/src/apify_client/_client_registry.py +++ b/src/apify_client/_client_registry.py @@ -13,6 +13,8 @@ ActorEnvVarClientAsync, ActorEnvVarCollectionClient, ActorEnvVarCollectionClientAsync, + ActorServerClient, + ActorServerClientAsync, ActorVersionClient, ActorVersionClientAsync, ActorVersionCollectionClient, @@ -74,6 +76,7 @@ class ClientRegistry: actor_collection_client: type[ActorCollectionClient] actor_env_var_client: type[ActorEnvVarClient] actor_env_var_collection_client: type[ActorEnvVarCollectionClient] + actor_server_client: type[ActorServerClient] actor_version_client: type[ActorVersionClient] actor_version_collection_client: type[ActorVersionCollectionClient] build_client: type[BuildClient] @@ -111,6 +114,7 @@ class ClientRegistryAsync: actor_collection_client: type[ActorCollectionClientAsync] actor_env_var_client: type[ActorEnvVarClientAsync] actor_env_var_collection_client: type[ActorEnvVarCollectionClientAsync] + actor_server_client: type[ActorServerClientAsync] actor_version_client: type[ActorVersionClientAsync] actor_version_collection_client: type[ActorVersionCollectionClientAsync] build_client: type[BuildClientAsync] diff --git a/src/apify_client/_resource_clients/__init__.py b/src/apify_client/_resource_clients/__init__.py index e818ce34..3c978c89 100644 --- a/src/apify_client/_resource_clients/__init__.py +++ b/src/apify_client/_resource_clients/__init__.py @@ -2,6 +2,7 @@ from .actor_collection import ActorCollectionClient, ActorCollectionClientAsync from .actor_env_var import ActorEnvVarClient, ActorEnvVarClientAsync from .actor_env_var_collection import ActorEnvVarCollectionClient, ActorEnvVarCollectionClientAsync +from .actor_server import ActorServerClient, ActorServerClientAsync from .actor_version import ActorVersionClient, ActorVersionClientAsync from .actor_version_collection import ActorVersionCollectionClient, ActorVersionCollectionClientAsync from .build import BuildClient, BuildClientAsync @@ -35,6 +36,8 @@ 'ActorEnvVarClientAsync', 'ActorEnvVarCollectionClient', 'ActorEnvVarCollectionClientAsync', + 'ActorServerClient', + 'ActorServerClientAsync', 'ActorVersionClient', 'ActorVersionClientAsync', 'ActorVersionCollectionClient', diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index cd15d19a..86521258 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -32,17 +32,16 @@ start_waiting_for_resources, start_waiting_for_resources_async, ) -from apify_client.errors import ApifyClientError -from apify_client.http_compressors._resolve import resolve_compressor if TYPE_CHECKING: - from collections.abc import Mapping - from datetime import datetime, timedelta + from datetime import timedelta from decimal import Decimal from logging import Logger - from apify_client._literals import ActorJobStatus, ActorPermissionLevel, HttpMethod, RunOrigin + from apify_client._literals import ActorJobStatus, ActorPermissionLevel, RunOrigin from apify_client._resource_clients import ( + ActorServerClient, + ActorServerClientAsync, ActorVersionClient, ActorVersionClientAsync, ActorVersionCollectionClient, @@ -58,9 +57,7 @@ WebhookCollectionClient, WebhookCollectionClientAsync, ) - from apify_client.http_clients import HttpResponse - from apify_client.http_compressors import HttpCompressor - from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, Timeout, WebhooksList + from apify_client.types import Timeout, WebhooksList _PricingInfo = ( PayPerEventActorPricingInfo @@ -91,7 +88,7 @@ def __init__( resource_path=resource_path, **kwargs, ) - self._server_url: str | None = None + self._server_client: ActorServerClient | None = None def get(self, *, timeout: Timeout = 'short') -> ActorResource | None: """Retrieve the Actor. @@ -631,211 +628,43 @@ def validate_input( return True - def get_server_url(self) -> str: - """Return the URL of the Actor's server. + def server(self) -> ActorServerClient: + """Retrieve a client for the server interface of this Actor. - The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this - client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. Server Actors are also called Standby Actors, and `get_standby_url` is a deprecated alias - of this method. + A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each + call. Server Actors are also called Standby Actors, and `standby` is a deprecated alias of this method. Every + call returns the same client, so the server URL it fetches is reused. https://docs.apify.com/platform/actors/running/standby Returns: - The URL of the Actor's server. - - Raises: - ApifyClientError: If the Actor is not a Server Actor. - ApifyApiError: If the Actor does not exist. - """ - if self._server_url is None: - response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') - actor = ActorResponse.model_validate(response_to_dict(response)).data - if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') - self._server_url = actor.standby_url - return self._server_url - - def get_server_openapi_spec(self) -> dict[str, Any] | None: - """Return the OpenAPI specification of the Actor's server. - - The specification is the web server schema from the Actor's definition, taken from the build that serves the - server interface: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP - client for the server, and point that client at the URL returned by `get_server_url`. Server Actors are also - called Standby Actors, and `get_standby_openapi_spec` is a deprecated alias of this method. - - https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema - - Returns: - The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. - - Raises: - ApifyClientError: If the Actor is not a Server Actor, or the build that serves its server interface doesn't - exist. - ApifyApiError: If the Actor does not exist. + The client for the server interface of this Actor. """ - response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') - actor = ActorResponse.model_validate(response_to_dict(response)).data - if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') - - build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' - - build_id = None - tagged_build = (actor.tagged_builds or {}).get(build_ref) - if tagged_build is not None: - build_id = tagged_build.build_id - else: - # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly. - for build_item in self.builds().iterate(desc=True): - if build_item.build_number == build_ref: - build_id = build_item.id - break - - build = None - if build_id is not None: - build_client = self._client_registry.build_client( - resource_id=build_id, + if self._server_client is None: + self._server_client = self._client_registry.actor_server_client( + resource_id=self._resource_id, + resource_path=self._resource_path, base_url=self._base_url, + api_base_url=self._api_base_url, public_base_url=self._public_base_url, http_client=self._http_client, client_registry=self._client_registry, ) - build = build_client.get() - if build is None: - raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') - - if build.actor_definition is None: - return None - return build.actor_definition.web_server_schema - - def server_request( - self, - path: str = '/', - *, - method: HttpMethod = 'GET', - params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, - headers: dict[str, str] | None = None, - data: str | bytes | None = None, - json: JsonSerializable | None = None, - compression: HttpCompressionAlgorithm | HttpCompressor | None = None, - timeout: Timeout = 'long', - ) -> HttpResponse: - """Send an HTTP request to the Actor's server. + return self._server_client - A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each - call. Server Actors are also called Standby Actors, and `standby_request` is a deprecated alias of this method. - The request goes to the URL returned by `get_server_url` and carries the client's token, which the platform - consumes, so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to - start a server run, which is why the timeout defaults to `long`. + def standby(self) -> ActorServerClient: + """Retrieve a client for the server interface of this Actor in Standby mode. - A server error or a rate-limit response is retried like an API call, so the server may receive the same - request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's - server may not decode a compressed body. + Deprecated alias of `server`, named after Actor Standby. Will be removed in v4. Use `server` instead. https://docs.apify.com/platform/actors/running/standby - - Args: - path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL, - so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. - method: HTTP method of the request. - params: Query parameters of the request, converted the same way as for API calls: booleans become `true` - or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string - in UTC, and `None` values are left out. - headers: Additional headers of the request. - data: Raw request body. Cannot be used together with `json`. - json: JSON-serializable request body. Cannot be used together with `data`. - compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor` - instance. The body is compressed under the same conditions as for API calls. Set it only if the - Actor's server decodes the resulting `Content-Encoding`. - timeout: Timeout for the HTTP request. - - Returns: - The response of the Actor's server. - - Raises: - ApifyClientError: If the Actor is not a Server Actor. - ApifyApiError: If the Actor does not exist, or the server responds with an error status. - ImportError: If `compression` needs an optional extra that is not installed. - ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. - """ - server_url = self.get_server_url() - return self._http_client.call( - url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', - method=method, - params=dict(params) if params is not None else None, - headers=headers, - data=data, - json=json, - timeout=timeout, - compression=resolve_compressor(compression) if compression is not None else False, - ) - - def get_standby_url(self) -> str: - """Return the URL of the Actor's server in Standby mode. - - Deprecated alias of `get_server_url`, named after Actor Standby. Will be removed in v4. Use `get_server_url` - instead. - - https://docs.apify.com/platform/actors/running/standby - """ - warnings.warn( - '`get_standby_url` is deprecated and will be removed in v4. Use `get_server_url` instead.', - DeprecationWarning, - stacklevel=3, - ) - return self.get_server_url() - - def get_standby_openapi_spec(self) -> dict[str, Any] | None: - """Return the OpenAPI specification of the Actor's server in Standby mode. - - Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Will be removed in v4. Use - `get_server_openapi_spec` instead. - - https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema """ warnings.warn( - '`get_standby_openapi_spec` is deprecated and will be removed in v4. ' - 'Use `get_server_openapi_spec` instead.', + '`standby` is deprecated and will be removed in v4. Use `server` instead.', DeprecationWarning, stacklevel=3, ) - return self.get_server_openapi_spec() - - def standby_request( - self, - path: str = '/', - *, - method: HttpMethod = 'GET', - params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, - headers: dict[str, str] | None = None, - data: str | bytes | None = None, - json: JsonSerializable | None = None, - compression: HttpCompressionAlgorithm | HttpCompressor | None = None, - timeout: Timeout = 'long', - ) -> HttpResponse: - """Send an HTTP request to the Actor's server in Standby mode. - - Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Will be removed in - v4. Use `server_request` instead. - - https://docs.apify.com/platform/actors/running/standby - """ - warnings.warn( - '`standby_request` is deprecated and will be removed in v4. Use `server_request` instead.', - DeprecationWarning, - stacklevel=3, - ) - return self.server_request( - path, - method=method, - params=params, - headers=headers, - data=data, - json=json, - compression=compression, - timeout=timeout, - ) + return self.server() @docs_group('Resource clients') @@ -858,7 +687,7 @@ def __init__( resource_path=resource_path, **kwargs, ) - self._server_url: str | None = None + self._server_client: ActorServerClientAsync | None = None async def get(self, *, timeout: Timeout = 'short') -> ActorResource | None: """Retrieve the Actor. @@ -1399,208 +1228,40 @@ async def validate_input( return True - async def get_server_url(self) -> str: - """Return the URL of the Actor's server. + def server(self) -> ActorServerClientAsync: + """Retrieve a client for the server interface of this Actor. - The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this - client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. Server Actors are also called Standby Actors, and `get_standby_url` is a deprecated alias - of this method. + A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each + call. Server Actors are also called Standby Actors, and `standby` is a deprecated alias of this method. Every + call returns the same client, so the server URL it fetches is reused. https://docs.apify.com/platform/actors/running/standby Returns: - The URL of the Actor's server. - - Raises: - ApifyClientError: If the Actor is not a Server Actor. - ApifyApiError: If the Actor does not exist. - """ - if self._server_url is None: - response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') - actor = ActorResponse.model_validate(response_to_dict(response)).data - if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') - self._server_url = actor.standby_url - return self._server_url - - async def get_server_openapi_spec(self) -> dict[str, Any] | None: - """Return the OpenAPI specification of the Actor's server. - - The specification is the web server schema from the Actor's definition, taken from the build that serves the - server interface: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP - client for the server, and point that client at the URL returned by `get_server_url`. Server Actors are also - called Standby Actors, and `get_standby_openapi_spec` is a deprecated alias of this method. - - https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema - - Returns: - The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. - - Raises: - ApifyClientError: If the Actor is not a Server Actor, or the build that serves its server interface doesn't - exist. - ApifyApiError: If the Actor does not exist. + The client for the server interface of this Actor. """ - response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') - actor = ActorResponse.model_validate(response_to_dict(response)).data - if actor.standby_url is None: - raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') - - build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' - - build_id = None - tagged_build = (actor.tagged_builds or {}).get(build_ref) - if tagged_build is not None: - build_id = tagged_build.build_id - else: - # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly. - async for build_item in self.builds().iterate(desc=True): - if build_item.build_number == build_ref: - build_id = build_item.id - break - - build = None - if build_id is not None: - build_client = self._client_registry.build_client( - resource_id=build_id, + if self._server_client is None: + self._server_client = self._client_registry.actor_server_client( + resource_id=self._resource_id, + resource_path=self._resource_path, base_url=self._base_url, + api_base_url=self._api_base_url, public_base_url=self._public_base_url, http_client=self._http_client, client_registry=self._client_registry, ) - build = await build_client.get() - if build is None: - raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') - - if build.actor_definition is None: - return None - return build.actor_definition.web_server_schema - - async def server_request( - self, - path: str = '/', - *, - method: HttpMethod = 'GET', - params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, - headers: dict[str, str] | None = None, - data: str | bytes | None = None, - json: JsonSerializable | None = None, - compression: HttpCompressionAlgorithm | HttpCompressor | None = None, - timeout: Timeout = 'long', - ) -> HttpResponse: - """Send an HTTP request to the Actor's server. + return self._server_client - A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each - call. Server Actors are also called Standby Actors, and `standby_request` is a deprecated alias of this method. - The request goes to the URL returned by `get_server_url` and carries the client's token, which the platform - consumes, so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to - start a server run, which is why the timeout defaults to `long`. + def standby(self) -> ActorServerClientAsync: + """Retrieve a client for the server interface of this Actor in Standby mode. - A server error or a rate-limit response is retried like an API call, so the server may receive the same - request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's - server may not decode a compressed body. + Deprecated alias of `server`, named after Actor Standby. Will be removed in v4. Use `server` instead. https://docs.apify.com/platform/actors/running/standby - - Args: - path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL, - so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. - method: HTTP method of the request. - params: Query parameters of the request, converted the same way as for API calls: booleans become `true` - or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string - in UTC, and `None` values are left out. - headers: Additional headers of the request. - data: Raw request body. Cannot be used together with `json`. - json: JSON-serializable request body. Cannot be used together with `data`. - compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor` - instance. The body is compressed under the same conditions as for API calls. Set it only if the - Actor's server decodes the resulting `Content-Encoding`. - timeout: Timeout for the HTTP request. - - Returns: - The response of the Actor's server. - - Raises: - ApifyClientError: If the Actor is not a Server Actor. - ApifyApiError: If the Actor does not exist, or the server responds with an error status. - ImportError: If `compression` needs an optional extra that is not installed. - ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. - """ - server_url = await self.get_server_url() - return await self._http_client.call( - url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', - method=method, - params=dict(params) if params is not None else None, - headers=headers, - data=data, - json=json, - timeout=timeout, - compression=resolve_compressor(compression) if compression is not None else False, - ) - - async def get_standby_url(self) -> str: - """Return the URL of the Actor's server in Standby mode. - - Deprecated alias of `get_server_url`, named after Actor Standby. Will be removed in v4. Use `get_server_url` - instead. - - https://docs.apify.com/platform/actors/running/standby - """ - warnings.warn( - '`get_standby_url` is deprecated and will be removed in v4. Use `get_server_url` instead.', - DeprecationWarning, - stacklevel=3, - ) - return await self.get_server_url() - - async def get_standby_openapi_spec(self) -> dict[str, Any] | None: - """Return the OpenAPI specification of the Actor's server in Standby mode. - - Deprecated alias of `get_server_openapi_spec`, named after Actor Standby. Will be removed in v4. Use - `get_server_openapi_spec` instead. - - https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema """ warnings.warn( - '`get_standby_openapi_spec` is deprecated and will be removed in v4. ' - 'Use `get_server_openapi_spec` instead.', + '`standby` is deprecated and will be removed in v4. Use `server` instead.', DeprecationWarning, stacklevel=3, ) - return await self.get_server_openapi_spec() - - async def standby_request( - self, - path: str = '/', - *, - method: HttpMethod = 'GET', - params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, - headers: dict[str, str] | None = None, - data: str | bytes | None = None, - json: JsonSerializable | None = None, - compression: HttpCompressionAlgorithm | HttpCompressor | None = None, - timeout: Timeout = 'long', - ) -> HttpResponse: - """Send an HTTP request to the Actor's server in Standby mode. - - Deprecated alias of `server_request`, named after Actor Standby. It takes the same arguments. Will be removed in - v4. Use `server_request` instead. - - https://docs.apify.com/platform/actors/running/standby - """ - warnings.warn( - '`standby_request` is deprecated and will be removed in v4. Use `server_request` instead.', - DeprecationWarning, - stacklevel=3, - ) - return await self.server_request( - path, - method=method, - params=params, - headers=headers, - data=data, - json=json, - compression=compression, - timeout=timeout, - ) + return self.server() diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py new file mode 100644 index 00000000..623c57b2 --- /dev/null +++ b/src/apify_client/_resource_clients/actor_server.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from apify_client._docs import docs_group +from apify_client._models import ActorResponse +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils.http import response_to_dict +from apify_client.errors import ApifyClientError +from apify_client.http_compressors._resolve import resolve_compressor + +if TYPE_CHECKING: + from collections.abc import Mapping + from datetime import datetime + + from apify_client._literals import HttpMethod + from apify_client.http_clients import HttpResponse + from apify_client.http_compressors import HttpCompressor + from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, Timeout + + +@docs_group('Resource clients') +class ActorServerClient(ResourceClient): + """Sub-client for the server interface of a specific Actor. + + Provides methods to work with the web server of a Server Actor, e.g. send it requests or get its OpenAPI + specification. Obtain an instance via an appropriate method on the `ActorClient` class. + """ + + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actors', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) + self._url: str | None = None + + def get_url(self) -> str: + """Return the URL of the Actor's server. + + The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this + client. Use it to call the server with another HTTP client, for example one generated from the server's + OpenAPI specification. + + https://docs.apify.com/platform/actors/running/standby + + Returns: + The URL of the Actor's server. + + Raises: + ApifyClientError: If the Actor is not a Server Actor. + ApifyApiError: If the Actor does not exist. + """ + if self._url is None: + response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._url = actor.standby_url + return self._url + + def get_openapi_spec(self) -> dict[str, Any] | None: + """Return the OpenAPI specification of the Actor's server. + + The specification is the web server schema from the Actor's definition, taken from the build that serves the + server interface: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP + client for the server, and point that client at the URL returned by `get_url`. + + https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + + Returns: + The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. + + Raises: + ApifyClientError: If the Actor is not a Server Actor, or the build that serves its server interface doesn't + exist. + ApifyApiError: If the Actor does not exist. + """ + response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + + build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' + + build_id = None + tagged_build = (actor.tagged_builds or {}).get(build_ref) + if tagged_build is not None: + build_id = tagged_build.build_id + else: + # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly. + builds_client = self._client_registry.build_collection_client( + resource_path='builds', + **self._base_client_kwargs, + ) + for build_item in builds_client.iterate(desc=True): + if build_item.build_number == build_ref: + build_id = build_item.id + break + + build = None + if build_id is not None: + build_client = self._client_registry.build_client( + resource_id=build_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + build = build_client.get() + if build is None: + raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') + + if build.actor_definition is None: + return None + return build.actor_definition.web_server_schema + + def request( + self, + path: str = '/', + *, + method: HttpMethod = 'GET', + params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, + headers: dict[str, str] | None = None, + data: str | bytes | None = None, + json: JsonSerializable | None = None, + compression: HttpCompressionAlgorithm | HttpCompressor | None = None, + timeout: Timeout = 'long', + ) -> HttpResponse: + """Send an HTTP request to the Actor's server. + + The request goes to the URL returned by `get_url` and carries the client's token, which the platform consumes, + so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a + server run, which is why the timeout defaults to `long`. + + A server error or a rate-limit response is retried like an API call, so the server may receive the same + request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's + server may not decode a compressed body. + + https://docs.apify.com/platform/actors/running/standby + + Args: + path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL, + so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. + method: HTTP method of the request. + params: Query parameters of the request, converted the same way as for API calls: booleans become `true` + or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string + in UTC, and `None` values are left out. + headers: Additional headers of the request. + data: Raw request body. Cannot be used together with `json`. + json: JSON-serializable request body. Cannot be used together with `data`. + compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor` + instance. The body is compressed under the same conditions as for API calls. Set it only if the + Actor's server decodes the resulting `Content-Encoding`. + timeout: Timeout for the HTTP request. + + Returns: + The response of the Actor's server. + + Raises: + ApifyClientError: If the Actor is not a Server Actor. + ApifyApiError: If the Actor does not exist, or the server responds with an error status. + ImportError: If `compression` needs an optional extra that is not installed. + ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. + """ + server_url = self.get_url() + return self._http_client.call( + url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', + method=method, + params=dict(params) if params is not None else None, + headers=headers, + data=data, + json=json, + timeout=timeout, + compression=resolve_compressor(compression) if compression is not None else False, + ) + + +@docs_group('Resource clients') +class ActorServerClientAsync(ResourceClientAsync): + """Sub-client for the server interface of a specific Actor. + + Provides methods to work with the web server of a Server Actor, e.g. send it requests or get its OpenAPI + specification. Obtain an instance via an appropriate method on the `ActorClientAsync` class. + """ + + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actors', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) + self._url: str | None = None + + async def get_url(self) -> str: + """Return the URL of the Actor's server. + + The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this + client. Use it to call the server with another HTTP client, for example one generated from the server's + OpenAPI specification. + + https://docs.apify.com/platform/actors/running/standby + + Returns: + The URL of the Actor's server. + + Raises: + ApifyClientError: If the Actor is not a Server Actor. + ApifyApiError: If the Actor does not exist. + """ + if self._url is None: + response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._url = actor.standby_url + return self._url + + async def get_openapi_spec(self) -> dict[str, Any] | None: + """Return the OpenAPI specification of the Actor's server. + + The specification is the web server schema from the Actor's definition, taken from the build that serves the + server interface: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP + client for the server, and point that client at the URL returned by `get_url`. + + https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + + Returns: + The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. + + Raises: + ApifyClientError: If the Actor is not a Server Actor, or the build that serves its server interface doesn't + exist. + ApifyApiError: If the Actor does not exist. + """ + response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') + actor = ActorResponse.model_validate(response_to_dict(response)).data + if actor.standby_url is None: + raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + + build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' + + build_id = None + tagged_build = (actor.tagged_builds or {}).get(build_ref) + if tagged_build is not None: + build_id = tagged_build.build_id + else: + # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly. + builds_client = self._client_registry.build_collection_client( + resource_path='builds', + **self._base_client_kwargs, + ) + async for build_item in builds_client.iterate(desc=True): + if build_item.build_number == build_ref: + build_id = build_item.id + break + + build = None + if build_id is not None: + build_client = self._client_registry.build_client( + resource_id=build_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + build = await build_client.get() + if build is None: + raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') + + if build.actor_definition is None: + return None + return build.actor_definition.web_server_schema + + async def request( + self, + path: str = '/', + *, + method: HttpMethod = 'GET', + params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, + headers: dict[str, str] | None = None, + data: str | bytes | None = None, + json: JsonSerializable | None = None, + compression: HttpCompressionAlgorithm | HttpCompressor | None = None, + timeout: Timeout = 'long', + ) -> HttpResponse: + """Send an HTTP request to the Actor's server. + + The request goes to the URL returned by `get_url` and carries the client's token, which the platform consumes, + so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a + server run, which is why the timeout defaults to `long`. + + A server error or a rate-limit response is retried like an API call, so the server may receive the same + request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's + server may not decode a compressed body. + + https://docs.apify.com/platform/actors/running/standby + + Args: + path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL, + so the request, and the token with it, cannot be sent to another host. Pass the query in `params`. + method: HTTP method of the request. + params: Query parameters of the request, converted the same way as for API calls: booleans become `true` + or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string + in UTC, and `None` values are left out. + headers: Additional headers of the request. + data: Raw request body. Cannot be used together with `json`. + json: JSON-serializable request body. Cannot be used together with `data`. + compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor` + instance. The body is compressed under the same conditions as for API calls. Set it only if the + Actor's server decodes the resulting `Content-Encoding`. + timeout: Timeout for the HTTP request. + + Returns: + The response of the Actor's server. + + Raises: + ApifyClientError: If the Actor is not a Server Actor. + ApifyApiError: If the Actor does not exist, or the server responds with an error status. + ImportError: If `compression` needs an optional extra that is not installed. + ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. + """ + server_url = await self.get_url() + return await self._http_client.call( + url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', + method=method, + params=dict(params) if params is not None else None, + headers=headers, + data=data, + json=json, + timeout=timeout, + compression=resolve_compressor(compression) if compression is not None else False, + ) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index b8cccebe..a7b43036 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -489,7 +489,7 @@ class HttpClient(HttpClientBase): through `_prepare_request_call`, so only a client that replaces `call` has to do it itself. A replaced `call` has to accept every argument of the inherited one, including `compression`, which - `ActorClient.server_request` always passes, as `False` unless its caller asks for compression. + `ActorServerClient.request` always passes, as `False` unless its caller asks for compression. """ def __enter__(self) -> Self: diff --git a/tests/unit/test_actor_server_request.py b/tests/unit/test_actor_server.py similarity index 64% rename from tests/unit/test_actor_server_request.py rename to tests/unit/test_actor_server.py index 7ad9a203..c5f550ac 100644 --- a/tests/unit/test_actor_server_request.py +++ b/tests/unit/test_actor_server.py @@ -53,13 +53,15 @@ def server_requests(httpserver: HTTPServer) -> list[Request]: return [request for request, _ in httpserver.log if request.path.startswith(f'{_SERVER_PREFIX}/')] -def test_server_request_sends_request_to_server_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_sends_request_to_server_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """The request goes to the server URL with the method, path, query, body, and the client's token.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='POST').respond_with_json({'ok': True}) - response = sync_client.actor(_MOCKED_ACTOR_ID).server_request( - '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} + response = ( + sync_client.actor(_MOCKED_ACTOR_ID) + .server() + .request('/search', method='POST', params={'q': 'apify'}, json={'limit': 5}) ) assert response.status_code == 200 @@ -71,15 +73,17 @@ def test_server_request_sends_request_to_server_url(httpserver: HTTPServer, sync assert captured[0].headers['Authorization'] == 'Bearer test' -async def test_server_request_sends_request_to_server_url_async( +async def test_request_sends_request_to_server_url_async( httpserver: HTTPServer, async_client: ApifyClientAsync ) -> None: - """Async variant of `test_server_request_sends_request_to_server_url`.""" + """Async variant of `test_request_sends_request_to_server_url`.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='POST').respond_with_json({'ok': True}) - response = await async_client.actor(_MOCKED_ACTOR_ID).server_request( - '/search', method='POST', params={'q': 'apify'}, json={'limit': 5} + response = ( + await async_client.actor(_MOCKED_ACTOR_ID) + .server() + .request('/search', method='POST', params={'q': 'apify'}, json={'limit': 5}) ) assert response.status_code == 200 @@ -91,64 +95,62 @@ async def test_server_request_sends_request_to_server_url_async( assert captured[0].headers['Authorization'] == 'Bearer test' -def test_server_request_fetches_server_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_fetches_server_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """Later calls on the same client reuse the server URL fetched by the first one.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) actor_client = sync_client.actor(_MOCKED_ACTOR_ID) - actor_client.server_request() - actor_client.server_request() + actor_client.server().request() + actor_client.server().request() actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] assert len(actor_gets) == 1 -async def test_server_request_fetches_server_url_once_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_server_request_fetches_server_url_once`.""" +async def test_request_fetches_server_url_once_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_request_fetches_server_url_once`.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) actor_client = async_client.actor(_MOCKED_ACTOR_ID) - await actor_client.server_request() - await actor_client.server_request() + await actor_client.server().request() + await actor_client.server().request() actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] assert len(actor_gets) == 1 -def test_get_server_url_returns_standby_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """`get_server_url` returns the Actor's `standby_url` and fetches it from the API only once.""" +def test_get_url_returns_standby_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`get_url` returns the Actor's `standby_url` and fetches it from the API only once.""" expect_server_actor(httpserver) actor_client = sync_client.actor(_MOCKED_ACTOR_ID) - assert actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) - assert actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) + assert actor_client.server().get_url() == httpserver.url_for(_SERVER_PREFIX) + assert actor_client.server().get_url() == httpserver.url_for(_SERVER_PREFIX) actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] assert len(actor_gets) == 1 -async def test_get_server_url_returns_standby_url_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: - """Async variant of `test_get_server_url_returns_standby_url`.""" +async def test_get_url_returns_standby_url_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_get_url_returns_standby_url`.""" expect_server_actor(httpserver) actor_client = async_client.actor(_MOCKED_ACTOR_ID) - assert await actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) - assert await actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX) + assert await actor_client.server().get_url() == httpserver.url_for(_SERVER_PREFIX) + assert await actor_client.server().get_url() == httpserver.url_for(_SERVER_PREFIX) actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] assert len(actor_gets) == 1 -def test_get_server_url_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """`get_server_url` raises for an Actor without a server interface.""" +def test_get_url_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`get_url` raises for an Actor without a server interface.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) with pytest.raises(ApifyClientError, match='is not a Server Actor'): - sync_client.actor(_MOCKED_ACTOR_ID).get_server_url() + sync_client.actor(_MOCKED_ACTOR_ID).server().get_url() @pytest.mark.parametrize( @@ -159,24 +161,24 @@ def test_get_server_url_without_server_mode_raises(httpserver: HTTPServer, sync_ pytest.param('https://evil.example/steal', id='absolute url'), ], ) -def test_server_request_path_stays_on_server_host(httpserver: HTTPServer, sync_client: ApifyClient, path: str) -> None: +def test_request_path_stays_on_server_host(httpserver: HTTPServer, sync_client: ApifyClient, path: str) -> None: """A path that looks like another host is still sent to the server, as a path on it.""" expect_server_actor(httpserver) httpserver.no_handler_status_code = 200 - response = sync_client.actor(_MOCKED_ACTOR_ID).server_request(path) + response = sync_client.actor(_MOCKED_ACTOR_ID).server().request(path) assert response.status_code == 200 assert len(server_requests(httpserver)) == 1 -def test_server_request_sends_body_uncompressed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_sends_body_uncompressed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """A body large enough for the client to compress is sent as is, since servers may not decode it.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) payload = {'items': ['x' * 100] * 100} - sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload) + sync_client.actor(_MOCKED_ACTOR_ID).server().request('/items', method='POST', json=payload) captured = server_requests(httpserver) assert len(captured) == 1 @@ -184,15 +186,13 @@ def test_server_request_sends_body_uncompressed(httpserver: HTTPServer, sync_cli assert json.loads(captured[0].get_data()) == payload -async def test_server_request_sends_body_uncompressed_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_server_request_sends_body_uncompressed`.""" +async def test_request_sends_body_uncompressed_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_request_sends_body_uncompressed`.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) payload = {'items': ['x' * 100] * 100} - await async_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload) + await async_client.actor(_MOCKED_ACTOR_ID).server().request('/items', method='POST', json=payload) captured = server_requests(httpserver) assert len(captured) == 1 @@ -207,7 +207,7 @@ async def test_server_request_sends_body_uncompressed_async( pytest.param(GzipHttpCompressor(quality=1), id='compressor instance'), ], ) -def test_server_request_compresses_body_when_asked( +def test_request_compresses_body_when_asked( httpserver: HTTPServer, sync_client: ApifyClient, compression: HttpCompressionAlgorithm | HttpCompressor ) -> None: """With `compression` set, a body large enough to compress is sent compressed and labeled with its encoding.""" @@ -215,7 +215,7 @@ def test_server_request_compresses_body_when_asked( httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) payload = {'items': ['x' * 100] * 100} - sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload, compression=compression) + sync_client.actor(_MOCKED_ACTOR_ID).server().request('/items', method='POST', json=payload, compression=compression) captured = server_requests(httpserver) assert len(captured) == 1 @@ -223,15 +223,15 @@ def test_server_request_compresses_body_when_asked( assert json.loads(gzip.decompress(captured[0].get_data())) == payload -async def test_server_request_compresses_body_when_asked_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_server_request_compresses_body_when_asked`.""" +async def test_request_compresses_body_when_asked_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_request_compresses_body_when_asked`.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) payload = {'items': ['x' * 100] * 100} - await async_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload, compression='gzip') + await ( + async_client.actor(_MOCKED_ACTOR_ID).server().request('/items', method='POST', json=payload, compression='gzip') + ) captured = server_requests(httpserver) assert len(captured) == 1 @@ -239,23 +239,23 @@ async def test_server_request_compresses_body_when_asked_async( assert json.loads(gzip.decompress(captured[0].get_data())) == payload -def test_server_request_rejects_unknown_compression(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_rejects_unknown_compression(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """An unsupported compression algorithm raises a `ValueError` without sending a request to the server.""" expect_server_actor(httpserver) with pytest.raises(ValueError, match='Unsupported compression algorithm'): - sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', data='x', compression='zip') # ty: ignore[invalid-argument-type] + sync_client.actor(_MOCKED_ACTOR_ID).server().request('/items', method='POST', data='x', compression='zip') # ty: ignore[invalid-argument-type] assert server_requests(httpserver) == [] -def test_server_request_keeps_caller_content_encoding(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_keeps_caller_content_encoding(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """A `Content-Encoding` header passed by the caller is sent as given, with the body untouched.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True}) body = gzip.compress(b'x' * 2000) - sync_client.actor(_MOCKED_ACTOR_ID).server_request( + sync_client.actor(_MOCKED_ACTOR_ID).server().request( '/items', method='POST', headers={'content-encoding': 'gzip'}, data=body ) @@ -265,41 +265,39 @@ def test_server_request_keeps_caller_content_encoding(httpserver: HTTPServer, sy assert captured[0].get_data() == body -def test_server_request_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """An Actor without a server interface raises an error before any request to a server.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) with pytest.raises(ApifyClientError, match='is not a Server Actor'): - sync_client.actor(_MOCKED_ACTOR_ID).server_request() + sync_client.actor(_MOCKED_ACTOR_ID).server().request() -async def test_server_request_without_server_mode_raises_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_server_request_without_server_mode_raises`.""" +async def test_request_without_server_mode_raises_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_request_without_server_mode_raises`.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) with pytest.raises(ApifyClientError, match='is not a Server Actor'): - await async_client.actor(_MOCKED_ACTOR_ID).server_request() + await async_client.actor(_MOCKED_ACTOR_ID).server().request() -def test_server_request_missing_actor_raises_not_found(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_missing_actor_raises_not_found(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """A missing Actor raises `NotFoundError`.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json( {'error': {'type': 'record-not-found', 'message': 'Actor was not found'}}, status=404 ) with pytest.raises(NotFoundError): - sync_client.actor(_MOCKED_ACTOR_ID).server_request() + sync_client.actor(_MOCKED_ACTOR_ID).server().request() -def test_server_request_error_status_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_request_error_status_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """An error status from the server raises the matching `ApifyApiError` subclass.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/missing', method='GET').respond_with_data('Not here', status=404) with pytest.raises(NotFoundError, match='Not here'): - sync_client.actor(_MOCKED_ACTOR_ID).server_request('/missing') + sync_client.actor(_MOCKED_ACTOR_ID).server().request('/missing') _OPENAPI_SPEC = {'openapi': '3.0.0', 'info': {'title': 'Test server', 'version': '1.0.0'}, 'paths': {}} @@ -365,7 +363,7 @@ def expect_server_actor_with_build( pytest.param('beta', 'beta_build_id', id='build tag'), ], ) -def test_get_server_openapi_spec_from_tagged_build( +def test_get_openapi_spec_from_tagged_build( httpserver: HTTPServer, sync_client: ApifyClient, standby_build: str | None, build_id: str ) -> None: """The specification comes from the web server schema of the tagged build that serves the server interface.""" @@ -373,40 +371,36 @@ def test_get_server_openapi_spec_from_tagged_build( httpserver, standby_build=standby_build, build_id=build_id, actor_definition={'webServerSchema': _OPENAPI_SPEC} ) - assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + assert sync_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() == _OPENAPI_SPEC -async def test_get_server_openapi_spec_from_tagged_build_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_get_server_openapi_spec_from_tagged_build`.""" +async def test_get_openapi_spec_from_tagged_build_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_get_openapi_spec_from_tagged_build`.""" expect_server_actor_with_build( httpserver, standby_build='beta', build_id='beta_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} ) - assert await async_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + assert await async_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() == _OPENAPI_SPEC -def test_get_server_openapi_spec_from_build_number(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_get_openapi_spec_from_build_number(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """A build number set for the server interface is looked up in the Actor's builds.""" expect_server_actor_with_build( httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} ) expect_builds_list(httpserver, {'latest_build_id': '0.0.2', 'old_build_id': '0.0.1'}) - assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + assert sync_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() == _OPENAPI_SPEC -async def test_get_server_openapi_spec_from_build_number_async( - httpserver: HTTPServer, async_client: ApifyClientAsync -) -> None: - """Async variant of `test_get_server_openapi_spec_from_build_number`.""" +async def test_get_openapi_spec_from_build_number_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_get_openapi_spec_from_build_number`.""" expect_server_actor_with_build( httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} ) expect_builds_list(httpserver, {'latest_build_id': '0.0.2', 'old_build_id': '0.0.1'}) - assert await async_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC + assert await async_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() == _OPENAPI_SPEC @pytest.mark.parametrize( @@ -416,7 +410,7 @@ async def test_get_server_openapi_spec_from_build_number_async( pytest.param(None, id='no actor definition'), ], ) -def test_get_server_openapi_spec_without_schema_returns_none( +def test_get_openapi_spec_without_schema_returns_none( httpserver: HTTPServer, sync_client: ApifyClient, actor_definition: dict[str, Any] | None ) -> None: """A build without a web server schema has no specification to return.""" @@ -424,67 +418,52 @@ def test_get_server_openapi_spec_without_schema_returns_none( httpserver, standby_build=None, build_id='latest_build_id', actor_definition=actor_definition ) - assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() is None + assert sync_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() is None -def test_get_server_openapi_spec_unknown_build_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: +def test_get_openapi_spec_unknown_build_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """A server interface build that is neither a tag nor a listed build number raises an error.""" expect_server_actor_with_build(httpserver, standby_build='9.9.9', build_id='unused_build_id', actor_definition=None) expect_builds_list(httpserver, {'latest_build_id': '0.0.2'}) with pytest.raises(ApifyClientError, match='does not exist'): - sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() + sync_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() -def test_get_server_openapi_spec_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """`get_server_openapi_spec` raises for an Actor without a server interface.""" +def test_get_openapi_spec_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`get_openapi_spec` raises for an Actor without a server interface.""" httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None)) with pytest.raises(ApifyClientError, match='is not a Server Actor'): - sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() + sync_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() -def test_standby_aliases_warn_and_delegate(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """The deprecated Standby-named methods emit a `DeprecationWarning` pointing at the caller and return the same.""" - expect_server_actor_with_build( - httpserver, standby_build=None, build_id='latest_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} - ) +def test_standby_alias_warns_and_returns_server_client(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """The deprecated `standby` emits a `DeprecationWarning` pointing at the caller and returns the server client.""" + expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='GET').respond_with_json({'ok': True}) actor_client = sync_client.actor(_MOCKED_ACTOR_ID) - with pytest.warns(DeprecationWarning, match='`get_standby_url` is deprecated') as record: - assert actor_client.get_standby_url() == httpserver.url_for(_SERVER_PREFIX) - assert record[0].filename == __file__ + with pytest.warns(DeprecationWarning, match='`standby` is deprecated') as record: + standby_client = actor_client.standby() - with pytest.warns(DeprecationWarning, match='`get_standby_openapi_spec` is deprecated') as record: - assert actor_client.get_standby_openapi_spec() == _OPENAPI_SPEC assert record[0].filename == __file__ - - with pytest.warns(DeprecationWarning, match='`standby_request` is deprecated') as record: - response = actor_client.standby_request('/search', params={'q': 'apify'}) - assert record[0].filename == __file__ - assert response.json() == {'ok': True} - assert server_requests(httpserver)[0].args.to_dict() == {'q': 'apify'} + assert standby_client is actor_client.server() + assert standby_client.request('/search').json() == {'ok': True} -async def test_standby_aliases_warn_and_delegate_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: - """Async variant of `test_standby_aliases_warn_and_delegate`.""" - expect_server_actor_with_build( - httpserver, standby_build=None, build_id='latest_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} - ) +async def test_standby_alias_warns_and_returns_server_client_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_standby_alias_warns_and_returns_server_client`.""" + expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='GET').respond_with_json({'ok': True}) actor_client = async_client.actor(_MOCKED_ACTOR_ID) - with pytest.warns(DeprecationWarning, match='`get_standby_url` is deprecated') as record: - assert await actor_client.get_standby_url() == httpserver.url_for(_SERVER_PREFIX) - assert record[0].filename == __file__ - - with pytest.warns(DeprecationWarning, match='`get_standby_openapi_spec` is deprecated') as record: - assert await actor_client.get_standby_openapi_spec() == _OPENAPI_SPEC - assert record[0].filename == __file__ + with pytest.warns(DeprecationWarning, match='`standby` is deprecated') as record: + standby_client = actor_client.standby() - with pytest.warns(DeprecationWarning, match='`standby_request` is deprecated') as record: - response = await actor_client.standby_request('/search', params={'q': 'apify'}) assert record[0].filename == __file__ + assert standby_client is actor_client.server() + response = await standby_client.request('/search') assert response.json() == {'ok': True} - assert server_requests(httpserver)[0].args.to_dict() == {'q': 'apify'} From b2db979ab041c8e2756b3f0a8331376260dd771c Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 09:45:23 +0100 Subject: [PATCH 23/36] docs: present Actor Standby as the former name of Server Actors and drop the httpx2 example --- README.md | 2 +- docs/02_concepts/14_server_actors.mdx | 40 +++++++++---------- docs/02_concepts/code/14_server_async.py | 6 +-- .../code/14_server_other_client_async.py | 25 ------------ .../code/14_server_other_client_sync.py | 25 ------------ docs/02_concepts/code/14_server_sync.py | 6 +-- .../code/14_server_url_spec_async.py | 17 ++++++++ .../code/14_server_url_spec_sync.py | 17 ++++++++ src/apify_client/_resource_clients/actor.py | 14 ++++--- 9 files changed, 68 insertions(+), 84 deletions(-) delete mode 100644 docs/02_concepts/code/14_server_other_client_async.py delete mode 100644 docs/02_concepts/code/14_server_other_client_sync.py create mode 100644 docs/02_concepts/code/14_server_url_spec_async.py create mode 100644 docs/02_concepts/code/14_server_url_spec_sync.py diff --git a/README.md b/README.md index d3f78354..bdce7968 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r - **Convenience methods** — `call()`, `wait_for_finish()`, nested resource access, and other shortcuts that hide platform quirks ([Convenience methods](https://docs.apify.com/api/client/python/docs/concepts/convenience-methods)). - **Pluggable HTTP layer** — use the default [Impit](https://github.com/apify/impit)-based client, opt in to the built-in [HTTPX2](https://github.com/pydantic/httpx2) client, or plug in any custom implementation ([HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)). - **Structured errors** — every API error surfaces as an [`ApifyApiError`](https://docs.apify.com/api/client/python/reference/class/ApifyApiError) with HTTP-specific subclasses for precise handling ([Error handling](https://docs.apify.com/api/client/python/docs/concepts/error-handling)). -- **Server Actors** — send HTTP requests to an Actor's server interface (Actor Standby), and get the server's URL and OpenAPI specification ([Server Actors](https://docs.apify.com/api/client/python/docs/concepts/server-actors)). +- **Server Actors** — send HTTP requests to an Actor's server interface (formerly Actor Standby), and get the server's URL and OpenAPI specification ([Server Actors](https://docs.apify.com/api/client/python/docs/concepts/server-actors)). - **Debug logging** — opt-in structured logging on the `apify_client` logger captures request URLs, status codes, retry attempts, and more ([Logging](https://docs.apify.com/api/client/python/docs/concepts/logging)). ## Usage examples diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 77bf6cfd..4a26cb2e 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -1,7 +1,7 @@ --- id: server-actors title: Server Actors -description: Send HTTP requests to Server Actors (Actor Standby), and get their URL and OpenAPI specification. +description: Send HTTP requests to Server Actors (formerly Actor Standby), and get their URL and OpenAPI specification. keywords: [server actor, server interface, standby, actor standby, standby_url, ActorServerClient] --- @@ -12,10 +12,18 @@ import ApiLink from '@theme/ApiLink'; import ServerAsyncExample from '!!raw-loader!./code/14_server_async.py'; import ServerSyncExample from '!!raw-loader!./code/14_server_sync.py'; -import ServerOtherClientAsyncExample from '!!raw-loader!./code/14_server_other_client_async.py'; -import ServerOtherClientSyncExample from '!!raw-loader!./code/14_server_other_client_sync.py'; +import ServerUrlSpecAsyncExample from '!!raw-loader!./code/14_server_url_spec_async.py'; +import ServerUrlSpecSyncExample from '!!raw-loader!./code/14_server_url_spec_sync.py'; -A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To work with such an Actor, get its server client with `ActorClient.server`. To send a request, use `ActorServerClient.request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: +A [Server Actor](https://docs.apify.com/platform/actors/running/standby) (formerly Actor Standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. + +:::note Formerly Actor Standby + +Actor Standby is the former name of Server Actors, and it'll be deprecated. The platform documentation and the API still use it, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. In the client, `ActorClient.standby` is a deprecated alias of `ActorClient.server` that will be removed in v4. To switch, rename `standby()` to `server()`. + +::: + +To work with a Server Actor, get its server client with `ActorClient.server`. To send a request, use `ActorServerClient.request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body: @@ -30,13 +38,7 @@ A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a -The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store) or its README. - -## Server Actors and Actor Standby - -Server Actors are Actors with [Actor Standby](https://docs.apify.com/platform/actors/running/standby) enabled, and their web server is the Actor's server interface. The platform documentation and the API use the Standby name, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. If you're looking for Standby support in the client, use the server client described on this page. - -The client also has a Standby-named method. `ActorClient.standby` is a deprecated alias of `ActorClient.server`. It returns the same server client, emits a `DeprecationWarning`, and will be removed in v4. To switch, rename the call, for example `actor_client.standby().request('/search')` to `actor_client.server().request('/search')`. The same alias exists on `ActorClientAsync`. +The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store), its README, or its OpenAPI specification. For details, see [Server URL and OpenAPI specification](#server-url-and-openapi-specification). ## How the request is sent @@ -46,28 +48,24 @@ Note that: - The path is always resolved against that URL, so a path can't send the request to another host. - By default, the request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls. -## Calling the server with another HTTP client +## Server URL and OpenAPI specification -For an Actor with many endpoints, you may prefer a client generated from the server's OpenAPI specification, or an HTTP client you already use. Two methods give such a client what it needs: +The server client also gives you the server's URL and its OpenAPI specification: - `ActorServerClient.get_url` returns the server's URL. It fetches and reuses the URL the same way as `request`. -- `ActorServerClient.get_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build that serves the server interface. That's the build set in the Actor's Standby settings, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. - -A request from another client needs your Apify token in the `Authorization` header, and a timeout long enough for a server run to start. For details, see [Authentication](#authentication) and [Timeouts, retries, and errors](#timeouts-retries-and-errors). The following example uses [HTTPX2](https://github.com/pydantic/httpx2), which you can install with the `httpx2` extra: +- `ActorServerClient.get_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build that serves the server interface. That's the build set in the Actor's `actor_standby.build` field, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. -```sh -pip install "apify-client[httpx2]" -``` +The following example prints the server's URL and the paths of its endpoints: - {ServerOtherClientAsyncExample} + {ServerUrlSpecAsyncExample} - {ServerOtherClientSyncExample} + {ServerUrlSpecSyncExample} diff --git a/docs/02_concepts/code/14_server_async.py b/docs/02_concepts/code/14_server_async.py index cd98ad2e..edd7b821 100644 --- a/docs/02_concepts/code/14_server_async.py +++ b/docs/02_concepts/code/14_server_async.py @@ -5,14 +5,14 @@ async def main() -> None: apify_client = ApifyClientAsync(TOKEN) - server_client = apify_client.actor('username/actor-name').server() + actor_server_client = apify_client.actor('username/actor-name').server() # Send a GET request to the /search endpoint of the Actor's server. - response = await server_client.request('/search', params={'q': 'apify'}) + response = await actor_server_client.request('/search', params={'q': 'apify'}) print(response.json()) # Send a POST request with a JSON body. - response = await server_client.request( + response = await actor_server_client.request( '/items', method='POST', json={'url': 'https://apify.com'}, diff --git a/docs/02_concepts/code/14_server_other_client_async.py b/docs/02_concepts/code/14_server_other_client_async.py deleted file mode 100644 index 9d3c4b35..00000000 --- a/docs/02_concepts/code/14_server_other_client_async.py +++ /dev/null @@ -1,25 +0,0 @@ -import httpx2 - -from apify_client import ApifyClientAsync - -TOKEN = 'MY-APIFY-TOKEN' - - -async def main() -> None: - apify_client = ApifyClientAsync(TOKEN) - server_client = apify_client.actor('username/actor-name').server() - - # Get the server's URL and the OpenAPI specification of its endpoints. - server_url = await server_client.get_url() - spec = await server_client.get_openapi_spec() - if spec is not None: - print(list(spec['paths'])) - - # The first request to an idle Actor waits for a server run to start. - async with httpx2.AsyncClient( - base_url=server_url, - headers={'Authorization': f'Bearer {TOKEN}'}, - timeout=360, - ) as http_client: - response = await http_client.get('/search', params={'q': 'apify'}) - print(response.json()) diff --git a/docs/02_concepts/code/14_server_other_client_sync.py b/docs/02_concepts/code/14_server_other_client_sync.py deleted file mode 100644 index 28225d13..00000000 --- a/docs/02_concepts/code/14_server_other_client_sync.py +++ /dev/null @@ -1,25 +0,0 @@ -import httpx2 - -from apify_client import ApifyClient - -TOKEN = 'MY-APIFY-TOKEN' - - -def main() -> None: - apify_client = ApifyClient(TOKEN) - server_client = apify_client.actor('username/actor-name').server() - - # Get the server's URL and the OpenAPI specification of its endpoints. - server_url = server_client.get_url() - spec = server_client.get_openapi_spec() - if spec is not None: - print(list(spec['paths'])) - - # The first request to an idle Actor waits for a server run to start. - with httpx2.Client( - base_url=server_url, - headers={'Authorization': f'Bearer {TOKEN}'}, - timeout=360, - ) as http_client: - response = http_client.get('/search', params={'q': 'apify'}) - print(response.json()) diff --git a/docs/02_concepts/code/14_server_sync.py b/docs/02_concepts/code/14_server_sync.py index 474fc34f..cf425532 100644 --- a/docs/02_concepts/code/14_server_sync.py +++ b/docs/02_concepts/code/14_server_sync.py @@ -5,14 +5,14 @@ def main() -> None: apify_client = ApifyClient(TOKEN) - server_client = apify_client.actor('username/actor-name').server() + actor_server_client = apify_client.actor('username/actor-name').server() # Send a GET request to the /search endpoint of the Actor's server. - response = server_client.request('/search', params={'q': 'apify'}) + response = actor_server_client.request('/search', params={'q': 'apify'}) print(response.json()) # Send a POST request with a JSON body. - response = server_client.request( + response = actor_server_client.request( '/items', method='POST', json={'url': 'https://apify.com'}, diff --git a/docs/02_concepts/code/14_server_url_spec_async.py b/docs/02_concepts/code/14_server_url_spec_async.py new file mode 100644 index 00000000..7acc4cd7 --- /dev/null +++ b/docs/02_concepts/code/14_server_url_spec_async.py @@ -0,0 +1,17 @@ +from apify_client import ApifyClientAsync + +TOKEN = 'MY-APIFY-TOKEN' + + +async def main() -> None: + apify_client = ApifyClientAsync(TOKEN) + actor_server_client = apify_client.actor('username/actor-name').server() + + # Get the URL of the Actor's server. + server_url = await actor_server_client.get_url() + print(server_url) + + # Get the OpenAPI specification and list the server's endpoints. + spec = await actor_server_client.get_openapi_spec() + if spec is not None: + print(list(spec['paths'])) diff --git a/docs/02_concepts/code/14_server_url_spec_sync.py b/docs/02_concepts/code/14_server_url_spec_sync.py new file mode 100644 index 00000000..c34a7dff --- /dev/null +++ b/docs/02_concepts/code/14_server_url_spec_sync.py @@ -0,0 +1,17 @@ +from apify_client import ApifyClient + +TOKEN = 'MY-APIFY-TOKEN' + + +def main() -> None: + apify_client = ApifyClient(TOKEN) + actor_server_client = apify_client.actor('username/actor-name').server() + + # Get the URL of the Actor's server. + server_url = actor_server_client.get_url() + print(server_url) + + # Get the OpenAPI specification and list the server's endpoints. + spec = actor_server_client.get_openapi_spec() + if spec is not None: + print(list(spec['paths'])) diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 86521258..156c14cf 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -632,8 +632,8 @@ def server(self) -> ActorServerClient: """Retrieve a client for the server interface of this Actor. A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each - call. Server Actors are also called Standby Actors, and `standby` is a deprecated alias of this method. Every - call returns the same client, so the server URL it fetches is reused. + call. Server Actors were formerly called Actor Standby, and `standby` is a deprecated alias of this method. + Every call returns the same client, so the server URL it fetches is reused. https://docs.apify.com/platform/actors/running/standby @@ -655,7 +655,8 @@ def server(self) -> ActorServerClient: def standby(self) -> ActorServerClient: """Retrieve a client for the server interface of this Actor in Standby mode. - Deprecated alias of `server`, named after Actor Standby. Will be removed in v4. Use `server` instead. + Deprecated alias of `server`, named after Actor Standby, the former name of Server Actors. Will be removed in + v4. Use `server` instead. https://docs.apify.com/platform/actors/running/standby """ @@ -1232,8 +1233,8 @@ def server(self) -> ActorServerClientAsync: """Retrieve a client for the server interface of this Actor. A Server Actor runs a web server, its server interface, that is called over HTTP without starting a run for each - call. Server Actors are also called Standby Actors, and `standby` is a deprecated alias of this method. Every - call returns the same client, so the server URL it fetches is reused. + call. Server Actors were formerly called Actor Standby, and `standby` is a deprecated alias of this method. + Every call returns the same client, so the server URL it fetches is reused. https://docs.apify.com/platform/actors/running/standby @@ -1255,7 +1256,8 @@ def server(self) -> ActorServerClientAsync: def standby(self) -> ActorServerClientAsync: """Retrieve a client for the server interface of this Actor in Standby mode. - Deprecated alias of `server`, named after Actor Standby. Will be removed in v4. Use `server` instead. + Deprecated alias of `server`, named after Actor Standby, the former name of Server Actors. Will be removed in + v4. Use `server` instead. https://docs.apify.com/platform/actors/running/standby """ From 429397420bd506982b17d7d7da10b47d0053b31e Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 09:54:18 +0100 Subject: [PATCH 24/36] docs: tweak the Server Actors page wording --- docs/02_concepts/14_server_actors.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 4a26cb2e..9308e30f 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -19,7 +19,7 @@ A [Server Actor](https://docs.apify.com/platform/actors/running/standby) (former :::note Formerly Actor Standby -Actor Standby is the former name of Server Actors, and it'll be deprecated. The platform documentation and the API still use it, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. In the client, `ActorClient.standby` is a deprecated alias of `ActorClient.server` that will be removed in v4. To switch, rename `standby()` to `server()`. +Actor Standby is the former name of Server Actors, and it'll be deprecated. The platform documentation and the API still use it, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. In the client, `ActorClient.standby` is a deprecated alias of `ActorClient.server` that will be removed in v4. ::: @@ -50,7 +50,7 @@ Note that: ## Server URL and OpenAPI specification -The server client also gives you the server's URL and its OpenAPI specification: +The Actor server client also gives you the server's URL and its OpenAPI specification: - `ActorServerClient.get_url` returns the server's URL. It fetches and reuses the URL the same way as `request`. - `ActorServerClient.get_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build that serves the server interface. That's the build set in the Actor's `actor_standby.build` field, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`. From b72a07c7afb598a166e03902988bc1d516831203 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 10:01:20 +0100 Subject: [PATCH 25/36] test: add an integration test for the Actor server client --- tests/integration/test_actor_server.py | 109 +++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/integration/test_actor_server.py diff --git a/tests/integration/test_actor_server.py b/tests/integration/test_actor_server.py new file mode 100644 index 00000000..26f102e3 --- /dev/null +++ b/tests/integration/test_actor_server.py @@ -0,0 +1,109 @@ +"""Unified tests for the server interface of an Actor (sync + async).""" + +from __future__ import annotations + +import json +import os +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest + +from .._utils import API_URL_ENV_VAR, get_random_resource_name, maybe_await +from apify_client import ApifyClient +from apify_client._consts import DEFAULT_API_URL + +if TYPE_CHECKING: + from collections.abc import Generator + + from apify_client import ApifyClientAsync + +OPENAPI_SPEC = { + 'openapi': '3.0.0', + 'info': {'title': 'Echo server', 'version': '1.0.0'}, + 'paths': {'/echo': {'post': {'responses': {'200': {'description': 'The echoed request.'}}}}}, +} + +# A web server that answers every request, including the platform's readiness probe, with the request it received. +ECHO_SERVER_SOURCE = """ +const http = require('http'); + +http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ method: req.method, url: req.url, headers: req.headers, body })); + }); +}).listen(process.env.ACTOR_WEB_SERVER_PORT); +""" + +DOCKERFILE = """ +FROM node:22-alpine +COPY main.js ./ +CMD ["node", "main.js"] +""" + + +@pytest.fixture(scope='module') +def server_actor_id(api_token: str) -> Generator[str]: + """Create and build a Server Actor running an echo server, and delete it after the module's tests.""" + client = ApifyClient(api_token, api_url=os.getenv(API_URL_ENV_VAR) or DEFAULT_API_URL) + actor_name = get_random_resource_name('server') + actor_json = { + 'actorSpecification': 1, + 'name': actor_name, + 'version': '0.0', + 'usesStandbyMode': True, + 'webServerSchema': OPENAPI_SPEC, + } + actor = client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + {'name': 'Dockerfile', 'format': 'TEXT', 'content': DOCKERFILE}, + {'name': 'main.js', 'format': 'TEXT', 'content': ECHO_SERVER_SOURCE}, + {'name': '.actor/actor.json', 'format': 'TEXT', 'content': json.dumps(actor_json)}, + ], + } + ], + actor_standby_is_enabled=True, + actor_standby_memory_mbytes=256, + actor_standby_idle_timeout=timedelta(minutes=1), + ) + actor_client = client.actor(actor.id) + + try: + build = actor_client.build(version_number='0.0') + finished_build = client.build(build.id).wait_for_finish(wait_duration=timedelta(minutes=5)) + assert finished_build is not None + assert finished_build.status == 'SUCCEEDED' + yield actor.id + finally: + actor_client.delete() + + +async def test_actor_server(client: ApifyClient | ApifyClientAsync, server_actor_id: str) -> None: + """The server client returns the Actor's URL and OpenAPI specification, and sends requests to its server.""" + actor = await maybe_await(client.actor(server_actor_id).get()) + assert actor is not None + actor_server_client = client.actor(server_actor_id).server() + + assert await maybe_await(actor_server_client.get_url()) == actor.standby_url + assert await maybe_await(actor_server_client.get_openapi_spec()) == OPENAPI_SPEC + + response = await maybe_await( + actor_server_client.request('/echo', method='POST', params={'q': 'apify'}, json={'limit': 5}) + ) + + assert response.status_code == 200 + echoed = response.json() + assert echoed['method'] == 'POST' + assert echoed['url'] == '/echo?q=apify' + assert json.loads(echoed['body']) == {'limit': 5} + # The platform consumes the client's token, so it never reaches the Actor's code. + assert 'authorization' not in echoed['headers'] From 56c78ba12c0270c39d1ea8e9137515ae5b7ec559 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 14:38:12 +0100 Subject: [PATCH 26/36] refactor: type Actor server request params with `QueryParams` --- src/apify_client/_resource_clients/actor_server.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index 623c57b2..e13114c3 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -10,13 +10,10 @@ from apify_client.http_compressors._resolve import resolve_compressor if TYPE_CHECKING: - from collections.abc import Mapping - from datetime import datetime - from apify_client._literals import HttpMethod from apify_client.http_clients import HttpResponse from apify_client.http_compressors import HttpCompressor - from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, Timeout + from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, QueryParams, Timeout @docs_group('Resource clients') @@ -126,7 +123,7 @@ def request( path: str = '/', *, method: HttpMethod = 'GET', - params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, + params: QueryParams | None = None, headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, @@ -173,7 +170,7 @@ def request( return self._http_client.call( url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', method=method, - params=dict(params) if params is not None else None, + params=params, headers=headers, data=data, json=json, @@ -289,7 +286,7 @@ async def request( path: str = '/', *, method: HttpMethod = 'GET', - params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None, + params: QueryParams | None = None, headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, @@ -336,7 +333,7 @@ async def request( return await self._http_client.call( url=f'{server_url.rstrip("/")}/{path.lstrip("/")}', method=method, - params=dict(params) if params is not None else None, + params=params, headers=headers, data=data, json=json, From 13a444ca15ea3c8a6562adc1b68ea0dfb078a648 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:05:37 +0100 Subject: [PATCH 27/36] feat: add authorization header, error status and retry status options to HttpClient.call() --- src/apify_client/http_clients/_base.py | 111 ++++++++++++++++++++--- tests/unit/test_http_clients.py | 99 ++++++++++++++++++++ tests/unit/test_pluggable_http_client.py | 14 ++- 3 files changed, 209 insertions(+), 15 deletions(-) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index b44af037..42538950 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -38,7 +38,7 @@ from apify_client.http_compressors._gzip import GzipHttpCompressor if TYPE_CHECKING: - from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping + from collections.abc import AsyncIterator, Awaitable, Callable, Collection, Iterator, Mapping from types import TracebackType from typing import Self @@ -313,12 +313,14 @@ def _prepare_request_call( data: str | bytes | bytearray | StreamedBodySource | None = None, json: JsonSerializable | None = None, compression: HttpCompressor | bool = True, + authorization_header: str | None = None, ) -> tuple[dict[str, str], dict[str, Any] | None, bytes | StreamedRequestBody | None]: """Prepare headers, params, and body for an HTTP request. Merges the client's default headers (including authorization) with per-request headers and serializes a JSON body. Header names are treated case-insensitively and per-request values win over the client - defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one. + defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one. With + `authorization_header`, the client's `Authorization` value goes out in the header of that name instead. The body is compressed with the client's compressor, or with the one passed in `compression`, unless `compression` is `False`, a `Content-Encoding` header is already set, the body is smaller than @@ -333,7 +335,15 @@ def _prepare_request_call( if json is not None and data is not None: raise ValueError('Cannot pass both "json" and "data" parameters at the same time!') - headers = self._merge_headers(self._headers, headers) + default_headers = self._headers + authorization = self._get_header(default_headers, 'authorization') + if authorization_header is not None and authorization is not None: + without_authorization = { + key: value for key, value in default_headers.items() if key.lower() != 'authorization' + } + default_headers = self._merge_headers(without_authorization, {authorization_header: authorization}) + + headers = self._merge_headers(default_headers, headers) # Dump JSON data to a string so it can be sent as a request body. if json is not None: @@ -444,8 +454,9 @@ def _handle_response_status( attempt: int, streamed: bool, stop_retrying: Callable[[], None], + retry_status_codes: Collection[int] | None, ) -> bool: - """Record the response status and stop retrying unless it is a server error or a rate limit. + """Record the response status and stop retrying unless the status is retryable. Returns whether the response is a success, so the caller can hand it back instead of raising. """ @@ -464,15 +475,37 @@ def _handle_response_status( 'within its time limit of about 5 minutes. Write a source that produces data slowly to a temporary ' 'file first, and upload the file.' ) - if ( - response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR - and response.status_code != HTTPStatus.TOO_MANY_REQUESTS - ): + if not self._is_status_code_retryable(response.status_code, retry_status_codes): logger.debug('Status code is not retryable', extra={'status_code': response.status_code}) stop_retrying() return False + @staticmethod + def _is_status_code_retryable(status_code: int, retry_status_codes: Collection[int] | None) -> bool: + """Return whether a status is retried: one in `retry_status_codes`, or else a rate limit or server error.""" + if retry_status_codes is not None: + return status_code in retry_status_codes + return status_code >= HTTPStatus.INTERNAL_SERVER_ERROR or status_code == HTTPStatus.TOO_MANY_REQUESTS + + def _returns_error_response( + self, + response: HttpResponse, + *, + attempt: int, + content: bytes | StreamedRequestBody | None, + retry_status_codes: Collection[int] | None, + ) -> bool: + """Return whether no retry follows an error response, so a call with `raise_for_status=False` returns it. + + A streamed body that cannot be rewound rules out a retry too. + """ + if not self._is_status_code_retryable(response.status_code, retry_status_codes): + return True + if isinstance(content, StreamedRequestBody) and not content.rewindable: + return True + return attempt > self._max_retries + @docs_group('HTTP clients') class HttpClient(HttpClientBase): @@ -488,8 +521,9 @@ class HttpClient(HttpClientBase): `Authorization` header never reaches the API. The inherited `call` merges them into the per-request headers through `_prepare_request_call`, so only a client that replaces `call` has to do it itself. - A replaced `call` has to accept every argument of the inherited one, including `compression`, which - `ActorServerClient.request` always passes, as `False` unless its caller asks for compression. + A replaced `call` has to accept every argument of the inherited one, including `compression`, + `authorization_header`, `raise_for_status`, and `retry_status_codes`, which `ActorServerClient.request` always + passes. """ def __enter__(self) -> Self: @@ -517,6 +551,9 @@ def call( stream: bool | None = None, timeout: Timeout = 'medium', compression: HttpCompressor | bool = True, + authorization_header: str | None = None, + raise_for_status: bool = True, + retry_status_codes: Collection[int] | None = None, ) -> HttpResponse: """Make an HTTP request with automatic retry and exponential backoff. @@ -537,12 +574,19 @@ def call( `no_timeout` disables the timeout entirely. compression: How to compress the request body. `True` uses the client's compressor, an `HttpCompressor` is used for this request only, and `False` sends the body uncompressed. + authorization_header: Name of the header that carries the client's `Authorization` value for this + request, which then goes out without the `Authorization` header. A per-request header of either name + still wins. + raise_for_status: Whether an error status raises `ApifyApiError`. With `False`, the error response is + returned once no retry follows. + retry_status_codes: Status codes to retry. Defaults to rate limits (429) and server errors (5xx). Returns: The HTTP response object. Raises: - ApifyApiError: If the request fails after all retries or returns a non-retryable error status. + ApifyApiError: If the request fails after all retries or returns a non-retryable error status, unless + `raise_for_status` is `False`. ValueError: If both json and data are provided. TypeError: If data is neither bytes-like nor a value the client can stream. """ @@ -557,6 +601,7 @@ def call( data=data, json=json, compression=compression, + authorization_header=authorization_header, ) return self._retry_with_exp_backoff( @@ -570,6 +615,8 @@ def call( content=content, stream=stream, timeout=timeout, + raise_for_status=raise_for_status, + retry_status_codes=retry_status_codes, ), max_retries=self._max_retries, backoff_base=self._min_delay_between_retries, @@ -655,6 +702,8 @@ def _make_request( content: bytes | StreamedRequestBody | None, stream: bool | None, timeout: Timeout, + raise_for_status: bool, + retry_status_codes: Collection[int] | None, ) -> HttpResponse: """Execute one request attempt through the transport adapter.""" log_context.attempt.set(attempt) @@ -680,7 +729,16 @@ def _make_request( content.close_chunks() if self._handle_response_status( - response, attempt=attempt, streamed=isinstance(content, StreamedRequestBody), stop_retrying=stop_retrying + response, + attempt=attempt, + streamed=isinstance(content, StreamedRequestBody), + stop_retrying=stop_retrying, + retry_status_codes=retry_status_codes, + ): + return response + + if not raise_for_status and self._returns_error_response( + response, attempt=attempt, content=content, retry_status_codes=retry_status_codes ): return response @@ -733,6 +791,9 @@ async def call( stream: bool | None = None, timeout: Timeout = 'medium', compression: HttpCompressor | bool = True, + authorization_header: str | None = None, + raise_for_status: bool = True, + retry_status_codes: Collection[int] | None = None, ) -> HttpResponse: """Make an HTTP request with automatic retry and exponential backoff. @@ -753,12 +814,19 @@ async def call( `no_timeout` disables the timeout entirely. compression: How to compress the request body. `True` uses the client's compressor, an `HttpCompressor` is used for this request only, and `False` sends the body uncompressed. + authorization_header: Name of the header that carries the client's `Authorization` value for this + request, which then goes out without the `Authorization` header. A per-request header of either name + still wins. + raise_for_status: Whether an error status raises `ApifyApiError`. With `False`, the error response is + returned once no retry follows. + retry_status_codes: Status codes to retry. Defaults to rate limits (429) and server errors (5xx). Returns: The HTTP response object. Raises: - ApifyApiError: If the request fails after all retries or returns a non-retryable error status. + ApifyApiError: If the request fails after all retries or returns a non-retryable error status, unless + `raise_for_status` is `False`. ValueError: If both json and data are provided. TypeError: If data is neither bytes-like nor a value the client can stream. """ @@ -779,6 +847,7 @@ async def call( data=data, json=json, compression=compression, + authorization_header=authorization_header, ) else: prepared_headers, prepared_params, content = self._prepare_request_call( @@ -787,6 +856,7 @@ async def call( data=data, json=json, compression=compression, + authorization_header=authorization_header, ) return await self._retry_with_exp_backoff( @@ -800,6 +870,8 @@ async def call( content=content, stream=stream, timeout=timeout, + raise_for_status=raise_for_status, + retry_status_codes=retry_status_codes, ), max_retries=self._max_retries, backoff_base=self._min_delay_between_retries, @@ -885,6 +957,8 @@ async def _make_request( content: bytes | StreamedRequestBody | None, stream: bool | None, timeout: Timeout, + raise_for_status: bool, + retry_status_codes: Collection[int] | None, ) -> HttpResponse: """Execute one request attempt through the transport adapter.""" log_context.attempt.set(attempt) @@ -910,7 +984,16 @@ async def _make_request( await content.aclose_chunks() if self._handle_response_status( - response, attempt=attempt, streamed=isinstance(content, StreamedRequestBody), stop_retrying=stop_retrying + response, + attempt=attempt, + streamed=isinstance(content, StreamedRequestBody), + stop_retrying=stop_retrying, + retry_status_codes=retry_status_codes, + ): + return response + + if not raise_for_status and self._returns_error_response( + response, attempt=attempt, content=content, retry_status_codes=retry_status_codes ): return response diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 778651a5..f4b0111b 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -49,6 +49,10 @@ class ConcreteHttpClient(HttpClient): """Minimal concrete HttpClient for testing base class helpers, relying on the hook defaults.""" +class ConcreteHttpClientAsync(HttpClientAsync): + """Minimal concrete HttpClientAsync for testing base class helpers, relying on the hook defaults.""" + + class CallOnlyHttpClient(HttpClient): """Custom client written against the pre-hook contract, overriding `call` and nothing else.""" @@ -668,6 +672,86 @@ def test_error_response_read_failure_on_non_retryable_status_is_not_retried() -> response.close.assert_called_once() +def error_response(status_code: int) -> Mock: + return Mock(status_code=status_code, text='', headers={}, json=Mock(side_effect=ValueError), aread=AsyncMock()) + + +def test_call_returns_a_non_retryable_error_response_with_raise_for_status_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With `raise_for_status=False`, a non-retryable error response comes back after one attempt, body unread.""" + client = ConcreteHttpClient(min_delay_between_retries=timedelta(0)) + response = error_response(404) + send_request = Mock(return_value=response) + monkeypatch.setattr(client, 'send_request', send_request) + + result = client.call(method='GET', url='https://api.test.com/endpoint', stream=True, raise_for_status=False) + + assert result is response + send_request.assert_called_once() + response.read.assert_not_called() + + +def test_call_returns_the_last_error_response_once_the_retries_run_out(monkeypatch: pytest.MonkeyPatch) -> None: + """With `raise_for_status=False`, a retryable error response is retried, and the last one comes back.""" + client = ConcreteHttpClient(max_retries=2, min_delay_between_retries=timedelta(0)) + responses = [error_response(500) for _ in range(3)] + send_request = Mock(side_effect=responses) + monkeypatch.setattr(client, 'send_request', send_request) + + result = client.call(method='GET', url='https://api.test.com/endpoint', raise_for_status=False) + + assert result is responses[-1] + assert send_request.call_count == 3 + + +async def test_call_returns_the_last_error_response_once_the_retries_run_out_async( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The async client also returns the last retried error response with `raise_for_status=False`.""" + client = ConcreteHttpClientAsync(max_retries=2, min_delay_between_retries=timedelta(0)) + responses = [error_response(500) for _ in range(3)] + send_request = AsyncMock(side_effect=responses) + monkeypatch.setattr(client, 'send_request', send_request) + + result = await client.call(method='GET', url='https://api.test.com/endpoint', raise_for_status=False) + + assert result is responses[-1] + assert send_request.await_count == 3 + + +def test_call_returns_a_retryable_error_response_for_a_body_it_cannot_resend(monkeypatch: pytest.MonkeyPatch) -> None: + """With `raise_for_status=False`, a streamed body that cannot be rewound gets its error response back at once.""" + client = ConcreteHttpClient(min_delay_between_retries=timedelta(0)) + response = error_response(503) + send_request = Mock(return_value=response) + monkeypatch.setattr(client, 'send_request', send_request) + + result = client.call( + method='POST', url='https://api.test.com/endpoint', data=iter([b'chunk']), raise_for_status=False + ) + + assert result is response + send_request.assert_called_once() + + +def test_call_retries_only_the_statuses_in_retry_status_codes(monkeypatch: pytest.MonkeyPatch) -> None: + """`retry_status_codes` replaces the default retried statuses.""" + client = ConcreteHttpClient(min_delay_between_retries=timedelta(0)) + send_request = Mock( + side_effect=[error_response(503), error_response(503), Mock(status_code=200), error_response(500)] + ) + monkeypatch.setattr(client, 'send_request', send_request) + + response = client.call(method='GET', url='https://api.test.com/endpoint', retry_status_codes=[503]) + assert response.status_code == 200 + assert send_request.call_count == 3 + + with pytest.raises(ApifyApiError): + client.call(method='GET', url='https://api.test.com/endpoint', retry_status_codes=[503]) + assert send_request.call_count == 4 + + @pytest.fixture( params=[ pytest.param((GzipHttpCompressor(), 'gzip', gzip.decompress), id='gzip'), @@ -805,6 +889,21 @@ def test_prepare_request_call_uses_per_request_compressor() -> None: assert brotli.decompress(data) == body +def test_prepare_request_call_moves_authorization_to_the_named_header() -> None: + """`authorization_header` sends the client's `Authorization` value in that header, and a caller's own one wins.""" + client = ConcreteHttpClient(token='test_token') + + headers, _params, _data = client._prepare_request_call(authorization_header='X-Apify-Authorization') + own_headers, _params, _data = client._prepare_request_call( + headers={'authorization': 'Basic own'}, authorization_header='X-Apify-Authorization' + ) + + assert headers['X-Apify-Authorization'] == 'Bearer test_token' + assert 'Authorization' not in headers + assert own_headers['X-Apify-Authorization'] == 'Bearer test_token' + assert own_headers['authorization'] == 'Basic own' + + def test_prepare_request_call_compresses_bytearray_data(compressor_case: tuple) -> None: """A `bytearray` body above the threshold is compressed without error (regression: needs bytes conversion).""" compressor, content_encoding, decompress = compressor_case diff --git a/tests/unit/test_pluggable_http_client.py b/tests/unit/test_pluggable_http_client.py index c2d56324..bf466125 100644 --- a/tests/unit/test_pluggable_http_client.py +++ b/tests/unit/test_pluggable_http_client.py @@ -31,7 +31,7 @@ ) if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Collection, Iterator from pytest_httpserver import HTTPServer @@ -101,6 +101,9 @@ def call( stream: bool | None = None, timeout: Timeout = 'medium', compression: HttpCompressor | bool = True, + authorization_header: str | None = None, + raise_for_status: bool = True, + retry_status_codes: Collection[int] | None = None, ) -> HttpResponse: self.calls.append( { @@ -113,6 +116,9 @@ def call( 'stream': stream, 'timeout': timeout, 'compression': compression, + 'authorization_header': authorization_header, + 'raise_for_status': raise_for_status, + 'retry_status_codes': retry_status_codes, } ) return _make_fake_response() @@ -137,6 +143,9 @@ async def call( stream: bool | None = None, timeout: Timeout = 'medium', compression: HttpCompressor | bool = True, + authorization_header: str | None = None, + raise_for_status: bool = True, + retry_status_codes: Collection[int] | None = None, ) -> HttpResponse: self.calls.append( { @@ -149,6 +158,9 @@ async def call( 'stream': stream, 'timeout': timeout, 'compression': compression, + 'authorization_header': authorization_header, + 'raise_for_status': raise_for_status, + 'retry_status_codes': retry_status_codes, } ) return _make_fake_response() From 567fc31e9c7dbf24317202d11a6b83c0ec60d10e Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:06:14 +0100 Subject: [PATCH 28/36] feat: send the token to Actor servers in the X-Apify-Authorization header --- docs/02_concepts/14_server_actors.mdx | 4 ++-- .../_resource_clients/actor_server.py | 16 ++++++++++------ tests/integration/test_actor_server.py | 6 ++++++ tests/unit/test_actor_server.py | 18 ++++++++++++++++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 9308e30f..66cc00c1 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -72,9 +72,9 @@ The following example prints the server's URL and the paths of its endpoints: ## Authentication -The request carries the client's API token in the `Authorization` header. The platform removes the token before it forwards the request, so the token doesn't reach the Actor's code. +The request carries the client's API token in the `X-Apify-Authorization` header. The platform removes the token before it forwards the request, so the token doesn't reach the Actor's code. -If the Actor reads the `Authorization` header for its own authentication, pass that header in `headers`, and send your Apify token in the `X-Apify-Authorization` header instead. A header you pass in `headers` replaces the client's `Authorization` header for that request. +The headers you pass in `headers` reach the Actor's server as they are. If the Actor reads the `Authorization` header for its own authentication, pass that header in `headers`. For details, see [How do I authenticate my requests](https://docs.apify.com/platform/actors/running/standby#how-do-i-authenticate-my-requests) in the platform documentation. diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index e13114c3..89c42367 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -132,9 +132,10 @@ def request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - The request goes to the URL returned by `get_url` and carries the client's token, which the platform consumes, - so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a - server run, which is why the timeout defaults to `long`. + The request goes to the URL returned by `get_url` and carries the client's token in the `X-Apify-Authorization` + header, which the platform consumes, so it never reaches the Actor's code. Headers you pass, `Authorization` + included, reach the Actor's server as they are. The first request to an idle Actor waits for the platform to + start a server run, which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -176,6 +177,7 @@ def request( json=json, timeout=timeout, compression=resolve_compressor(compression) if compression is not None else False, + authorization_header='X-Apify-Authorization', ) @@ -295,9 +297,10 @@ async def request( ) -> HttpResponse: """Send an HTTP request to the Actor's server. - The request goes to the URL returned by `get_url` and carries the client's token, which the platform consumes, - so it never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a - server run, which is why the timeout defaults to `long`. + The request goes to the URL returned by `get_url` and carries the client's token in the `X-Apify-Authorization` + header, which the platform consumes, so it never reaches the Actor's code. Headers you pass, `Authorization` + included, reach the Actor's server as they are. The first request to an idle Actor waits for the platform to + start a server run, which is why the timeout defaults to `long`. A server error or a rate-limit response is retried like an API call, so the server may receive the same request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's @@ -339,4 +342,5 @@ async def request( json=json, timeout=timeout, compression=resolve_compressor(compression) if compression is not None else False, + authorization_header='X-Apify-Authorization', ) diff --git a/tests/integration/test_actor_server.py b/tests/integration/test_actor_server.py index 26f102e3..6828a9d0 100644 --- a/tests/integration/test_actor_server.py +++ b/tests/integration/test_actor_server.py @@ -106,4 +106,10 @@ async def test_actor_server(client: ApifyClient | ApifyClientAsync, server_actor assert echoed['url'] == '/echo?q=apify' assert json.loads(echoed['body']) == {'limit': 5} # The platform consumes the client's token, so it never reaches the Actor's code. + assert 'x-apify-authorization' not in echoed['headers'] assert 'authorization' not in echoed['headers'] + + with_own_authorization = await maybe_await( + actor_server_client.request('/echo', headers={'Authorization': 'Bearer actor-token'}) + ) + assert with_own_authorization.json()['headers']['authorization'] == 'Bearer actor-token' diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index c5f550ac..1a32ffe1 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -70,7 +70,8 @@ def test_request_sends_request_to_server_url(httpserver: HTTPServer, sync_client assert len(captured) == 1 assert captured[0].args.to_dict() == {'q': 'apify'} assert json.loads(captured[0].get_data()) == {'limit': 5} - assert captured[0].headers['Authorization'] == 'Bearer test' + assert captured[0].headers['X-Apify-Authorization'] == 'Bearer test' + assert 'Authorization' not in captured[0].headers async def test_request_sends_request_to_server_url_async( @@ -92,7 +93,20 @@ async def test_request_sends_request_to_server_url_async( assert len(captured) == 1 assert captured[0].args.to_dict() == {'q': 'apify'} assert json.loads(captured[0].get_data()) == {'limit': 5} - assert captured[0].headers['Authorization'] == 'Bearer test' + assert captured[0].headers['X-Apify-Authorization'] == 'Bearer test' + assert 'Authorization' not in captured[0].headers + + +def test_request_sends_caller_authorization_header(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An `Authorization` header passed by the caller reaches the server as it is, next to the client's token.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) + + sync_client.actor(_MOCKED_ACTOR_ID).server().request(headers={'Authorization': 'Bearer actor-token'}) + + captured = server_requests(httpserver) + assert captured[0].headers['Authorization'] == 'Bearer actor-token' + assert captured[0].headers['X-Apify-Authorization'] == 'Bearer test' def test_request_fetches_server_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: From 85da1716eaeb02078c00c05fbe85ed418d931c6c Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:07:06 +0100 Subject: [PATCH 29/36] feat: return Actor server error responses instead of raising --- docs/02_concepts/14_server_actors.mdx | 4 +- docs/02_concepts/code/14_server_async.py | 7 +- docs/02_concepts/code/14_server_sync.py | 7 +- docs/uv.lock | 14 ++++ .../_resource_clients/actor_server.py | 27 +++++-- tests/unit/test_actor_server.py | 77 +++++++++++++++++-- 6 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 docs/uv.lock diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 66cc00c1..094feaf6 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -82,4 +82,6 @@ For details, see [How do I authenticate my requests](https://docs.apify.com/plat When no server run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). -An error status from the Actor's server raises an `ApifyApiError`, like an error from the API. Server errors and rate-limit responses are retried with the client's retry settings, so a request that the server failed to answer may reach it more than once. If your endpoint isn't safe to call twice, lower `max_retries` on the client. For details, see [Retries](./05_retries.mdx) and [Error handling](./04_error_handling.mdx). +The method returns the server's response whatever its status, so check `status_code` before you use the body. Only rate-limit (429) and Service Unavailable (503) responses are retried, with the client's retry settings. Once the retries run out, the method returns the last response. For details, see [Retries](./05_retries.mdx). + +The method raises only when it can't reach the server: an `ApifyApiError` when the Actor doesn't exist, or an `ApifyClientError` when it isn't a Server Actor. For details, see [Error handling](./04_error_handling.mdx). diff --git a/docs/02_concepts/code/14_server_async.py b/docs/02_concepts/code/14_server_async.py index edd7b821..a47b9619 100644 --- a/docs/02_concepts/code/14_server_async.py +++ b/docs/02_concepts/code/14_server_async.py @@ -1,3 +1,5 @@ +from http import HTTPStatus + from apify_client import ApifyClientAsync TOKEN = 'MY-APIFY-TOKEN' @@ -7,8 +9,11 @@ async def main() -> None: apify_client = ApifyClientAsync(TOKEN) actor_server_client = apify_client.actor('username/actor-name').server() - # Send a GET request to the /search endpoint of the Actor's server. + # Send a GET request to the /search endpoint of the Actor's server. The method + # returns error responses too, so check the status first. response = await actor_server_client.request('/search', params={'q': 'apify'}) + if response.status_code != HTTPStatus.OK: + raise RuntimeError(f'Search failed with status {response.status_code}') print(response.json()) # Send a POST request with a JSON body. diff --git a/docs/02_concepts/code/14_server_sync.py b/docs/02_concepts/code/14_server_sync.py index cf425532..b31742e8 100644 --- a/docs/02_concepts/code/14_server_sync.py +++ b/docs/02_concepts/code/14_server_sync.py @@ -1,3 +1,5 @@ +from http import HTTPStatus + from apify_client import ApifyClient TOKEN = 'MY-APIFY-TOKEN' @@ -7,8 +9,11 @@ def main() -> None: apify_client = ApifyClient(TOKEN) actor_server_client = apify_client.actor('username/actor-name').server() - # Send a GET request to the /search endpoint of the Actor's server. + # Send a GET request to the /search endpoint of the Actor's server. The method + # returns error responses too, so check the status first. response = actor_server_client.request('/search', params={'q': 'apify'}) + if response.status_code != HTTPStatus.OK: + raise RuntimeError(f'Search failed with status {response.status_code}') print(response.json()) # Send a POST request with a JSON body. diff --git a/docs/uv.lock b/docs/uv.lock new file mode 100644 index 00000000..e1c93aca --- /dev/null +++ b/docs/uv.lock @@ -0,0 +1,14 @@ +version = 1 +revision = 5 +requires-python = ">=3.15" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "PT24H" + +[options.exclude-newer-package] +apify-client = false +apify-fingerprint-datapoints = false +apify-shared = false +crawlee = false +impit = false diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index 89c42367..96823008 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -16,6 +16,11 @@ from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, QueryParams, Timeout +SERVER_RETRY_STATUS_CODES = frozenset({429, 503}) +"""Statuses a server request is retried on. Other statuses may come from the Actor's code, which a retry would run +again.""" + + @docs_group('Resource clients') class ActorServerClient(ResourceClient): """Sub-client for the server interface of a specific Actor. @@ -137,9 +142,10 @@ def request( included, reach the Actor's server as they are. The first request to an idle Actor waits for the platform to start a server run, which is why the timeout defaults to `long`. - A server error or a rate-limit response is retried like an API call, so the server may receive the same - request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's - server may not decode a compressed body. + The method returns any response of the server, error statuses included. A rate-limit (429) or Service + Unavailable (503) response is retried with the client's retry settings, and the last one is returned once the + retries run out. Request bodies are sent uncompressed unless `compression` is set, since the Actor's server may + not decode a compressed body. https://docs.apify.com/platform/actors/running/standby @@ -163,7 +169,7 @@ def request( Raises: ApifyClientError: If the Actor is not a Server Actor. - ApifyApiError: If the Actor does not exist, or the server responds with an error status. + ApifyApiError: If the Actor does not exist. ImportError: If `compression` needs an optional extra that is not installed. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ @@ -178,6 +184,8 @@ def request( timeout=timeout, compression=resolve_compressor(compression) if compression is not None else False, authorization_header='X-Apify-Authorization', + raise_for_status=False, + retry_status_codes=SERVER_RETRY_STATUS_CODES, ) @@ -302,9 +310,10 @@ async def request( included, reach the Actor's server as they are. The first request to an idle Actor waits for the platform to start a server run, which is why the timeout defaults to `long`. - A server error or a rate-limit response is retried like an API call, so the server may receive the same - request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's - server may not decode a compressed body. + The method returns any response of the server, error statuses included. A rate-limit (429) or Service + Unavailable (503) response is retried with the client's retry settings, and the last one is returned once the + retries run out. Request bodies are sent uncompressed unless `compression` is set, since the Actor's server may + not decode a compressed body. https://docs.apify.com/platform/actors/running/standby @@ -328,7 +337,7 @@ async def request( Raises: ApifyClientError: If the Actor is not a Server Actor. - ApifyApiError: If the Actor does not exist, or the server responds with an error status. + ApifyApiError: If the Actor does not exist. ImportError: If `compression` needs an optional extra that is not installed. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ @@ -343,4 +352,6 @@ async def request( timeout=timeout, compression=resolve_compressor(compression) if compression is not None else False, authorization_header='X-Apify-Authorization', + raise_for_status=False, + retry_status_codes=SERVER_RETRY_STATUS_CODES, ) diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index 1a32ffe1..355e7d08 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -2,19 +2,20 @@ import gzip import json +from datetime import timedelta from typing import TYPE_CHECKING, Any import pytest -from werkzeug import Response +from werkzeug import Request, Response +from apify_client import ApifyClient from apify_client.errors import ApifyClientError, NotFoundError from apify_client.http_compressors import GzipHttpCompressor if TYPE_CHECKING: from pytest_httpserver import HTTPServer - from werkzeug import Request - from apify_client import ApifyClient, ApifyClientAsync + from apify_client import ApifyClientAsync from apify_client.http_compressors import HttpCompressor from apify_client.types import HttpCompressionAlgorithm @@ -305,13 +306,75 @@ def test_request_missing_actor_raises_not_found(httpserver: HTTPServer, sync_cli sync_client.actor(_MOCKED_ACTOR_ID).server().request() -def test_request_error_status_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """An error status from the server raises the matching `ApifyApiError` subclass.""" +def test_request_returns_error_response(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """An error response of the server comes back as it is.""" expect_server_actor(httpserver) httpserver.expect_request(f'{_SERVER_PREFIX}/missing', method='GET').respond_with_data('Not here', status=404) - with pytest.raises(NotFoundError, match='Not here'): - sync_client.actor(_MOCKED_ACTOR_ID).server().request('/missing') + response = sync_client.actor(_MOCKED_ACTOR_ID).server().request('/missing') + + assert response.status_code == 404 + assert response.text == 'Not here' + + +async def test_request_returns_error_response_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async variant of `test_request_returns_error_response`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/missing', method='GET').respond_with_data('Not here', status=404) + + response = await async_client.actor(_MOCKED_ACTOR_ID).server().request('/missing') + + assert response.status_code == 404 + assert response.text == 'Not here' + + +def expect_flaky_server(httpserver: HTTPServer, statuses: list[int]) -> None: + """Answer `/flaky` with each of `statuses` in turn, then with 200.""" + remaining = list(statuses) + + def handler(_request: Request) -> Response: + status = remaining.pop(0) if remaining else 200 + return Response(json.dumps({'status': status}), status=status, content_type='application/json') + + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/flaky').respond_with_handler(handler) + + +def retrying_client(httpserver: HTTPServer, max_retries: int) -> ApifyClient: + api_url = httpserver.url_for('/').removesuffix('/') + return ApifyClient(token='test', api_url=api_url, max_retries=max_retries, min_delay_between_retries=timedelta(0)) + + +@pytest.mark.parametrize('status', [pytest.param(429, id='rate limit'), pytest.param(503, id='service unavailable')]) +def test_request_retries_platform_statuses(httpserver: HTTPServer, status: int) -> None: + """A rate-limit or Service Unavailable response is retried.""" + expect_flaky_server(httpserver, [status, status]) + + response = retrying_client(httpserver, max_retries=2).actor(_MOCKED_ACTOR_ID).server().request('/flaky') + + assert response.status_code == 200 + assert len(server_requests(httpserver)) == 3 + + +def test_request_returns_last_retried_response(httpserver: HTTPServer) -> None: + """Once the retries run out, the last retried response comes back.""" + expect_flaky_server(httpserver, [503, 503, 503]) + + response = retrying_client(httpserver, max_retries=1).actor(_MOCKED_ACTOR_ID).server().request('/flaky') + + assert response.status_code == 503 + assert len(server_requests(httpserver)) == 2 + + +def test_request_does_not_retry_other_server_errors(httpserver: HTTPServer) -> None: + """A server error other than 503 comes back after a single attempt.""" + expect_flaky_server(httpserver, [500]) + + client = retrying_client(httpserver, max_retries=2) + response = client.actor(_MOCKED_ACTOR_ID).server().request('/flaky', method='POST', json={'a': 1}) + + assert response.status_code == 500 + assert len(server_requests(httpserver)) == 1 _OPENAPI_SPEC = {'openapi': '3.0.0', 'info': {'title': 'Test server', 'version': '1.0.0'}, 'paths': {}} From d7f7376ba62699bc0e850223aa62438ca1dc9f10 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:07:47 +0100 Subject: [PATCH 30/36] feat: add a stream option to ActorServerClient.request() --- docs/02_concepts/14_server_actors.mdx | 2 +- .../_resource_clients/actor_server.py | 12 +++++++-- tests/unit/test_actor_server.py | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 094feaf6..b713c322 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -38,7 +38,7 @@ To work with a Server Actor, get its server client with HttpResponse: """Send an HTTP request to the Actor's server. @@ -330,7 +335,9 @@ async def request( compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor` instance. The body is compressed under the same conditions as for API calls. Set it only if the Actor's server decodes the resulting `Content-Encoding`. - timeout: Timeout for the HTTP request. + stream: Whether to return the response with the body unread, so it can be read in chunks with `iter_bytes` + (`aiter_bytes` in the async client). The response holds its connection open until you close it. + timeout: Timeout for the HTTP request. With `stream`, it bounds the wait for the server to start responding. Returns: The response of the Actor's server. @@ -354,4 +361,5 @@ async def request( authorization_header='X-Apify-Authorization', raise_for_status=False, retry_status_codes=SERVER_RETRY_STATUS_CODES, + stream=stream, ) diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index 355e7d08..3ccd62d3 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -328,6 +328,32 @@ async def test_request_returns_error_response_async(httpserver: HTTPServer, asyn assert response.text == 'Not here' +def test_request_returns_response_unread_with_stream(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """With `stream`, the body can be read in chunks.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_data(b'x' * 10_000) + + response = sync_client.actor(_MOCKED_ACTOR_ID).server().request(stream=True) + try: + assert b''.join(response.iter_bytes()) == b'x' * 10_000 + finally: + response.close() + + +async def test_request_returns_response_unread_with_stream_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_request_returns_response_unread_with_stream`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_data(b'x' * 10_000) + + response = await async_client.actor(_MOCKED_ACTOR_ID).server().request(stream=True) + try: + assert b''.join([chunk async for chunk in response.aiter_bytes()]) == b'x' * 10_000 + finally: + await response.aclose() + + def expect_flaky_server(httpserver: HTTPServer, statuses: list[int]) -> None: """Answer `/flaky` with each of `statuses` in turn, then with 200.""" remaining = list(statuses) From d6120586f1447a8df39eef9168d4f3937f226f8c Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:08:24 +0100 Subject: [PATCH 31/36] feat: add a timeout option to ActorServerClient.get_url() and get_openapi_spec() --- .../_resource_clients/actor_server.py | 36 ++++++++++++------- tests/unit/test_actor_server.py | 30 ++++++++++++++++ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index c89515ce..6ef20650 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -43,7 +43,7 @@ def __init__( ) self._url: str | None = None - def get_url(self) -> str: + def get_url(self, *, timeout: Timeout = 'short') -> str: """Return the URL of the Actor's server. The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this @@ -52,6 +52,9 @@ def get_url(self) -> str: https://docs.apify.com/platform/actors/running/standby + Args: + timeout: Timeout for the API HTTP request. + Returns: The URL of the Actor's server. @@ -60,14 +63,14 @@ def get_url(self) -> str: ApifyApiError: If the Actor does not exist. """ if self._url is None: - response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') + response = self._http_client.call(url=self._build_url(), method='GET', timeout=timeout) actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') self._url = actor.standby_url return self._url - def get_openapi_spec(self) -> dict[str, Any] | None: + def get_openapi_spec(self, *, timeout: Timeout = 'short') -> dict[str, Any] | None: """Return the OpenAPI specification of the Actor's server. The specification is the web server schema from the Actor's definition, taken from the build that serves the @@ -76,6 +79,9 @@ def get_openapi_spec(self) -> dict[str, Any] | None: https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + Args: + timeout: Timeout for each API HTTP request. + Returns: The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. @@ -84,7 +90,7 @@ def get_openapi_spec(self) -> dict[str, Any] | None: exist. ApifyApiError: If the Actor does not exist. """ - response = self._http_client.call(url=self._build_url(), method='GET', timeout='short') + response = self._http_client.call(url=self._build_url(), method='GET', timeout=timeout) actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') @@ -101,7 +107,7 @@ def get_openapi_spec(self) -> dict[str, Any] | None: resource_path='builds', **self._base_client_kwargs, ) - for build_item in builds_client.iterate(desc=True): + for build_item in builds_client.iterate(desc=True, timeout=timeout): if build_item.build_number == build_ref: build_id = build_item.id break @@ -115,7 +121,7 @@ def get_openapi_spec(self) -> dict[str, Any] | None: http_client=self._http_client, client_registry=self._client_registry, ) - build = build_client.get() + build = build_client.get(timeout=timeout) if build is None: raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') @@ -215,7 +221,7 @@ def __init__( ) self._url: str | None = None - async def get_url(self) -> str: + async def get_url(self, *, timeout: Timeout = 'short') -> str: """Return the URL of the Actor's server. The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this @@ -224,6 +230,9 @@ async def get_url(self) -> str: https://docs.apify.com/platform/actors/running/standby + Args: + timeout: Timeout for the API HTTP request. + Returns: The URL of the Actor's server. @@ -232,14 +241,14 @@ async def get_url(self) -> str: ApifyApiError: If the Actor does not exist. """ if self._url is None: - response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') + response = await self._http_client.call(url=self._build_url(), method='GET', timeout=timeout) actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') self._url = actor.standby_url return self._url - async def get_openapi_spec(self) -> dict[str, Any] | None: + async def get_openapi_spec(self, *, timeout: Timeout = 'short') -> dict[str, Any] | None: """Return the OpenAPI specification of the Actor's server. The specification is the web server schema from the Actor's definition, taken from the build that serves the @@ -248,6 +257,9 @@ async def get_openapi_spec(self) -> dict[str, Any] | None: https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema + Args: + timeout: Timeout for each API HTTP request. + Returns: The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one. @@ -256,7 +268,7 @@ async def get_openapi_spec(self) -> dict[str, Any] | None: exist. ApifyApiError: If the Actor does not exist. """ - response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short') + response = await self._http_client.call(url=self._build_url(), method='GET', timeout=timeout) actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') @@ -273,7 +285,7 @@ async def get_openapi_spec(self) -> dict[str, Any] | None: resource_path='builds', **self._base_client_kwargs, ) - async for build_item in builds_client.iterate(desc=True): + async for build_item in builds_client.iterate(desc=True, timeout=timeout): if build_item.build_number == build_ref: build_id = build_item.id break @@ -287,7 +299,7 @@ async def get_openapi_spec(self) -> dict[str, Any] | None: http_client=self._http_client, client_registry=self._client_registry, ) - build = await build_client.get() + build = await build_client.get(timeout=timeout) if build is None: raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.') diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index 3ccd62d3..9070b08b 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -4,6 +4,7 @@ import json from datetime import timedelta from typing import TYPE_CHECKING, Any +from unittest.mock import Mock import pytest from werkzeug import Request, Response @@ -496,6 +497,35 @@ def test_get_openapi_spec_from_build_number(httpserver: HTTPServer, sync_client: assert sync_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec() == _OPENAPI_SPEC +def test_get_openapi_spec_passes_timeout_to_every_api_call( + httpserver: HTTPServer, sync_client: ApifyClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The timeout of `get_openapi_spec` applies to fetching the Actor, listing its builds, and fetching the build.""" + expect_server_actor_with_build( + httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + expect_builds_list(httpserver, {'old_build_id': '0.0.1'}) + call = Mock(wraps=sync_client.http_client.call) + monkeypatch.setattr(sync_client.http_client, 'call', call) + + sync_client.actor(_MOCKED_ACTOR_ID).server().get_openapi_spec(timeout='long') + + assert [call_args.kwargs['timeout'] for call_args in call.call_args_list] == ['long', 'long', 'long'] + + +def test_get_url_passes_timeout( + httpserver: HTTPServer, sync_client: ApifyClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The timeout of `get_url` applies to fetching the Actor.""" + expect_server_actor(httpserver) + call = Mock(wraps=sync_client.http_client.call) + monkeypatch.setattr(sync_client.http_client, 'call', call) + + sync_client.actor(_MOCKED_ACTOR_ID).server().get_url(timeout='medium') + + assert call.call_args.kwargs['timeout'] == 'medium' + + async def test_get_openapi_spec_from_build_number_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: """Async variant of `test_get_openapi_spec_from_build_number`.""" expect_server_actor_with_build( From f79dc9e5ad71d7dc6ef47c9519a44425d697042e Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:08:51 +0100 Subject: [PATCH 32/36] perf: reuse the server URL fetched by get_openapi_spec() --- .../_resource_clients/actor_server.py | 14 ++++++++------ tests/unit/test_actor_server.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index 6ef20650..04a2756b 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -46,9 +46,9 @@ def __init__( def get_url(self, *, timeout: Timeout = 'short') -> str: """Return the URL of the Actor's server. - The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this - client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. + The URL is the Actor's `standby_url`, fetched from the API by the first call of this method or + `get_openapi_spec` and reused by later calls on this client. Use it to call the server with another HTTP client, + for example one generated from the server's OpenAPI specification. https://docs.apify.com/platform/actors/running/standby @@ -94,6 +94,7 @@ def get_openapi_spec(self, *, timeout: Timeout = 'short') -> dict[str, Any] | No actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._url = actor.standby_url build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' @@ -224,9 +225,9 @@ def __init__( async def get_url(self, *, timeout: Timeout = 'short') -> str: """Return the URL of the Actor's server. - The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this - client. Use it to call the server with another HTTP client, for example one generated from the server's - OpenAPI specification. + The URL is the Actor's `standby_url`, fetched from the API by the first call of this method or + `get_openapi_spec` and reused by later calls on this client. Use it to call the server with another HTTP client, + for example one generated from the server's OpenAPI specification. https://docs.apify.com/platform/actors/running/standby @@ -272,6 +273,7 @@ async def get_openapi_spec(self, *, timeout: Timeout = 'short') -> dict[str, Any actor = ActorResponse.model_validate(response_to_dict(response)).data if actor.standby_url is None: raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.') + self._url = actor.standby_url build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest' diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index 9070b08b..1499a49d 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -513,6 +513,23 @@ def test_get_openapi_spec_passes_timeout_to_every_api_call( assert [call_args.kwargs['timeout'] for call_args in call.call_args_list] == ['long', 'long', 'long'] +def test_request_reuses_server_url_fetched_by_get_openapi_spec( + httpserver: HTTPServer, sync_client: ApifyClient +) -> None: + """A request after `get_openapi_spec` reuses the server URL it fetched.""" + expect_server_actor_with_build( + httpserver, standby_build=None, build_id='latest_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC} + ) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) + actor_server_client = sync_client.actor(_MOCKED_ACTOR_ID).server() + + actor_server_client.get_openapi_spec() + actor_server_client.request() + + actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH] + assert len(actor_gets) == 1 + + def test_get_url_passes_timeout( httpserver: HTTPServer, sync_client: ApifyClient, monkeypatch: pytest.MonkeyPatch ) -> None: From 23928aad9e90b4ec4fd0c33103490291f8b64795 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:08:57 +0100 Subject: [PATCH 33/36] docs: drop the cold-start duration from the Server Actors page --- docs/02_concepts/14_server_actors.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index b713c322..3f0dd364 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -80,7 +80,7 @@ For details, see [How do I authenticate my requests](https://docs.apify.com/plat ## Timeouts, retries, and errors -When no server run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). +When no server run is ready, the first request waits for the platform to start one. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). The method returns the server's response whatever its status, so check `status_code` before you use the body. Only rate-limit (429) and Service Unavailable (503) responses are retried, with the client's retry settings. Once the retries run out, the method returns the last response. For details, see [Retries](./05_retries.mdx). From b90d6f8bc5f134202793fe2d956809e79a0a9eb2 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 18:43:04 +0100 Subject: [PATCH 34/36] test: cover Actor server error responses in the integration test --- tests/integration/test_actor_server.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_actor_server.py b/tests/integration/test_actor_server.py index 6828a9d0..9045cb78 100644 --- a/tests/integration/test_actor_server.py +++ b/tests/integration/test_actor_server.py @@ -24,7 +24,8 @@ 'paths': {'/echo': {'post': {'responses': {'200': {'description': 'The echoed request.'}}}}}, } -# A web server that answers every request, including the platform's readiness probe, with the request it received. +# A web server that answers every request, including the platform's readiness probe, with the request it received, +# under the status in the `status` query parameter, or 200. ECHO_SERVER_SOURCE = """ const http = require('http'); @@ -32,7 +33,8 @@ let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }); + const status = Number(new URL(req.url, 'http://localhost').searchParams.get('status')) || 200; + res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ method: req.method, url: req.url, headers: req.headers, body })); }); }).listen(process.env.ACTOR_WEB_SERVER_PORT); @@ -113,3 +115,7 @@ async def test_actor_server(client: ApifyClient | ApifyClientAsync, server_actor actor_server_client.request('/echo', headers={'Authorization': 'Bearer actor-token'}) ) assert with_own_authorization.json()['headers']['authorization'] == 'Bearer actor-token' + + error_response = await maybe_await(actor_server_client.request('/echo', params={'status': 404})) + assert error_response.status_code == 404 + assert error_response.json()['url'] == '/echo?status=404' From 95e94faa71a5f16241f654f3a2c13e9a4002b31c Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 30 Oct 2026 09:59:24 +0100 Subject: [PATCH 35/36] feat: stop retrying Actor server requests after transport errors --- docs/02_concepts/10_custom_http_clients.mdx | 2 +- docs/02_concepts/14_server_actors.mdx | 2 +- .../_resource_clients/actor_server.py | 2 ++ src/apify_client/http_clients/_base.py | 30 ++++++++++++++----- tests/unit/test_actor_server.py | 24 +++++++++++++++ tests/unit/test_http_clients.py | 24 +++++++++++++++ tests/unit/test_pluggable_http_client.py | 4 +++ 7 files changed, 79 insertions(+), 9 deletions(-) diff --git a/docs/02_concepts/10_custom_http_clients.mdx b/docs/02_concepts/10_custom_http_clients.mdx index ad05f9f1..2483a8cf 100644 --- a/docs/02_concepts/10_custom_http_clients.mdx +++ b/docs/02_concepts/10_custom_http_clients.mdx @@ -151,7 +151,7 @@ Use the `ApifyClient.wit After that, all API calls made through the client will go through your custom HTTP client. :::warning -If you override `call` itself, your implementation becomes responsible for request preparation, retries, timeouts, API error conversion, logging, and statistics. Implementing the transport hooks and inheriting `call` keeps the shared behavior. +If you override `call` itself, your implementation becomes responsible for request preparation, retries, timeouts, API error conversion, logging, and statistics. It also has to honor the `compression`, `authorization_header`, `raise_for_status`, `retry_status_codes`, and `retry_transport_errors` arguments. `ActorServerClient.request` relies on them to keep the API token away from the Actor's code, to return the server's error responses, and to avoid sending a request to the Actor twice. Implementing the transport hooks and inheriting `call` keeps the shared behavior. ::: If you override `call` and prepare requests with the inherited helpers, a streamed body arrives as a `StreamedRequestBody`. Send its `iter_bytes()` or `aiter_bytes()`, and send the body a second time only when `rewindable` is true, after calling `rewind()`. For what the client streams and how, see [Streaming uploads](./09_streaming.mdx#streaming-uploads). diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 3f0dd364..576321d7 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -82,6 +82,6 @@ For details, see [How do I authenticate my requests](https://docs.apify.com/plat When no server run is ready, the first request waits for the platform to start one. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). -The method returns the server's response whatever its status, so check `status_code` before you use the body. Only rate-limit (429) and Service Unavailable (503) responses are retried, with the client's retry settings. Once the retries run out, the method returns the last response. For details, see [Retries](./05_retries.mdx). +The method returns the server's response whatever its status, so check `status_code` before you use the body. Only rate-limit (429) and Service Unavailable (503) responses are retried, with the client's retry settings. Once the retries run out, the method returns the last response. Network errors and timeouts aren't retried, since the Actor may have already handled the request. For details, see [Retries](./05_retries.mdx). The method raises only when it can't reach the server: an `ApifyApiError` when the Actor doesn't exist, or an `ApifyClientError` when it isn't a Server Actor. For details, see [Error handling](./04_error_handling.mdx). diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index 04a2756b..06fea4e8 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -196,6 +196,7 @@ def request( authorization_header='X-Apify-Authorization', raise_for_status=False, retry_status_codes=SERVER_RETRY_STATUS_CODES, + retry_transport_errors=False, stream=stream, ) @@ -375,5 +376,6 @@ async def request( authorization_header='X-Apify-Authorization', raise_for_status=False, retry_status_codes=SERVER_RETRY_STATUS_CODES, + retry_transport_errors=False, stream=stream, ) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 42538950..1cbd8112 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -406,8 +406,9 @@ def _handle_request_exception( *, content: bytes | StreamedRequestBody | None, stop_retrying: Callable[[], None], + retry_transport_errors: bool, ) -> None: - """Stop retrying when an exception is not a retryable transport failure. + """Stop retrying when an exception is not a retryable transport failure, or the request retries none. A failure of a streamed body's source stops retrying too, since sending the body again cannot fix it. The transport reports such a failure as its own error, which may wrap the cause beyond recognition and pass as @@ -423,7 +424,7 @@ def _handle_request_exception( raise source_error from exc return - if not self.is_retryable_transport_error(exc): + if not retry_transport_errors or not self.is_retryable_transport_error(exc): logger.debug('Exception is not retryable', exc_info=exc) stop_retrying() @@ -521,9 +522,10 @@ class HttpClient(HttpClientBase): `Authorization` header never reaches the API. The inherited `call` merges them into the per-request headers through `_prepare_request_call`, so only a client that replaces `call` has to do it itself. - A replaced `call` has to accept every argument of the inherited one, including `compression`, - `authorization_header`, `raise_for_status`, and `retry_status_codes`, which `ActorServerClient.request` always - passes. + A replaced `call` has to accept every argument of the inherited one and honor `compression`, + `authorization_header`, `raise_for_status`, `retry_status_codes`, and `retry_transport_errors`. + `ActorServerClient.request` relies on them to keep the token away from the Actor's code, to return the server's + error responses, and to avoid sending a request to the Actor twice. """ def __enter__(self) -> Self: @@ -554,6 +556,7 @@ def call( authorization_header: str | None = None, raise_for_status: bool = True, retry_status_codes: Collection[int] | None = None, + retry_transport_errors: bool = True, ) -> HttpResponse: """Make an HTTP request with automatic retry and exponential backoff. @@ -580,6 +583,8 @@ def call( raise_for_status: Whether an error status raises `ApifyApiError`. With `False`, the error response is returned once no retry follows. retry_status_codes: Status codes to retry. Defaults to rate limits (429) and server errors (5xx). + retry_transport_errors: Whether transport errors, timeouts included, are retried. With `False`, the + first one is raised, since the request may have reached the server before it failed. Returns: The HTTP response object. @@ -617,6 +622,7 @@ def call( timeout=timeout, raise_for_status=raise_for_status, retry_status_codes=retry_status_codes, + retry_transport_errors=retry_transport_errors, ), max_retries=self._max_retries, backoff_base=self._min_delay_between_retries, @@ -704,6 +710,7 @@ def _make_request( timeout: Timeout, raise_for_status: bool, retry_status_codes: Collection[int] | None, + retry_transport_errors: bool, ) -> HttpResponse: """Execute one request attempt through the transport adapter.""" log_context.attempt.set(attempt) @@ -722,7 +729,9 @@ def _make_request( stream=stream or False, ) except Exception as exc: - self._handle_request_exception(exc, content=content, stop_retrying=stop_retrying) + self._handle_request_exception( + exc, content=content, stop_retrying=stop_retrying, retry_transport_errors=retry_transport_errors + ) raise finally: if isinstance(content, StreamedRequestBody): @@ -794,6 +803,7 @@ async def call( authorization_header: str | None = None, raise_for_status: bool = True, retry_status_codes: Collection[int] | None = None, + retry_transport_errors: bool = True, ) -> HttpResponse: """Make an HTTP request with automatic retry and exponential backoff. @@ -820,6 +830,8 @@ async def call( raise_for_status: Whether an error status raises `ApifyApiError`. With `False`, the error response is returned once no retry follows. retry_status_codes: Status codes to retry. Defaults to rate limits (429) and server errors (5xx). + retry_transport_errors: Whether transport errors, timeouts included, are retried. With `False`, the + first one is raised, since the request may have reached the server before it failed. Returns: The HTTP response object. @@ -872,6 +884,7 @@ async def call( timeout=timeout, raise_for_status=raise_for_status, retry_status_codes=retry_status_codes, + retry_transport_errors=retry_transport_errors, ), max_retries=self._max_retries, backoff_base=self._min_delay_between_retries, @@ -959,6 +972,7 @@ async def _make_request( timeout: Timeout, raise_for_status: bool, retry_status_codes: Collection[int] | None, + retry_transport_errors: bool, ) -> HttpResponse: """Execute one request attempt through the transport adapter.""" log_context.attempt.set(attempt) @@ -977,7 +991,9 @@ async def _make_request( stream=stream or False, ) except Exception as exc: - self._handle_request_exception(exc, content=content, stop_retrying=stop_retrying) + self._handle_request_exception( + exc, content=content, stop_retrying=stop_retrying, retry_transport_errors=retry_transport_errors + ) raise finally: if isinstance(content, StreamedRequestBody): diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index 1499a49d..9f365f13 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -2,6 +2,7 @@ import gzip import json +import threading from datetime import timedelta from typing import TYPE_CHECKING, Any from unittest.mock import Mock @@ -404,6 +405,29 @@ def test_request_does_not_retry_other_server_errors(httpserver: HTTPServer) -> N assert len(server_requests(httpserver)) == 1 +def test_request_does_not_retry_a_timeout(httpserver: HTTPServer) -> None: + """A request that times out is not sent again, since the Actor may have already handled it.""" + release = threading.Event() + + def wait_for_release(_request: Request) -> Response: + release.wait(timeout=5) + return Response('late') + + handler = Mock(side_effect=wait_for_release) + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/slow').respond_with_handler(handler) + client = retrying_client(httpserver, max_retries=2) + + try: + with pytest.raises(Exception) as exc_info: # noqa: PT011 - the error type depends on the transport + client.actor(_MOCKED_ACTOR_ID).server().request('/slow', method='POST', timeout=timedelta(milliseconds=200)) + finally: + release.set() + + assert client.http_client.is_timeout_error(exc_info.value) + handler.assert_called_once() + + _OPENAPI_SPEC = {'openapi': '3.0.0', 'info': {'title': 'Test server', 'version': '1.0.0'}, 'paths': {}} _TAGGED_BUILDS = { 'latest': {'buildId': 'latest_build_id', 'buildNumber': '0.0.2'}, diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index f4b0111b..e515135a 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -543,6 +543,30 @@ def test_transient_transport_error_is_retried() -> None: assert request.call_count == 3 +def test_transport_error_is_not_retried_with_retry_transport_errors_false() -> None: + """With `retry_transport_errors=False`, a transient transport failure is raised on the first attempt.""" + client = ImpitHttpClient(token='test_token', max_retries=2, min_delay_between_retries=timedelta(0)) + request = Mock(side_effect=impit.TimeoutException('timeout')) + client._impit_client = Mock(request=request) + + with pytest.raises(impit.TimeoutException): + client.call(method='POST', url='https://api.test.com/endpoint', retry_transport_errors=False) + + request.assert_called_once() + + +async def test_transport_error_is_not_retried_with_retry_transport_errors_false_async() -> None: + """The async client also raises the first transport failure with `retry_transport_errors=False`.""" + client = ImpitHttpClientAsync(token='test_token', max_retries=2, min_delay_between_retries=timedelta(0)) + request = AsyncMock(side_effect=impit.TimeoutException('timeout')) + client._impit_async_client = Mock(request=request) + + with pytest.raises(impit.TimeoutException): + await client.call(method='POST', url='https://api.test.com/endpoint', retry_transport_errors=False) + + request.assert_awaited_once() + + def test_httpx2_permanent_transport_error_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None: """The HTTPX2 adapter feeds the same fail-fast classification into the shared pipeline.""" with Httpx2HttpClient(token='test_token', min_delay_between_retries=timedelta(0)) as client: diff --git a/tests/unit/test_pluggable_http_client.py b/tests/unit/test_pluggable_http_client.py index bf466125..866d4396 100644 --- a/tests/unit/test_pluggable_http_client.py +++ b/tests/unit/test_pluggable_http_client.py @@ -104,6 +104,7 @@ def call( authorization_header: str | None = None, raise_for_status: bool = True, retry_status_codes: Collection[int] | None = None, + retry_transport_errors: bool = True, ) -> HttpResponse: self.calls.append( { @@ -119,6 +120,7 @@ def call( 'authorization_header': authorization_header, 'raise_for_status': raise_for_status, 'retry_status_codes': retry_status_codes, + 'retry_transport_errors': retry_transport_errors, } ) return _make_fake_response() @@ -146,6 +148,7 @@ async def call( authorization_header: str | None = None, raise_for_status: bool = True, retry_status_codes: Collection[int] | None = None, + retry_transport_errors: bool = True, ) -> HttpResponse: self.calls.append( { @@ -161,6 +164,7 @@ async def call( 'authorization_header': authorization_header, 'raise_for_status': raise_for_status, 'retry_status_codes': retry_status_codes, + 'retry_transport_errors': retry_transport_errors, } ) return _make_fake_response() From 0581db01fb3ba41ad34008bfdf7814c40e12b5c2 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 30 Oct 2026 09:59:26 +0100 Subject: [PATCH 36/36] docs: list every error ActorServerClient.request can raise --- docs/02_concepts/14_server_actors.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 576321d7..7b6eed94 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -84,4 +84,4 @@ When no server run is ready, the first request waits for the platform to start o The method returns the server's response whatever its status, so check `status_code` before you use the body. Only rate-limit (429) and Service Unavailable (503) responses are retried, with the client's retry settings. Once the retries run out, the method returns the last response. Network errors and timeouts aren't retried, since the Actor may have already handled the request. For details, see [Retries](./05_retries.mdx). -The method raises only when it can't reach the server: an `ApifyApiError` when the Actor doesn't exist, or an `ApifyClientError` when it isn't a Server Actor. For details, see [Error handling](./04_error_handling.mdx). +The method raises only when it doesn't get a response from the server: an `ApifyApiError` when the API can't return the Actor, for example because it doesn't exist, an `ApifyClientError` when it isn't a Server Actor, or the HTTP library's error when the request fails or times out. For details, see [Error handling](./04_error_handling.mdx).