Skip to content

Add opt-in upstream API fallback with two independent toggles - #20

Merged
Pijukatel merged 10 commits into
v2-poc-requirementsfrom
claude/actor-runtime-api-fallback-rljqv9
Aug 25, 2026
Merged

Add opt-in upstream API fallback with two independent toggles#20
Pijukatel merged 10 commits into
v2-poc-requirementsfrom
claude/actor-runtime-api-fallback-rljqv9

Conversation

@Pijukatel

Copy link
Copy Markdown
Collaborator

What

When a call fails locally, the runtime can re-attempt it against the real Apify platform. Two independent toggles gate this, both off by default and both reset on restart:

  • fallbackUnimplementedEnabled — the runtime does not serve this path at all (local 404 or 501).
  • fallbackNotFoundEnabled — the runtime serves the route, but has no such record (record-not-found).

Neither implies the other; all four combinations are valid. Every other error type is never relayed.

Readable and writable at GET/POST /actor-runtime/api-fallback, and from a new console /settings page. Every console page's navigation shows both toggles' current values, so neither can be on without being visible. All HTTP methods are eligible, writes included — an eligible write that the platform accepts is a real, billed operation on the caller's own account.

A successful relay returns the platform's status and body unchanged, marked with x-actor-runtime-fallback and x-actor-runtime-fallback-trigger. Anything else — non-2xx, timeout, unreachable platform — reproduces exactly the response the caller would have gotten with both toggles off. Only the token the caller themselves presented is ever forwarded.

Also in this PR, at the reviewer's request during the same work: the console's builds, runs and logs listings now show the most recently started item first.

Why

requirements/api.md carried this as a "Not implemented" stub. Without it, a call touching anything the runtime does not have — an Actor that only exists on the platform, a real run's dataset id, an endpoint family this runtime never built — hard-fails, and the only recourse is to unset APIFY_CLIENT_BASE_URL, re-run against the real platform, and set it back.

Notes

  • Enabling either toggle sends the caller's own Apify token to the configured platform on every eligible request. That is the risk being opted into; it is why both default to off and reset on restart.
  • README.md still says the runtime needs no outbound network access after the first build/push, and does not mention this feature. Worth deciding separately whether it should.
  • The relayed response body is held in memory with no size cap, while inbound bodies are capped at 256 MB. Streaming is tracked as a follow-up.
  • NODE_ENV is unset in the image, so Express's default error handling runs in development mode. No reachable path renders a stack trace after this change, but setting it would be defence in depth.
  • Fallback only resolves an otherwise-failing request. A listing that already succeeds locally is never enriched with platform objects — see the follow-up issues for the open question there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG


Generated by Claude Code

Pijukatel and others added 10 commits August 20, 2026 13:27
When a call misses locally, the runtime can replay it against the real
Apify platform instead of failing. Two independent toggles gate it: one
for paths this runtime does not serve at all, one for records it has not
seen. Both default off and reset on restart. Readable and writable on the
runtime-internal API and from the console's new settings page.

Relays only a successful upstream reply, and only ever forwards the token
the caller themselves presented; any upstream failure returns the original
local error unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
Relay each Set-Cookie the platform set as its own header line, since the
HTTP client only guarantees separate entries for that one name and a
cookie value may itself contain a comma. Other repeated header names are
relayed comma-joined; the requirements now state that contract rather than
promising byte-level preservation of repeated lines.

Drop the duplicate upstream-base-URL helper in favour of the existing one,
which now trims trailing slashes, and stop the fallback service reaching
into the api layer for a two-line buffer coercion.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
An upstream that sent its status line and headers and then died left the
relay throwing after the local error was already decided: one seam answered
500 instead of the original error, the other let the rejection reach
Express's final handler, which rendered a stack trace as HTML. The relay now
absorbs any failure and reports that it did not relay, and both seams answer
through one helper.

Also match the path exclusion to how Express routes, so a differently-cased
or double-slashed spelling of the runtime's own namespace can no longer
forward a token upstream; stop the new tests reaching the real platform
during identity warm-up; make the relay timeout injectable so the hang test
no longer costs 30 seconds; and reject cross-site form submissions to the
console's writes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The mid-relay error path stripped every upstream header name from the
response, including ones it had never appended and ones whose removal has
side effects in Node (`date` clears `sendDate`, `connection` sets
`_removedConnection`). A fail-closed response therefore dropped `Date`,
`Connection` and `Keep-Alive` relative to the both-toggles-off response it
is documented to reproduce.

The block could not run for the failure it guarded against: the body is
read as the first statement of the `try`, before any response mutation, so
nothing is ever appended when that read fails. Removed it, and moved the
relay's `return true` and success log outside the `try` so a successful
relay can no longer be turned back into a fallthrough.

Fail-closed tests now compare the full response header set against each
request's own both-toggles-off baseline instead of status, body and markers
alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The dev-folder route's doc comment still called it the console's one
mutation, which the settings form falsified; the module header and
`requirements/console.md` were updated but this sibling sentence was not.

`attemptFallback`'s doc comment described its `try`/`catch` as covering
everything from the status check onward, which stopped being true when the
success log and `return true` moved out of it. The contract it states is
unchanged; only its stated basis was wrong, in the comment an editor would
consult before adding a statement after `res.send()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
Two sentences described internals rather than behaviour a caller can
observe: the relay bullet explained that the body is re-framed rather than
streamed, and the console section explained that both surfaces write
through one shared toggle state. Either would have to change if the
implementation were rewritten without any observable difference.

Both now state only the consequence a caller or console user can see, and
one paragraph is rewrapped to the surrounding width after the edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The Settings-page section explained that both surfaces write through one
underlying toggle state. An implementation keeping two stores in sync would
falsify that while behaving identically, so it described internals rather
than a contract. The guarantee it was there to support - a flip on either
surface is immediately visible on the other and via the API's own GET,
with no restart - is unchanged.

The previous commit's message described this edit, but it was dropped from
the working tree before that commit was made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The upstream fallback sections explained how the runtime works rather than
what it guarantees: which headers are hop-by-hop, why cookie values cannot
be comma-joined, what the spec table matches, how a partial body merges
into stored state, and which log level each outcome writes at. None of that
is something a caller can observe or rely on.

What a caller does rely on stays: the toggles and their defaults, the
endpoint contracts, which local outcome each toggle covers, all methods
being eligible and what that costs, the fail-closed guarantee, that only
the caller's own token is forwarded, and how a relayed response is marked.
The retry wording is restated as the guarantee behind it - an eligible
request reaches the platform at most once, so a relayed write is never
duplicated.

The console's cross-site rejection now states the limit of the guarantee
as well as the guarantee: a submission that does not identify itself as
cross-site is not rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The builds, runs and logs views rendered whatever order the registry
happened to return, which it makes no promise about. They now show the
most recently started record first, and the logs view interleaves builds
and runs by start time instead of listing all builds and then all runs.

Records sharing a start time are ordered by id so the sequence is stable
across renders. The API's own ordering is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The comment justified keeping this out of the API's sort helper by saying
that helper works on already-paginated DTOs. It does not - at all seven of
its call sites it sorts raw records, before pagination and before any DTO
conversion. What actually separates the two is the sort direction and the
fact that the console and the API are sibling consumers of the service
layer, neither importing the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants