Skip to content
Draft
52 changes: 52 additions & 0 deletions docs/02_concepts/14_standby_actors.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
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 <ApiLink to="class/ActorClient#standby_request">`ActorClient.standby_request`</ApiLink>. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body:

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

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 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

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).
20 changes: 20 additions & 0 deletions docs/02_concepts/code/14_standby_async.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions docs/02_concepts/code/14_standby_sync.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion scripts/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
144 changes: 143 additions & 1 deletion src/apify_client/_resource_clients/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
start_waiting_for_resources,
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
Expand All @@ -55,7 +57,9 @@
WebhookCollectionClient,
WebhookCollectionClientAsync,
)
from apify_client.types import Timeout, WebhooksList
from apify_client.http_clients import HttpResponse
from apify_client.http_compressors import HttpCompressor
from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, Timeout, WebhooksList

_PricingInfo = (
PayPerEventActorPricingInfo
Expand Down Expand Up @@ -86,6 +90,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.
Expand Down Expand Up @@ -625,6 +630,74 @@ 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,
compression: HttpCompressionAlgorithm | HttpCompressor | 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 unless `compression` is set, since the Actor's
server may not decode a compressed body.

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, 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`.
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:
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.
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')
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

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,
compression=resolve_compressor(compression) if compression is not None else False,
)


@docs_group('Resource clients')
class ActorClientAsync(ResourceClientAsync):
Expand All @@ -646,6 +719,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.
Expand Down Expand Up @@ -1185,3 +1259,71 @@ 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,
compression: HttpCompressionAlgorithm | HttpCompressor | 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 unless `compression` is set, since the Actor's
server may not decode a compressed body.

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, 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`.
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:
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.
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')
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

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,
compression=resolve_compressor(compression) if compression is not None else False,
)
26 changes: 19 additions & 7 deletions src/apify_client/http_clients/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,18 +312,19 @@ 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.

Merges the client's default headers (including authorization) with per-request headers and serializes a
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -545,6 +551,7 @@ def call(
params=params,
data=data,
json=json,
compression=compression,
)

return self._retry_with_exp_backoff(
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -755,20 +765,22 @@ 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(
headers=headers,
params=params,
data=data,
json=json,
compression=compression,
)

return await self._retry_with_exp_backoff(
Expand Down
Loading
Loading