Skip to content

Commit 63bd448

Browse files
authored
feat: add Server Actor support (formerly Actor Standby) (#1153)
Adds `ActorClient.server()` and `ActorClientAsync.server()`, which return an `ActorServerClient` / `ActorServerClientAsync` for Server Actors (formerly Actor Standby): ```python actor_server_client = client.actor('username/actor-name').server() response = actor_server_client.request('/search', params={'q': 'apify'}) url = actor_server_client.get_url() spec = actor_server_client.get_openapi_spec() ``` - `request()` sends a request to the Actor's server. The host always comes from the Actor's `standbyUrl`. The token goes in the `X-Apify-Authorization` header, which the platform strips, so headers the caller passes, `Authorization` included, reach the Actor as they are. It doesn't follow redirects and returns a redirect response as it is, so the token can't reach a host the Actor's code picked. - `request()` returns the server's response whatever its status. Only 429 and 503 are retried, since other statuses may come from the Actor's code. Network errors and timeouts aren't retried, since the Actor may have already handled the request. It raises only when it gets no response: the Actor doesn't exist or isn't a Server Actor, or the request fails or times out. - `request()` sends bodies uncompressed unless `compression` is set, returns the body unread with `stream=True`, and defaults to the `long` timeout to cover a cold start. - `get_url()` returns the server URL. It's fetched once per `ActorClient`, by `get_url()` or `get_openapi_spec()`. - `get_openapi_spec()` returns the web server schema from the build that serves the server (`actorStandby.build`, or `latest`), or `None` if there's none. The schema field comes from apify/apify-docs#3115. - `get_url()` and `get_openapi_spec()` take a `timeout`. `ActorClient.standby()` is a deprecated alias of `server()` and will be removed in v4. Other changes: - `HttpClient.call` and `HttpClientAsync.call` take new arguments: `compression`, `authorization_header`, `raise_for_status`, `retry_status_codes`, `retry_transport_errors`, and `follow_redirects`. A custom HTTP client that overrides `call` must accept and honor them, and one that implements `send_request` must honor `follow_redirects`. - Request params are appended with `&` to a URL that already has a query. - The docstring sync script no longer turns `ApifyClientError` into a nonexistent `ApifyClientAsyncError`. - A Server Actors concepts page, unit tests, and an integration test against a temporary echo Server Actor. Closes: #973 *✍️ Drafted by Claude Code*
1 parent 5f1b936 commit 63bd448

26 files changed

Lines changed: 2064 additions & 42 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r
137137
- **Convenience methods** — `call()`, `wait_for_finish()`, nested resource access, and other shortcuts that hide platform quirks ([Convenience methods](https://docs.apify.com/api/client/python/docs/concepts/convenience-methods)).
138138
- **Pluggable HTTP layer** — use the default [Impit](https://github.com/apify/impit)-based client, opt in to the built-in [HTTPX2](https://github.com/pydantic/httpx2) client, or plug in any custom implementation ([HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)).
139139
- **Structured errors** — every API error surfaces as an [`ApifyApiError`](https://docs.apify.com/api/client/python/reference/class/ApifyApiError) with HTTP-specific subclasses for precise handling ([Error handling](https://docs.apify.com/api/client/python/docs/concepts/error-handling)).
140+
- **Server Actors** — send HTTP requests to an Actor's server interface (formerly Actor Standby), and get the server's URL and OpenAPI specification ([Server Actors](https://docs.apify.com/api/client/python/docs/concepts/server-actors)).
140141
- **Debug logging** — opt-in structured logging on the `apify_client` logger captures request URLs, status codes, retry attempts, and more ([Logging](https://docs.apify.com/api/client/python/docs/concepts/logging)).
141142
142143
## Usage examples

‎docs/02_concepts/10_custom_http_clients.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ The base classes and the response protocol are available from the `apify_client.
100100

101101
The public `call` method provides the shared request pipeline. A concrete transport implements these hooks:
102102

103-
- `send_request(...)` sends one prepared request and returns an `HttpResponse`, error statuses included. It receives the URL with the query parameters already encoded into it, the headers with the client's default headers already merged in, the body, and the timeout for this attempt in seconds. The body is either `bytes`, already serialized and compressed, or an iterator of `bytes` chunks for a streamed body, which is an async iterator in the asynchronous client. The inherited `call` needs it, so every transport adapter has to implement it. Let the HTTP library's exceptions propagate unwrapped, and leave status handling and <ApiLink to="class/ApifyApiError">`ApifyApiError`</ApiLink> to `call`.
103+
- `send_request(...)` sends one prepared request and returns an `HttpResponse`, error statuses included. It receives the URL with the query parameters already encoded into it, the headers with the client's default headers already merged in, the body, and the timeout for this attempt in seconds. The body is either `bytes`, already serialized and compressed, or an iterator of `bytes` chunks for a streamed body, which is an async iterator in the asynchronous client. The inherited `call` needs it, so every transport adapter has to implement it. Let the HTTP library's exceptions propagate unwrapped, and leave status handling and <ApiLink to="class/ApifyApiError">`ApifyApiError`</ApiLink> to `call`. It also takes `follow_redirects`, which `call` passes as `False` for a request to an Actor's server: return a redirect response as it is then, since the request carries the API token.
104104
- `is_retryable_transport_error(exc)` classifies transport failures for the shared retry loop. The default classifies nothing as retryable, so a transport that doesn't override it gives up on the first connection failure.
105105
- `is_timeout_error(exc)` identifies transport-specific timeout exceptions for higher-level client features. The default recognizes Python's `TimeoutError`. Timeout classification is independent of retryability, so a timeout the retry loop should retry has to be listed in `is_retryable_transport_error` too.
106106
- `close()` or `aclose()` closes resources owned by the transport. The default does nothing, which is correct for a transport that owns no pool or session.
@@ -151,7 +151,7 @@ Use the <ApiLink to="class/ApifyClient#with_custom_http_client">`ApifyClient.wit
151151
After that, all API calls made through the client will go through your custom HTTP client.
152152

153153
:::warning
154-
If you override `call` itself, your implementation becomes responsible for request preparation, retries, timeouts, API error conversion, logging, and statistics. Implementing the transport hooks and inheriting `call` keeps the shared behavior.
154+
If you override `call` itself, your implementation becomes responsible for request preparation, retries, timeouts, API error conversion, logging, and statistics. It also has to honor the `compression`, `authorization_header`, `raise_for_status`, `retry_status_codes`, `retry_transport_errors`, and `follow_redirects` arguments. <ApiLink to="class/ActorServerClient#request">`ActorServerClient.request`</ApiLink> relies on them to keep the API token away from the Actor's code, to return the server's error responses, and to avoid sending a request to the Actor twice. Implementing the transport hooks and inheriting `call` keeps the shared behavior.
155155
:::
156156

157157
If you override `call` and prepare requests with the inherited helpers, a streamed body arrives as a <ApiLink to="class/StreamedRequestBody">`StreamedRequestBody`</ApiLink>. Send its `iter_bytes()` or `aiter_bytes()`, and send the body a second time only when `rewindable` is true, after calling `rewind()`. For what the client streams and how, see [Streaming uploads](./09_streaming.mdx#streaming-uploads).
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
id: server-actors
3+
title: Server Actors
4+
description: Send HTTP requests to Server Actors (formerly Actor Standby), and get their URL and OpenAPI specification.
5+
keywords: [server actor, server interface, standby, actor standby, standby_url, ActorServerClient]
6+
---
7+
8+
import Tabs from '@theme/Tabs';
9+
import TabItem from '@theme/TabItem';
10+
import CodeBlock from '@theme/CodeBlock';
11+
import ApiLink from '@theme/ApiLink';
12+
13+
import ServerAsyncExample from '!!raw-loader!./code/14_server_async.py';
14+
import ServerSyncExample from '!!raw-loader!./code/14_server_sync.py';
15+
import ServerUrlSpecAsyncExample from '!!raw-loader!./code/14_server_url_spec_async.py';
16+
import ServerUrlSpecSyncExample from '!!raw-loader!./code/14_server_url_spec_sync.py';
17+
18+
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.
19+
20+
:::note Formerly Actor Standby
21+
22+
Actor Standby is the former name of Server Actors, and it'll be deprecated. The platform documentation and the API still use it, for example in the Actor's `standby_url` and `actor_standby` fields, and in the `actor_standby_*` arguments of `ActorClient.update`. In the client, `ActorClient.standby` is a deprecated alias of `ActorClient.server` that will be removed in v4.
23+
24+
:::
25+
26+
To work with a Server Actor, get its server client with <ApiLink to="class/ActorClient#server">`ActorClient.server`</ApiLink>. To send a request, use <ApiLink to="class/ActorServerClient#request">`ActorServerClient.request`</ApiLink>. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body:
27+
28+
<Tabs>
29+
<TabItem value="AsyncExample" label="Async client" default>
30+
<CodeBlock className="language-python">
31+
{ServerAsyncExample}
32+
</CodeBlock>
33+
</TabItem>
34+
<TabItem value="SyncExample" label="Sync client">
35+
<CodeBlock className="language-python">
36+
{ServerSyncExample}
37+
</CodeBlock>
38+
</TabItem>
39+
</Tabs>
40+
41+
The method returns the server's response. To read a large body in chunks, pass `stream=True` and close the response when you're done with it. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store), its README, or its OpenAPI specification. For details, see [Server URL and OpenAPI specification](#server-url-and-openapi-specification).
42+
43+
## How the request is sent
44+
45+
Note that:
46+
47+
- 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 through the same `ActorClient`. If the Actor isn't a Server Actor, the server client raises an `ApifyClientError`.
48+
- The path is always resolved against that URL, so a path can't send the request to another host.
49+
- By default, 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.
50+
51+
## Server URL and OpenAPI specification
52+
53+
The Actor server client also gives you the server's URL and its OpenAPI specification:
54+
55+
- <ApiLink to="class/ActorServerClient#get_url">`ActorServerClient.get_url`</ApiLink> returns the server's URL. It fetches and reuses the URL the same way as `request`.
56+
- <ApiLink to="class/ActorServerClient#get_openapi_spec">`ActorServerClient.get_openapi_spec`</ApiLink> returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build that serves the server interface. That's the build set in the Actor's `actor_standby.build` field, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`.
57+
58+
The following example prints the server's URL and the paths of its endpoints:
59+
60+
<Tabs>
61+
<TabItem value="AsyncExample" label="Async client" default>
62+
<CodeBlock className="language-python">
63+
{ServerUrlSpecAsyncExample}
64+
</CodeBlock>
65+
</TabItem>
66+
<TabItem value="SyncExample" label="Sync client">
67+
<CodeBlock className="language-python">
68+
{ServerUrlSpecSyncExample}
69+
</CodeBlock>
70+
</TabItem>
71+
</Tabs>
72+
73+
## Authentication
74+
75+
The request carries the client's API token in the `X-Apify-Authorization` header. The platform removes the token before it forwards the request, so the token doesn't reach the Actor's code. The method doesn't follow redirects and returns a redirect response as it is, since following it would send the token to a host the Actor's code picked.
76+
77+
The headers you pass in `headers` reach the Actor's server as they are. If the Actor reads the `Authorization` header for its own authentication, pass that header in `headers`.
78+
79+
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.
80+
81+
## Timeouts, retries, and errors
82+
83+
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).
84+
85+
The method returns the server's response whatever its status, so check `status_code` before you use the body. Only rate-limit (429) and Service Unavailable (503) responses are retried, with the client's retry settings. Once the retries run out, the method returns the last response. The platform sends these when it can't pass the request to a server run, but the Actor's code can return them too, and then a retry sends the request to the Actor again. Network errors and timeouts aren't retried, since the Actor may have already handled the request. For details, see [Retries](./05_retries.mdx).
86+
87+
The method raises only when it doesn't get a response from the server: an `ApifyApiError` when the API can't return the Actor, for example because it doesn't exist, an `ApifyClientError` when it isn't a Server Actor, or the HTTP library's error when the request fails or times out. For details, see [Error handling](./04_error_handling.mdx).

‎docs/02_concepts/code/10_plugging_in_async.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ async def send_request(
2121
content: bytes | AsyncIterator[bytes] | None,
2222
timeout: float | None,
2323
stream: bool,
24+
follow_redirects: bool = True,
2425
) -> HttpResponse:
2526
"""Send one request through the custom transport."""
2627
raise NotImplementedError

‎docs/02_concepts/code/10_plugging_in_sync.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def send_request(
2121
content: bytes | Iterator[bytes] | None,
2222
timeout: float | None,
2323
stream: bool,
24+
follow_redirects: bool = True,
2425
) -> HttpResponse:
2526
"""Send one request through the custom transport."""
2627
raise NotImplementedError
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from http import HTTPStatus
2+
3+
from apify_client import ApifyClientAsync
4+
5+
TOKEN = 'MY-APIFY-TOKEN'
6+
7+
8+
async def main() -> None:
9+
apify_client = ApifyClientAsync(TOKEN)
10+
actor_server_client = apify_client.actor('username/actor-name').server()
11+
12+
# Send a GET request to the /search endpoint of the Actor's server. The method
13+
# returns error responses too, so check the status first.
14+
response = await actor_server_client.request('/search', params={'q': 'apify'})
15+
if response.status_code != HTTPStatus.OK:
16+
raise RuntimeError(f'Search failed with status {response.status_code}')
17+
print(response.json())
18+
19+
# Send a POST request with a JSON body.
20+
response = await actor_server_client.request(
21+
'/items',
22+
method='POST',
23+
json={'url': 'https://apify.com'},
24+
)
25+
print(response.status_code)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from http import HTTPStatus
2+
3+
from apify_client import ApifyClient
4+
5+
TOKEN = 'MY-APIFY-TOKEN'
6+
7+
8+
def main() -> None:
9+
apify_client = ApifyClient(TOKEN)
10+
actor_server_client = apify_client.actor('username/actor-name').server()
11+
12+
# Send a GET request to the /search endpoint of the Actor's server. The method
13+
# returns error responses too, so check the status first.
14+
response = actor_server_client.request('/search', params={'q': 'apify'})
15+
if response.status_code != HTTPStatus.OK:
16+
raise RuntimeError(f'Search failed with status {response.status_code}')
17+
print(response.json())
18+
19+
# Send a POST request with a JSON body.
20+
response = actor_server_client.request(
21+
'/items',
22+
method='POST',
23+
json={'url': 'https://apify.com'},
24+
)
25+
print(response.status_code)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from apify_client import ApifyClientAsync
2+
3+
TOKEN = 'MY-APIFY-TOKEN'
4+
5+
6+
async def main() -> None:
7+
apify_client = ApifyClientAsync(TOKEN)
8+
actor_server_client = apify_client.actor('username/actor-name').server()
9+
10+
# Get the URL of the Actor's server.
11+
server_url = await actor_server_client.get_url()
12+
print(server_url)
13+
14+
# Get the OpenAPI specification and list the server's endpoints.
15+
spec = await actor_server_client.get_openapi_spec()
16+
if spec is not None:
17+
print(list(spec['paths']))
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from apify_client import ApifyClient
2+
3+
TOKEN = 'MY-APIFY-TOKEN'
4+
5+
6+
def main() -> None:
7+
apify_client = ApifyClient(TOKEN)
8+
actor_server_client = apify_client.actor('username/actor-name').server()
9+
10+
# Get the URL of the Actor's server.
11+
server_url = actor_server_client.get_url()
12+
print(server_url)
13+
14+
# Get the OpenAPI specification and list the server's endpoints.
15+
spec = actor_server_client.get_openapi_spec()
16+
if spec is not None:
17+
print(list(spec['paths']))

‎docs/03_guides/code/05_custom_http_client_async.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,15 @@ async def send_request(
100100
content: bytes | AsyncIterator[bytes] | None,
101101
timeout: float | None,
102102
stream: bool,
103+
follow_redirects: bool = True,
103104
) -> HttpResponse:
104105
response = await self._session.request(
105106
method=method,
106107
url=url,
107108
headers=headers,
108109
data=content,
109110
timeout=aiohttp.ClientTimeout(total=timeout),
111+
allow_redirects=follow_redirects,
110112
)
111113
adapted_response = AiohttpResponse(response)
112114

0 commit comments

Comments
 (0)