Request Parking (atenet router)
July 29, 2026 · View on GitHub
Summary
Request parking lets the atenet router hold ("park") an inbound request
whose target actor cannot be served yet because of transient worker-pool
saturation, retrying the resume until the actor becomes routable or a bounded
wait elapses — instead of immediately returning 503 to the client.
Motivation
When a request arrives for a suspended actor, the router resumes it before routing:
Envoy --(ext_proc RequestHeaders)--> router.handleRequestHeaders
--> ActorResumer.ResumeActor --> ateapi ResumeActor (gRPC)
ateapi's AssignWorkerStep claims a free worker from the actor's WorkerPool.
In an oversubscribed system — the core premise of Substrate, where many actors
multiplex onto few workers — a burst of traffic can momentarily exhaust the
pool. AssignWorkerStep then returns FailedPrecondition: "no free workers available".
Previously the router mapped that straight to an HTTP 503 and failed the
request. But such saturation is usually momentary: another actor suspends within
milliseconds and frees its worker. Failing fast turns a sub-second blip into a
user-visible error.
Behavior
With parking enabled (the default), the router treats FailedPrecondition and
Unavailable from ResumeActor as retryable conditions (alongside the
existing Aborted concurrent-resume conflict) — a parked request rides out
transient pool saturation and control-plane blips (e.g. an ateapi rolling
restart) alike. The request is parked: the resumer keeps retrying with
exponential backoff until either
- the resume succeeds (the actor is
RUNNINGand has a worker IP) — the request is then routed normally; or - the park budget (
--parked-request-budget, default5s) elapses — the underlying capacity error is returned, surfacing as503 "actor <id> unavailable: no free workers available".
To bound resource use and provide backpressure, the router admits requests to a
parking lot of fixed capacity (--parked-request-max, default 1024). Each
in-flight resume occupies one slot. When the lot is full, further requests are
shed immediately with 503 "actor <id> unavailable: router at capacity" rather
than queueing without bound.
Every parked request holds one ext_proc stream — one active request against
Envoy's ext_proc cluster — for its entire wait, while ordinary requests hold
one only for a millisecond-scale header exchange. The cluster's circuit breaker
is therefore the hard ceiling on concurrent parked requests. By default the
router derives it as twice --parked-request-max (minimum 1024), so the
lot always fits and an equal share of fast-path headroom remains — a
saturated lot cannot starve requests to already-running actors, at any lot
size. --extproc-max-requests overrides the derivation; explicit values are
validated >= --parked-request-max at startup, because a breaker below the lot
would silently truncate it — Envoy would reject the overflow itself, with 503s
that never reach the lot and never count in parking.rejected.
Concurrent requests for the same actor are de-duplicated by the resumer's
singleflight group: they share a single in-flight ResumeActor call and all
park on its result, so a hot actor consumes N parking slots but only one
control-plane RPC.
The park budget is per-flight, not per-request. The budget clock starts
when a flight's first caller begins the resume; every later request for the
same actor joins that flight and shares its remaining budget and outcome. A
request that joins late may therefore see budget_exhausted after waiting far
less than a full budget itself — the accepted cost of collapsing a hot actor's
requests into one control-plane call. (parking.wait.duration records each
request's own parked time, so sub-budget budget_exhausted samples are
expected under sustained saturation.)
What is not parked
Only transient conditions — capacity (FailedPrecondition), concurrency
(Aborted), and control-plane unavailability (Unavailable) — are parked.
Errors that will not resolve by waiting are returned immediately (fail fast):
| Resume result | Behavior |
|---|---|
OK | Route to worker |
Aborted (concurrent resume) | Retry (always) |
FailedPrecondition (no free worker) | Park & retry (when enabled) |
Unavailable (control-plane blip) | Park & retry (when enabled) |
NotFound | Fail fast → 404 |
DeadlineExceeded | Fail fast → 504 |
PermissionDenied / Unauthenticated | Fail fast → 403 / 401 |
When parking is disabled (--parked-request-max=0), the router fails fast:
FailedPrecondition and Unavailable are returned immediately, there is no
admission cap, and only Aborted (concurrent-resume) conflicts are retried,
within a 15s budget.
Configuration
| Flag | Default | Meaning |
|---|---|---|
--parked-request-budget | 5s | Park budget per resume flight; requests de-duplicated onto an in-flight resume share its remaining budget (see Behavior). |
--parked-request-max | 1024 | Max concurrent parked/in-flight resume requests; excess shed (503). 0 disables parking. |
--parked-request-retry-interval | 100ms | Delay before a parked request's first resume retry. |
--parked-request-retry-factor | 1.1 | Multiplier applied to the retry delay after each attempt (>= 1). |
--parked-request-retry-jitter | 0.1 | Random fraction in [0, 1) added per retry to de-synchronize parked requests. |
--extproc-max-requests | 0 (auto) | Envoy circuit-breaker max_requests for the ext_proc cluster. 0 derives twice --parked-request-max (min 1024); explicit values must be >= --parked-request-max (enforced at startup). The excess is fast-path headroom (see Behavior). |
The retry backoff deliberately has no cap and no attempt limit: the budget alone bounds the wait.
Observability
Metrics (OpenTelemetry, meter atenet-router):
-
atenet.router.parking.active— up/down counter: requests currently parked. -
atenet.router.parking.wait.duration— histogram (seconds) of time spent parked. Recorded exactly once per admitted request, at the moment its resume attempt completes; never recorded for shed requests (those only incrementparking.rejected) nor when parking is disabled. Theoutcomelabel says how the park ended:outcomeWhen it is set servedThe resume succeeded and the request was routed to its worker. budget_exhaustedThe park budget elapsed while the resume was still blocked on a retryable condition (pool saturated, a concurrent operation holding the actor, or the control plane unavailable) — the signal that capacity, not a fault, is the bottleneck. canceledThe client disconnected while parked (request context canceled). timeoutThe request's own deadline expired while parked (distinct from the park budget). errorThe resume failed with a non-retryable error ( NotFound,PermissionDenied, ...). -
atenet.router.parking.rejected— counter: requests shed because the lot was full.
Status page (/statusz): a "Request Parking" card shows whether parking is
enabled, the current vs. maximum parked count, and the max wait.