scale_from_zero: true, and no healthy agent serves it here, Hivenet Router holds the request instead of failing it. It counts the request in the hivenet_pending_demand metric, keeps the connection alive, and serves the request as soon as an agent for the model registers.
Hivenet Router does not start or stop models. An external autoscaler, such as KEDA, reads hivenet_pending_demand and scales the model from zero. The agent registers once its backend answers its health check, and the router releases the held requests.
Hold at zero needs cross-site redirect: the sibling map says which models may be held on this site. With no --site-id, or for a model the map does not place here with scale_from_zero: true, nothing is held and the router behaves as before.
The sibling map is held in memory. A restarted router holds nothing until the map writer pushes the map again, and requests for a model at zero fail as before in the meantime.
How a request is handled
After the API key is authenticated, the model alias is resolved and the model is checked against the key’s allowlist:- Served here. If a healthy agent on this site serves the model, the request is served here as usual.
- Redirect first. If the sibling map lists another site in the request’s scope that serves the model, the router answers
307to it, following the rules in Cross-site redirect. The one exception: while the map’s hints are fresh, every sibling in the caller’s home set (the two sites a key is redirected to for a model) is cold and the model may be held here (step 3), the request is not redirected, because a cold sibling is no faster than waking the model here. A warm sibling outside the home set does not prevent this. The request then continues to step 3, as it does when no sibling is in scope or the request has already been redirected twice. - Held here? If the map places the model on this site, for the endpoint’s kind, with
scale_from_zero: true, the request is held. Otherwise it fails as before, normally with404 model_not_found. - Quota. The key’s quota is checked before the hold, so a key over its limits is refused at once. See Quota while held.
- Hold. The request waits, counted once in
hivenet_pending_demand{model}. After--wake-keepalive, the client receives keepalive bytes. - Wake. The autoscaler starts the model. When an agent for the model registers, every held request is released and continues as a normal request: admission, routing and the backend call. It stays counted in
hivenet_pending_demanduntil it gets a slot on an agent, and keeps receiving keepalives until its response starts. See After the wake. - Timeout. If the model does not wake within the hold budget, the request gets
503 model_wakingwithRetry-After.
chat for POST /v1/chat/completions and POST /v1/messages; embedding for POST /v1/embeddings; rerank for POST /v1/rerank. POST /v1/messages/count_tokens is never held: a client sends it just before the real request, which is held and wakes the model. Holding both would count each prompt twice in the pending demand.
A request whose key may not use the model is never held: it gets 403 model_forbidden at once.
What the client sees
A held request looks like a slow first response. The client needs no changes, but its request timeout must be longer than the hold budget plus the generation time.Keepalive
A held connection carries no data, so proxies and load balancers with an idle timeout may close it, and client SDKs may then retry. After--wake-keepalive (default 15s), and then at the same interval, the router sends keepalive bytes. They go on after the wake while the released request waits for capacity, with no longer gap at the release, and stop when the response starts:
- an SSE comment,
: keep-alive, to a streaming request ("stream": true); - a single space to any other request. Leading whitespace is valid before a JSON body.
200 status and the response headers, so the status can no longer change. An error after it is written in the body:
- for a streaming request, as a final SSE event. OpenAI-compatible endpoints get
data: {"error":{...}}./v1/messagesgets an Anthropicerrorevent whose message starts with the error code. - for any other request, as the usual JSON error envelope after the spaces.
503, not the 200 sent on the wire.
model_waking
Match on
code, not on the message. retry_after repeats the Retry-After header, in seconds.
When you retry, send your own ID in X-Client-Request-ID (or X-Request-ID) and resend the same ID, so the retry is recognized as the same request. The ID must be a UUID, in any letter case: the router ignores any other value, and the retry then counts as a new request.
A client that disconnects while held is recorded with status 499 in the audit log.
Hold limits
Each held request keeps its request body in memory, so
--wake-hold-max-total bounds that memory. A request beyond any limit gets 503 model_waking at once and is not counted in the pending demand.
Set the hold budget per model from its measured cold start: the time from the autoscaler’s decision to the backend’s first successful health check, plus a margin. A budget shorter than the model’s cold start fails every wake.
Quota while held
The per-model requests-per-minute quota scales with the number of healthy replicas, and is skipped when a model has none. For a request that may be held here (its model is placed here, for the endpoint’s kind, withscale_from_zero: true), the quota counts at least one replica, so a held request is charged like any other and a key cannot hold requests past its quota. A request refused at once because a hold limit is reached is charged too.
After the wake
A wake releases every held request together, often more than the woken replicas can serve at once. Released requests line up for the woken capacity instead of being refused, and receive keepalives while they wait:- Admission gate. The global admission budget (
max_inflight,admit_budget_tokens) lets a released request wait for room until its hold budget ends, even whenHIVENET_ROUTER_ADMIT_PARK_TIMEOUTis0. While it waits, the budget is recomputed every second, so replicas that join after the wake are used. If there is still no room when the hold budget ends, the request gets503 model_waking, not429. A request larger than the whole budget of the current pool gets the usual429 concurrency_limit_exceededat once; it can fit later if more replicas join. The per-keymax_occupancy_sharedoes not wait: a key over its share gets429 rate_limit_exceededat once, as usual. - Capacity queue. The capacity queue accepts released requests beyond
--queue-depth; the hold limits already bound how many there are. The wait there is bounded by--request-timeout(default60s), not by the hold budget. When it runs out, the request moves to the next fallback step, or gets504 request_timeoutwhen there is none. With--queue-depth 0there is no queue: a released request that finds no free slot moves to the next fallback step or fails at once.
--request-timeout for the wake burst: with one replica of 4 slots and 100 released requests, many wait longer than 60s.
A new or recovered agent wakes every queued request together. A request that finds no free slot goes back in line at its original place, ahead of later arrivals.
Pending demand and the autoscaler
hivenet_pending_demand{model} is the number of distinct requests waiting for a model: the requests held now, plus the requests released by a wake that have not yet got a slot on an agent. It does not drop to 0 as soon as the first replica is up, so it also shows how many requests the woken capacity still has to take. A request leaves it when it gets a slot, so a request being generated is never counted.
A client that retries the same request, with the same API key and the same client request ID (X-Client-Request-ID or X-Request-ID), counts once. The client ID must be a UUID: any other value is ignored, and each retry then counts on its own. A request without a client ID counts on its own, and requests from different keys always count separately, even with the same client ID.
The metric has a series, 0 when nothing is held, for every model that the map places on this site with scale_from_zero: true. It has no series for the model while the router has no map (after a restart, until the map writer pushes it again), or when the map no longer places the model here with scale_from_zero: true. Write autoscaler queries so that a missing series counts as 0, as in the example below.
Each router counts only the requests it holds itself. With several routers per site, sum the metric across them.
Waking the model
Held requests are released when an agent for the model registers, so the agent must register only when its backend can serve. The Hivenet Router agent checks its backend’s health endpoint before it registers: run it next to the backend, for example as a sidecar container, and start them together. Some backends report healthy before they can answer quickly: Ollama loads the model on its first request, and SGLang compiles kernels on it, which can take a minute or more. For these two engines, when the agent serves a chat model (--capability llm), it sends a one-token chat completion before it registers, and registers only once it succeeds, so the held requests do not pay for the load.
When an agent’s connection to the router drops, the agent leaves the routing pool at once. A model that was just scaled down is therefore held again immediately, rather than receiving requests for the missed-heartbeat timeout.
Example: KEDA and a LeaderWorkerSet
This example scales a model served by a LeaderWorkerSet (LWS) between 0 and 1 replica. The model wakes when a request is held, stays awake while it serves traffic, and goes back to 0 after 30 minutes without requests:- Wake.
hivenet_pending_demandrises above0when a request is held, so the value passesactivationThresholdand KEDA scales the LWS from0to1. - Stay awake.
increase(...[2m])is above0for 2 minutes after each routed request, so the trigger stays active while the model serves traffic. - Idle.
cooldownPeriodis the idle window. KEDA starts it when the trigger becomes inactive, about 2 minutes after the last request, and scales the LWS back toidleReplicaCount: 0at its end. Keep theincreasewindow short: a long window adds its own length to the cooldown, so[30m]with a 30-minute cooldown keeps an idle model up for about an hour. - On or off only.
clamp_max(..., 1)makes the trigger say only whether the model should run. Without it, KEDA also reads the value as a replica count: withthreshold: "1", 30 requests in the window ask for 30 replicas. That does not matter withmaxReplicaCount: 1. For a model allowed more replicas, add a separate trigger for the replica count, for examplehivenet_pending_demanddivided by the requests one replica serves at once: it still counts the released requests waiting for capacity right after a wake. - No data.
hivenet_pending_demandhas no series while the router has no map, andhivenet_routing_requests_routed_totalhas none before the model’s first request. Eachor vector(0)keeps one missing term from emptying the whole query: an empty result would scale a model that serves traffic down to0. - New series. The counter has
tenant_id,engineandregionlabels, andincreasedoes not count the first sample of a series. The first request of a new label combination, or the first after a router restart, does not keep the trigger active, so the cooldown may start up to 2 minutes earlier than after the last request.
increase, not rate: a per-second rate of a lightly used model stays below any useful threshold. Give the backend container a readiness probe on its health endpoint, so Kubernetes does not report the pod ready before the model can serve.
Configuration
The flags are listed in the configuration reference:--wake-hold-budget, the three hold limits in Hold limits, and --wake-keepalive (default 15s, 0 disables keepalives). The router refuses to start when a value is invalid: a budget or limit that is not greater than 0, or a negative keepalive.
The hold_budget placement field
A placement in the sibling map may set its own hold budget:
hold_budget is optional: a Go duration greater than 0 and at most 1h. Any other value rejects the whole map push with 400. It applies only to placements with scale_from_zero: true.
Metrics
The hold-at-zero metrics arehivenet_pending_demand (see Pending demand), hivenet_wake_holds_total{outcome} and hivenet_wake_hold_seconds. A rising outcome="expired" count means the model takes longer to wake than its hold budget. A rising outcome="full" count means the hold limits are too low for the traffic that arrives while the model is asleep.
Limitations
- Hold at zero needs cross-site redirect and the sibling map: there is no other way to declare which models may be held.
- A model whose wake takes longer than its hold budget is still held for the whole budget before
model_waking. The router does not know a model’s expected wake time. - There is no endpoint to wake a model ahead of traffic. Scale it up with your autoscaler instead.
- Each router holds its own requests. A client retry that reaches another router of the same site is counted there too.
- Once keepalives have started, a later error is reported in the body of a
200response.

