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
21 changes: 21 additions & 0 deletions docs/02_concepts/14_server_actors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -78,6 +80,25 @@ 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 `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).

<Tabs>
<TabItem value="AsyncExample" label="Async client" default>
<CodeBlock className="language-python">
{ServerParentRequestAsyncExample}
</CodeBlock>
</TabItem>
<TabItem value="SyncExample" label="Sync client">
<CodeBlock className="language-python">
{ServerParentRequestSyncExample}
</CodeBlock>
</TabItem>
</Tabs>

## 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
20 changes: 20 additions & 0 deletions docs/02_concepts/code/14_server_parent_request_async.py
Original file line number Diff line number Diff line change
@@ -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()
20 changes: 20 additions & 0 deletions docs/02_concepts/code/14_server_parent_request_sync.py
Original file line number Diff line number Diff line change
@@ -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()
14 changes: 13 additions & 1 deletion src/apify_client/_resource_clients/actor_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down 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,9 @@ 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. 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`.
Expand All @@ -208,6 +212,8 @@ def request(
"""
server_url = self.get_url()
server_path = path.partition('#')[0].lstrip('/')
if parent_request_id is not None:
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,
Expand Down Expand Up @@ -355,6 +361,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 +393,9 @@ 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. 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`.
Expand All @@ -404,6 +414,8 @@ async def request(
"""
server_url = await self.get_url()
server_path = path.partition('#')[0].lstrip('/')
if parent_request_id is not None:
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,
Expand Down
14 changes: 14 additions & 0 deletions src/apify_client/_utils/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
24 changes: 5 additions & 19 deletions src/apify_client/http_clients/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_actor_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,36 @@ 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async counterpart of this test?

"""`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']


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)
Expand Down
21 changes: 0 additions & 21 deletions tests/unit/test_http_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading