From d912c738b644f7926cf9f63d146ac59a28f1668e Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 19 Oct 2026 16:31:01 +0200 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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