Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/02_concepts/14_server_actors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions src/apify_client/_resource_clients/actor_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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`.
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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`.
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/test_actor_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading