Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
- browser-view-py
- all-modes
- pay-per-event
- standby
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,31 @@ options are off by default and reset on every restart.
**This can write to your real Apify account.** Every HTTP method is relayed, so only turn it on with a
token whose account you are willing to change.

## Actor Standby

An Actor with Standby enabled - `"usesStandbyMode": true` in `.actor/actor.json`, or `actorStandby` set
through the API - is served over HTTP at its `standbyUrl`, on the API port:

```bash
cd sample_actor_standby_ts # or sample_actor_standby_py
apify push
curl "http://<username>--my-standby-actor-ts.localhost:3333/hello?name=Ada&token=<token>"
```

The `standbyUrl` has the platform's shape, one `*.localhost` hostname per Actor, so a web UI served by the
Actor works as on `*.apify.actor`. Clients that do not resolve `*.localhost` use
`http://localhost:3333/actor-runtime/standby/<username>--<actor-name>`, and other Actors
`http://apify-api:3333/actor-runtime/standby/<username>--<actor-name>`.

The two samples are the same server in TypeScript and Python - JSON endpoints, a request body echo, a
Server-Sent Events stream, a websocket and stats kept across runs; each README lists the calls.
`sample_actor_standby_web` serves a web page with root-relative links, and in an ordinary run calls a
standby Actor from inside its container.

Requests are handed to standby runs the runtime starts, scales by `desiredRequestsPerActorRun` /
`maxRequestsPerActorRun` and winds down after `idleTimeoutSecs` without a request, as on the platform.
Single-tenant only - see `requirements/actor-driver.md`'s "Actor Standby".

## Apify Proxy

Set `APIFY_PROXY_PASSWORD` in the runtime container's environment
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default tseslint.config(
'dist/**',
'node_modules/**',
'sample_actor_ts/**',
'sample_actor_standby_ts/**',
'sample_actor_py/**',
'sample_actor_playwright/**',
'data/**',
Expand Down
9 changes: 7 additions & 2 deletions requirements/actor-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ start`, ...) is refused by name, naming both the `CMD` fix and how to clear debu
leaves it to be discovered on the bill.
- The pricing can also be set from the console (`console.md`).

# Actor Standby

- Implemented as on the platform (settings, `usesStandbyMode`, scaling, readiness, idle shutdown, env vars).
- Differences: single-tenant, owner-only; a new build of the standby tag replaces older standby runs; a
runtime restart aborts standby runs.

# Users

- Users are created adhoc by the runtime for each new token used in the API call (`cli.md`'s User bootstrap).
Expand All @@ -253,8 +259,7 @@ start`, ...) is refused by name, naming both the `CMD` fix and how to clear debu
default storage ids, or any other contract var the runtime itself sets.
- `APIFY_IS_AT_HOME=1` (mirrors the real platform; an SDK/client instantiated
in the container reports `isAtHome`/`is_at_home = true`).
- `APIFY_META_ORIGIN` — `API` for ordinary runs (every local run arrives via
the API, apify-cli included)
- `APIFY_META_ORIGIN` — `STANDBY` for a standby run, `API` for every other run
- `APIFY_API_BASE_URL` — the runtime's own API, reachable by name from any
Actor container on the shared Docker network (see "Networking" above).
- `APIFY_TOKEN` — the run owner's token
Expand Down
7 changes: 7 additions & 0 deletions requirements/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@
miss; only a request naming an Actor unknown here is eligible for the upstream fallback (below), and
then as the caller's original request, which the platform resolves end to end.

# Actor Standby

- Implemented as on the platform. Differences: only the owner is served, and `standbyUrl` is
`http://<username>--<actor-name>.localhost:3333`, or `http://localhost:3333/actor-runtime/standby/<username>--<actor-name>`
for clients without `*.localhost` (`http://apify-api:3333/...` from Actors). Standby errors are never
relayed by the upstream fallback.

# Actor runtime API

- `/actor-runtime/*` is the API controlling functions specific to the local Actor runtime
Expand Down
5 changes: 5 additions & 0 deletions requirements/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,8 @@
the other, and via the API's own `GET`, with no restart needed either way.
- The console has no login, so anyone who can reach it can flip either toggle for every caller of the
API.

## Actor Standby (Actor detail view)

- Shows the standby settings, live standby runs, and the standby URL with the owner's token, as a link and
a copy button (the token masked on screen). Read-only. The run detail view shows each run's origin.
3 changes: 2 additions & 1 deletion requirements/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
with a clear status message, while every other endpoint (storages, actor/build/run records,
console) still works.
- Both ports are fixed and not configurable.
- Port 3333 also serves the per-run events websocket (`api.md`); no additional port is published for it.
- Port 3333 also serves the per-run events websocket and standby Actors (`api.md`); no additional port is
published for either.
- **Debug mode is the one exception to "no other Actor container port is ever published"**
(`actor-driver.md`'s "Debug mode" section): when debug mode is on for an Actor, that Actor's runs get a
port published on the host, bound to `127.0.0.1` (`5678` Python / `9229` Node by default, per-Actor
Expand Down
6 changes: 6 additions & 0 deletions requirements/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- **A second narrow exception of the same kind**: the browser-view e2e test may open the console's viewer
page and its websocket directly, to emulate a developer's browser opening the view. Everything else in it
goes through `apify` commands.
- **A third narrow exception**: the Standby e2e test calls standby URLs directly (HTTP, websocket).
- For asserting the test results, the tests must inspect the return values of the Apify cli commands.
- The e2e suite requires a reachable Docker daemon (it builds and runs real Actor containers) and
detects its absence, failing in such case.
Expand All @@ -43,3 +44,8 @@ Test case must verify full Actor development flow:
- For each Playwright sample Actor (`sample_actor_playwright`, `sample_actor_playwright_py`): push, turn browser view on, start a run
- Assert the run log names the viewer URL, the view is reachable while the run is going, the run finishes `SUCCEEDED` with an input-dependent `itemCount`, and the view is gone once the run has ended
- With the toggle cleared, a plain `apify call` of the same Actor runs with no browser-view line in its log

## Actor Standby

- Each standby sample, pushed: its requests share one `STANDBY` run, which ends `SUCCEEDED` once idle, and
the next request starts a new one; `sample_actor_standby_web` is also reachable from another Actor's run.
6 changes: 3 additions & 3 deletions requirements/unsupported.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ real account, not in the runtime.

## Running Actors

- Actor server (Actor Standby)
- Multi-tenant Actor Standby, Standby for tasks, and Standby's Console-auth and tokenless options
- Run web server and live view (`containerUrl`)
- Metamorph
- Resurrecting finished runs
- Restart on error
- Infinite runs (timeout `0`)
- Infinite runs (timeout `0`), other than standby runs
- Synchronous runs returning output (`run-sync`)
- Actor-set run status messages
- Result cap (`maxItems`)
Expand Down Expand Up @@ -91,7 +91,7 @@ real account, not in the runtime.
## Inside the Actor container

- Run metadata env vars (input key, build, task, user, timestamps)
- Web server, Standby and proxy env vars
- Web server URL (`ACTOR_WEB_SERVER_URL`) and proxy env vars
- Input secrets private key
- Log rate limiting, line truncation and size cap
- Secret redaction in logs
Expand Down
11 changes: 11 additions & 0 deletions sample_actor_standby_py/.actor/actor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://apify.com/schemas/v1/actor.ide.json",
"actorSpecification": 1,
"name": "my-standby-actor-py",
"title": "Python Standby sample actor for actor-runtime",
"description": "An Actor server: JSON endpoints, a request body echo, a Server-Sent Events stream and a websocket, all served in Standby mode.",
"version": "0.0",
"buildTag": "latest",
"usesStandbyMode": true,
"dockerfile": "../Dockerfile"
}
4 changes: 4 additions & 0 deletions sample_actor_standby_py/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv
__pycache__
*.pyc
.git
8 changes: 8 additions & 0 deletions sample_actor_standby_py/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM apify/actor-python:3.13

COPY --chown=myuser:myuser requirements.txt ./
RUN pip install -r requirements.txt

COPY --chown=myuser:myuser . ./

CMD ["python3", "-m", "src"]
26 changes: 26 additions & 0 deletions sample_actor_standby_py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Python Standby sample Actor

An Actor server for trying Actor Standby against the local runtime; `sample_actor_standby_ts` is the same
server in TypeScript. `.actor/actor.json` sets `usesStandbyMode`, so `apify push` enables Standby.

```bash
apify push
URL=http://localhost:3333/actor-runtime/standby/<username>--my-standby-actor-py
TOKEN=<your token>

curl "$URL/?token=$TOKEN" # what the server offers
curl "$URL/hello?name=Ada&token=$TOKEN" # a greeting; one dataset item per call
curl -X POST -H 'content-type: application/json' -d '{"a":1}' "$URL/echo?token=$TOKEN"
curl "$URL/stats?token=$TOKEN" # this run's and every run's request count
curl -N "$URL/stream?count=5&token=$TOKEN" # Server-Sent Events, one every 0.5 s
npx wscat -c "${URL/http/ws}/ws?token=$TOKEN" # a websocket echo
```

- The first request starts a standby run and waits until the server answers the readiness probe; later
requests reuse that run. Each `/hello` pushes one item to the run's default dataset.
- `/stats` keeps a running total across runs in the named key-value store `standby-sample-py-stats`, saved on
every `persistState` event and when the run is wound down.
- After `idleTimeoutSecs` without a request (300 by default; shorten it with
`apify api PUT v2/actors/<actorId> --body '{"actorStandby":{"idleTimeoutSecs":10}}'`) the run gets the
`aborting` event, stops serving and ends `SUCCEEDED`. The next request starts a fresh run.
- `apify call` starts an ordinary run instead, which says where to send requests and exits.
2 changes: 2 additions & 0 deletions sample_actor_standby_py/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
apify
aiohttp
Empty file.
6 changes: 6 additions & 0 deletions sample_actor_standby_py/src/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import asyncio

from .main import main

if __name__ == '__main__':
asyncio.run(main())
153 changes: 153 additions & 0 deletions sample_actor_standby_py/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Python Actor Standby sample for actor-runtime.

An HTTP server that the platform (or the runtime) starts on demand and sends requests to. Mirrors
`sample_actor_standby_ts` endpoint for endpoint:

GET / what this server offers, and which run is answering
GET /hello?name=Ada a greeting; pushes one item to the run's default dataset
POST /echo the request's JSON (or text) body, echoed back
GET /stats requests served by this run, and by every run so far (a named key-value store)
GET /stream?count=5 a Server-Sent Events stream, one event every half second
WS /ws a websocket that echoes every message
"""

from __future__ import annotations

import asyncio
import json
from datetime import datetime, timezone
from typing import Any

from aiohttp import WSMsgType, web
from apify import Actor, Event

# Sent by the platform (and the runtime) until the server answers; any response means "ready".
READINESS_PROBE_HEADER = 'x-apify-container-server-readiness-probe'
STATS_STORE = 'standby-sample-py-stats'
STATS_KEY = 'STATS'


def now() -> str:
return datetime.now(timezone.utc).isoformat()


class StandbyServer:
def __init__(self, run_id: str | None, standby_url: str, before: dict[str, int]) -> None:
self.run_id = run_id
self.standby_url = standby_url
# The totals of every earlier run; this run's own count is added on top whenever they are saved.
self.before = before
self.served = 0

def all_runs(self) -> dict[str, int]:
return {'served': self.before['served'] + self.served, 'runs': self.before['runs'] + 1}

@web.middleware
async def count_requests(self, request: web.Request, handler: Any) -> web.StreamResponse:
if request.headers.get(READINESS_PROBE_HEADER):
return web.Response(text='ok\n')
if request.path != '/':
self.served += 1
Actor.log.info(f'{request.method} {request.path} (request #{self.served} of this run)')
return await handler(request)

async def index(self, _request: web.Request) -> web.Response:
return web.json_response(
{
'actor': 'Python Standby sample',
'runId': self.run_id,
'standbyUrl': self.standby_url,
'endpoints': ['GET /hello?name=', 'POST /echo', 'GET /stats', 'GET /stream?count=', 'WS /ws'],
}
)

async def hello(self, request: web.Request) -> web.Response:
name = request.query.get('name', 'world')
await Actor.push_data({'name': name, 'servedAt': now()})
return web.json_response({'greeting': f'Hello, {name}!', 'runId': self.run_id, 'served': self.served})

async def echo(self, request: web.Request) -> web.Response:
body: Any = await request.text()
if request.content_type == 'application/json':
try:
body = json.loads(body)
except json.JSONDecodeError:
return web.json_response({'error': 'The body is not valid JSON.'}, status=400)
return web.json_response({'runId': self.run_id, 'query': dict(request.query), 'body': body})

async def stats(self, _request: web.Request) -> web.Response:
return web.json_response(
{'runId': self.run_id, 'thisRun': {'served': self.served}, 'allRuns': self.all_runs()}
)

async def stream(self, request: web.Request) -> web.StreamResponse:
try:
count = min(int(request.query.get('count', '5')), 50)
except ValueError:
count = 5
response = web.StreamResponse(headers={'content-type': 'text/event-stream', 'cache-control': 'no-cache'})
await response.prepare(request)
for n in range(1, count + 1):
data = json.dumps({'n': n, 'of': count, 'at': now()})
await response.write(f'event: tick\ndata: {data}\n\n'.encode())
await asyncio.sleep(0.5)
await response.write(b'event: done\ndata: {}\n\n')
await response.write_eof()
return response

async def websocket(self, request: web.Request) -> web.WebSocketResponse:
socket = web.WebSocketResponse()
await socket.prepare(request)
await socket.send_json({'hello': 'Send me anything and I will echo it.', 'runId': self.run_id})
async for message in socket:
if message.type == WSMsgType.TEXT:
await socket.send_json({'echo': message.data})
return socket

def app(self) -> web.Application:
app = web.Application(middlewares=[self.count_requests])
app.router.add_get('/', self.index)
app.router.add_get('/hello', self.hello)
app.router.add_post('/echo', self.echo)
app.router.add_get('/stats', self.stats)
app.router.add_get('/stream', self.stream)
app.router.add_get('/ws', self.websocket)
return app


async def main() -> None:
async with Actor:
config = Actor.configuration
if config.meta_origin != 'STANDBY':
# `apify call` lands here: an Actor server has nothing to do without requests.
Actor.log.info(f'This Actor is an HTTP server. Send requests to {config.standby_url}/ instead.')
return

stats_store = await Actor.open_key_value_store(name=STATS_STORE)
before = await stats_store.get_value(STATS_KEY) or {'served': 0, 'runs': 0}
server = StandbyServer(config.actor_run_id, config.standby_url, before)

async def save_stats(*_args: Any) -> None:
await stats_store.set_value(STATS_KEY, server.all_runs())

# An idle standby run is wound down with an `aborting` event: stop serving, save, and exit.
stopping = asyncio.Event()

async def on_aborting(*_args: Any) -> None:
Actor.log.info(f'Shutting down after serving {server.served} requests.')
stopping.set()

# Saved whenever the platform asks (periodically, and before a migration), so no count is lost.
Actor.on(Event.PERSIST_STATE, save_stats)
Actor.on(Event.ABORTING, on_aborting)

# The Python SDK reads the server port from ACTOR_WEB_SERVER_PORT (4321 unless set otherwise).
port = config.web_server_port
runner = web.AppRunner(server.app())
await runner.setup()
await web.TCPSite(runner, '0.0.0.0', port).start()
Actor.log.info(f'Standby server listening on port {port}')

await stopping.wait()
await runner.cleanup()
await save_stats()
11 changes: 11 additions & 0 deletions sample_actor_standby_ts/.actor/actor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://apify.com/schemas/v1/actor.ide.json",
"actorSpecification": 1,
"name": "my-standby-actor-ts",
"title": "TypeScript Standby sample actor for actor-runtime",
"description": "An Actor server: JSON endpoints, a request body echo, a Server-Sent Events stream and a websocket, all served in Standby mode.",
"version": "0.0",
"buildTag": "latest",
"usesStandbyMode": true,
"dockerfile": "../Dockerfile"
}
18 changes: 18 additions & 0 deletions sample_actor_standby_ts/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# configurations
.idea
.vscode
.zed

# crawlee and apify storage folders
apify_storage
crawlee_storage
storage

# installed files
node_modules

# git folder
.git

# dist folder
dist
Loading
Loading