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)