From 1a516414cc48b5f945b422890964a2252475f6bc Mon Sep 17 00:00:00 2001 From: Jan Buchar Date: Mon, 9 Nov 2026 16:23:14 +0100 Subject: [PATCH 1/2] chore: Add parentRequestId to server().request method --- docs/02_concepts/14_server_actors.mdx | 15 +++++++++++++++ .../_resource_clients/actor_server.py | 10 ++++++++++ tests/unit/test_actor_server.py | 13 +++++++++++++ 3 files changed, 38 insertions(+) diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index 5833679c..ad0e0af7 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -78,6 +78,21 @@ The headers you pass in `headers` reach the Actor's server as they are. If the A 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. +## Calling a Server Actor from a Server Actor + +A multi-tenant Server Actor shares the developer's runs among its users, so the platform tells requests apart by an ID it sends in the `X-Actor-Request-ID` header of each request it forwards to the Actor. The Actor uses that ID to charge the request's caller, and to pass the caller's pricing tier on to Actors it calls. + +When your multi-tenant Server Actor calls another one while handling a request, pass the incoming `X-Actor-Request-ID` header in `parent_request_id`. The client sends it as the same header, so the called Actor charges at the pricing tier of your incoming request. Use a client created with the run's `APIFY_TOKEN`. For details, see [Compose multi-tenant Server Actors](https://docs.apify.com/platform/actors/development/programming-interface/standby#compose-server-actors). + +```python +server_client = client.actor('username/other-actor').server() +response = await server_client.request( + '/search', + params={'q': 'apify'}, + parent_request_id=request.headers['x-actor-request-id'], +) +``` + ## Timeouts, retries, and errors When no server run is ready, the first request waits for the platform to start one. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx). diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index afddb71c..7d6a27f0 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -159,6 +159,7 @@ def request( headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, + parent_request_id: str | None = None, compression: HttpCompressionAlgorithm | HttpCompressor | None = None, stream: bool = False, timeout: Timeout = 'long', @@ -190,6 +191,8 @@ def 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`. + parent_request_id: The `X-Actor-Request-ID` header of the request your Server Actor is handling, sent on + as the same header so the called Actor charges at that request's pricing tier. 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`. @@ -208,6 +211,8 @@ def request( """ server_url = self.get_url() server_path = path.partition('#')[0].lstrip('/') + if parent_request_id is not None: + headers = self._http_client._merge_headers(headers, {'X-Actor-Request-ID': parent_request_id}) # noqa: SLF001 return self._http_client.call( url=f'{server_url.rstrip("/")}/{server_path}', method=method, @@ -355,6 +360,7 @@ async def request( headers: dict[str, str] | None = None, data: str | bytes | None = None, json: JsonSerializable | None = None, + parent_request_id: str | None = None, compression: HttpCompressionAlgorithm | HttpCompressor | None = None, stream: bool = False, timeout: Timeout = 'long', @@ -386,6 +392,8 @@ async def 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`. + parent_request_id: The `X-Actor-Request-ID` header of the request your Server Actor is handling, sent on + as the same header so the called Actor charges at that request's pricing tier. 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`. @@ -404,6 +412,8 @@ async def request( """ server_url = await self.get_url() server_path = path.partition('#')[0].lstrip('/') + if parent_request_id is not None: + headers = self._http_client._merge_headers(headers, {'X-Actor-Request-ID': parent_request_id}) # noqa: SLF001 return await self._http_client.call( url=f'{server_url.rstrip("/")}/{server_path}', method=method, diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index 018f738e..dfa10e14 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -112,6 +112,19 @@ def test_request_sends_caller_authorization_header(httpserver: HTTPServer, sync_ assert captured[0].headers['X-Apify-Authorization'] == 'Bearer test' +def test_request_sends_parent_request_id_header(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`parent_request_id` goes out as the `X-Actor-Request-ID` header, over one passed in `headers`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) + + sync_client.actor(_MOCKED_ACTOR_ID).server().request( + headers={'x-actor-request-id': 'stale'}, parent_request_id='parent-id' + ) + + captured = server_requests(httpserver) + assert captured[0].headers.getlist('X-Actor-Request-ID') == ['parent-id'] + + def test_request_fetches_server_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """Later calls on the same client reuse the server URL fetched by the first one.""" expect_server_actor(httpserver) From 72924b6d20b8ee496119c223526e0f0a429ece0f Mon Sep 17 00:00:00 2001 From: Jan Buchar Date: Wed, 11 Nov 2026 12:00:39 +0100 Subject: [PATCH 2/2] Address review feedback --- docs/02_concepts/14_server_actors.mdx | 24 ++++++++++++------- .../code/14_server_parent_request_async.py | 20 ++++++++++++++++ .../code/14_server_parent_request_sync.py | 20 ++++++++++++++++ .../_resource_clients/actor_server.py | 12 ++++++---- src/apify_client/_utils/http.py | 14 +++++++++++ src/apify_client/http_clients/_base.py | 24 ++++--------------- tests/unit/test_actor_server.py | 17 +++++++++++++ tests/unit/test_http_clients.py | 21 ---------------- tests/unit/test_utils.py | 22 +++++++++++++++++ 9 files changed, 120 insertions(+), 54 deletions(-) create mode 100644 docs/02_concepts/code/14_server_parent_request_async.py create mode 100644 docs/02_concepts/code/14_server_parent_request_sync.py diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx index ad0e0af7..99b672e6 100644 --- a/docs/02_concepts/14_server_actors.mdx +++ b/docs/02_concepts/14_server_actors.mdx @@ -14,6 +14,8 @@ import ServerAsyncExample from '!!raw-loader!./code/14_server_async.py'; import ServerSyncExample from '!!raw-loader!./code/14_server_sync.py'; import ServerUrlSpecAsyncExample from '!!raw-loader!./code/14_server_url_spec_async.py'; import ServerUrlSpecSyncExample from '!!raw-loader!./code/14_server_url_spec_sync.py'; +import ServerParentRequestAsyncExample from '!!raw-loader!./code/14_server_parent_request_async.py'; +import ServerParentRequestSyncExample from '!!raw-loader!./code/14_server_parent_request_sync.py'; A [Server Actor](https://docs.apify.com/platform/actors/running/standby) (formerly Actor Standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. @@ -82,16 +84,20 @@ For details, see [How do I authenticate my requests](https://docs.apify.com/plat A multi-tenant Server Actor shares the developer's runs among its users, so the platform tells requests apart by an ID it sends in the `X-Actor-Request-ID` header of each request it forwards to the Actor. The Actor uses that ID to charge the request's caller, and to pass the caller's pricing tier on to Actors it calls. -When your multi-tenant Server Actor calls another one while handling a request, pass the incoming `X-Actor-Request-ID` header in `parent_request_id`. The client sends it as the same header, so the called Actor charges at the pricing tier of your incoming request. Use a client created with the run's `APIFY_TOKEN`. For details, see [Compose multi-tenant Server Actors](https://docs.apify.com/platform/actors/development/programming-interface/standby#compose-server-actors). +When your multi-tenant Server Actor calls another one while handling a request, pass the `X-Actor-Request-ID` header of the incoming request in `parent_request_id`. The client sends it as the same header on the outgoing request, so the called Actor charges at the pricing tier of the incoming one. Use a client created with the run's `APIFY_TOKEN`. For details, see [Compose multi-tenant Server Actors](https://docs.apify.com/platform/actors/development/programming-interface/standby#compose-server-actors). -```python -server_client = client.actor('username/other-actor').server() -response = await server_client.request( - '/search', - params={'q': 'apify'}, - parent_request_id=request.headers['x-actor-request-id'], -) -``` + + + + {ServerParentRequestAsyncExample} + + + + + {ServerParentRequestSyncExample} + + + ## Timeouts, retries, and errors diff --git a/docs/02_concepts/code/14_server_parent_request_async.py b/docs/02_concepts/code/14_server_parent_request_async.py new file mode 100644 index 00000000..990661e6 --- /dev/null +++ b/docs/02_concepts/code/14_server_parent_request_async.py @@ -0,0 +1,20 @@ +import os +from collections.abc import Mapping +from typing import Any + +from apify_client import ApifyClientAsync + + +async def search_other_actor(incoming_headers: Mapping[str, str]) -> Any: + # Call this while handling a request your Server Actor received, with the headers + # of that incoming request. The client uses the token of the Actor's run. + apify_client = ApifyClientAsync(os.environ['APIFY_TOKEN']) + other_server_client = apify_client.actor('username/other-actor').server() + + # The outgoing request is charged at the pricing tier of the incoming one. + response = await other_server_client.request( + '/search', + params={'q': 'apify'}, + parent_request_id=incoming_headers['X-Actor-Request-ID'], + ) + return response.json() diff --git a/docs/02_concepts/code/14_server_parent_request_sync.py b/docs/02_concepts/code/14_server_parent_request_sync.py new file mode 100644 index 00000000..12fbea0b --- /dev/null +++ b/docs/02_concepts/code/14_server_parent_request_sync.py @@ -0,0 +1,20 @@ +import os +from collections.abc import Mapping +from typing import Any + +from apify_client import ApifyClient + + +def search_other_actor(incoming_headers: Mapping[str, str]) -> Any: + # Call this while handling a request your Server Actor received, with the headers + # of that incoming request. The client uses the token of the Actor's run. + apify_client = ApifyClient(os.environ['APIFY_TOKEN']) + other_server_client = apify_client.actor('username/other-actor').server() + + # The outgoing request is charged at the pricing tier of the incoming one. + response = other_server_client.request( + '/search', + params={'q': 'apify'}, + parent_request_id=incoming_headers['X-Actor-Request-ID'], + ) + return response.json() diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index 7d6a27f0..0821c9d4 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -6,7 +6,7 @@ from apify_client._docs import docs_group from apify_client._models import ActorResponse from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils.http import response_to_dict +from apify_client._utils.http import merge_headers, response_to_dict from apify_client.errors import ApifyClientError from apify_client.http_compressors._resolve import resolve_compressor @@ -192,7 +192,8 @@ def request( data: Raw request body. Cannot be used together with `json`. json: JSON-serializable request body. Cannot be used together with `data`. parent_request_id: The `X-Actor-Request-ID` header of the request your Server Actor is handling, sent on - as the same header so the called Actor charges at that request's pricing tier. + as the same header so the called Actor charges at that request's pricing tier. It replaces an + `X-Actor-Request-ID` passed in `headers`. 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`. @@ -212,7 +213,7 @@ def request( server_url = self.get_url() server_path = path.partition('#')[0].lstrip('/') if parent_request_id is not None: - headers = self._http_client._merge_headers(headers, {'X-Actor-Request-ID': parent_request_id}) # noqa: SLF001 + headers = merge_headers(headers, {'X-Actor-Request-ID': parent_request_id}) return self._http_client.call( url=f'{server_url.rstrip("/")}/{server_path}', method=method, @@ -393,7 +394,8 @@ async def request( data: Raw request body. Cannot be used together with `json`. json: JSON-serializable request body. Cannot be used together with `data`. parent_request_id: The `X-Actor-Request-ID` header of the request your Server Actor is handling, sent on - as the same header so the called Actor charges at that request's pricing tier. + as the same header so the called Actor charges at that request's pricing tier. It replaces an + `X-Actor-Request-ID` passed in `headers`. 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`. @@ -413,7 +415,7 @@ async def request( server_url = await self.get_url() server_path = path.partition('#')[0].lstrip('/') if parent_request_id is not None: - headers = self._http_client._merge_headers(headers, {'X-Actor-Request-ID': parent_request_id}) # noqa: SLF001 + headers = merge_headers(headers, {'X-Actor-Request-ID': parent_request_id}) return await self._http_client.call( url=f'{server_url.rstrip("/")}/{server_path}', method=method, diff --git a/src/apify_client/_utils/http.py b/src/apify_client/_utils/http.py index fab01a78..d3fbba6e 100644 --- a/src/apify_client/_utils/http.py +++ b/src/apify_client/_utils/http.py @@ -158,6 +158,20 @@ def response_to_list(response: HttpResponse) -> list: raise ValueError(f'The response is not a list. Got: {type(data).__name__}') +def merge_headers(base: dict[str, str] | None, override: dict[str, str] | None) -> dict[str, str]: + """Merge two header dicts, treating header names case-insensitively. + + A header from `override` replaces a same-named header in `base` regardless of the casing + of either name, and keeps the casing it was passed with. + """ + merged = dict(base) if base else {} + for key, value in (override or {}).items(): + for existing_key in [k for k in merged if k.lower() == key.lower()]: + del merged[existing_key] + merged[key] = value + return merged + + def check_custom_headers(class_name: str, headers: dict[str, str]) -> None: """Warn if custom headers override important default headers.""" overwrite_headers = [key for key in headers if key.title() in OVERRIDABLE_DEFAULT_HEADERS] diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index efd79f07..34e76af0 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -31,7 +31,7 @@ from apify_client._docs import docs_group from apify_client._logging import LoggerOnce, log_context, logger_name from apify_client._statistics import ClientStatistics -from apify_client._utils.http import is_compressible_content_type +from apify_client._utils.http import is_compressible_content_type, merge_headers from apify_client._utils.time import to_seconds from apify_client.errors import ApifyApiError from apify_client.http_clients._streamed_body import StreamedRequestBody @@ -188,7 +188,7 @@ def __init__( if token is not None: default_headers['Authorization'] = f'Bearer {token}' - self._headers = self._merge_headers(default_headers, headers) + self._headers = merge_headers(default_headers, headers) def set_default_authorization(self, token: str) -> None: """Set the `Authorization` header from the token, unless an authorization header is already configured. @@ -216,20 +216,6 @@ def is_retryable_transport_error(self, exc: Exception) -> bool: _ = exc return False - @staticmethod - def _merge_headers(base: dict[str, str] | None, override: dict[str, str] | None) -> dict[str, str]: - """Merge two header dicts, treating header names case-insensitively. - - A header from `override` replaces a same-named header in `base` regardless of the casing - of either name, and keeps the casing it was passed with. - """ - merged = dict(base) if base else {} - for key, value in (override or {}).items(): - for existing_key in [k for k in merged if k.lower() == key.lower()]: - del merged[existing_key] - merged[key] = value - return merged - @staticmethod def _get_header(headers: dict[str, str], name: str) -> str | None: """Look up a header value by name, treated case-insensitively. Returns `None` if the header is not set.""" @@ -350,9 +336,9 @@ def _prepare_request_call( without_authorization = { key: value for key, value in default_headers.items() if key.lower() != 'authorization' } - default_headers = self._merge_headers(without_authorization, {authorization_header: authorization}) + default_headers = merge_headers(without_authorization, {authorization_header: authorization}) - headers = self._merge_headers(default_headers, headers) + headers = merge_headers(default_headers, headers) # Dump JSON data to a string so it can be sent as a request body. if json is not None: @@ -382,7 +368,7 @@ def _prepare_request_call( and is_compressible_content_type(self._get_header(headers, 'content-type')) ): content = compressor.compress(content) - headers = self._merge_headers(headers, {'Content-Encoding': compressor.content_encoding}) + headers = 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. diff --git a/tests/unit/test_actor_server.py b/tests/unit/test_actor_server.py index dfa10e14..f7ba4379 100644 --- a/tests/unit/test_actor_server.py +++ b/tests/unit/test_actor_server.py @@ -125,6 +125,23 @@ def test_request_sends_parent_request_id_header(httpserver: HTTPServer, sync_cli assert captured[0].headers.getlist('X-Actor-Request-ID') == ['parent-id'] +async def test_request_sends_parent_request_id_header_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async variant of `test_request_sends_parent_request_id_header`.""" + expect_server_actor(httpserver) + httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True}) + + await ( + async_client.actor(_MOCKED_ACTOR_ID) + .server() + .request(headers={'x-actor-request-id': 'stale'}, parent_request_id='parent-id') + ) + + captured = server_requests(httpserver) + assert captured[0].headers.getlist('X-Actor-Request-ID') == ['parent-id'] + + def test_request_fetches_server_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: """Later calls on the same client reuse the server URL fetched by the first one.""" expect_server_actor(httpserver) diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 7d7d0161..c0aa1523 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -259,27 +259,6 @@ def test_set_default_authorization_sets_token_when_missing() -> None: assert client._headers['Authorization'] == 'Bearer test_token' -@pytest.mark.parametrize( - ('base', 'override', 'expected'), - [ - pytest.param(None, None, {}, id='both none'), - pytest.param({'Accept': 'a'}, None, {'Accept': 'a'}, id='override none'), - pytest.param(None, {'Accept': 'a'}, {'Accept': 'a'}, id='base none'), - pytest.param({'Accept': 'a'}, {'X-Custom': 'b'}, {'Accept': 'a', 'X-Custom': 'b'}, id='disjoint names'), - pytest.param({'Authorization': 'x'}, {'Authorization': 'y'}, {'Authorization': 'y'}, id='same casing'), - pytest.param({'Authorization': 'x'}, {'authorization': 'y'}, {'authorization': 'y'}, id='lowercase override'), - pytest.param({'authorization': 'x'}, {'AUTHORIZATION': 'y'}, {'AUTHORIZATION': 'y'}, id='uppercase override'), - ], -) -def test_merge_headers( - base: dict[str, str] | None, - override: dict[str, str] | None, - expected: dict[str, str], -) -> None: - """_merge_headers merges case-insensitively, override values win and keep their casing.""" - assert HttpClient._merge_headers(base, override) == expected - - def test_http_client_creates_sync_impit_client() -> None: """The synchronous adapter creates the Impit client, defers the one without redirects, and closes them.""" client = ImpitHttpClient(token='test_token_123') diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 1a2c395a..e20bd619 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -20,6 +20,7 @@ from apify_client._utils.errors import catch_not_found_or_throw from apify_client._utils.http import ( is_compressible_content_type, + merge_headers, response_to_dict, response_to_list, to_path_segment, @@ -571,3 +572,24 @@ def test_create_storage_signature() -> None: # Test with version parameter signature_v1 = create_storage_content_signature('resource_123', 'secret_key', version=1) assert signature != signature_v1 + + +@pytest.mark.parametrize( + ('base', 'override', 'expected'), + [ + pytest.param(None, None, {}, id='both none'), + pytest.param({'Accept': 'a'}, None, {'Accept': 'a'}, id='override none'), + pytest.param(None, {'Accept': 'a'}, {'Accept': 'a'}, id='base none'), + pytest.param({'Accept': 'a'}, {'X-Custom': 'b'}, {'Accept': 'a', 'X-Custom': 'b'}, id='disjoint names'), + pytest.param({'Authorization': 'x'}, {'Authorization': 'y'}, {'Authorization': 'y'}, id='same casing'), + pytest.param({'Authorization': 'x'}, {'authorization': 'y'}, {'authorization': 'y'}, id='lowercase override'), + pytest.param({'authorization': 'x'}, {'AUTHORIZATION': 'y'}, {'AUTHORIZATION': 'y'}, id='uppercase override'), + ], +) +def test_merge_headers( + base: dict[str, str] | None, + override: dict[str, str] | None, + expected: dict[str, str], +) -> None: + """merge_headers merges case-insensitively, override values win and keep their casing.""" + assert merge_headers(base, override) == expected