Routing, failover and policy¶
How a request becomes a provider call. The flow is: decode the client protocol, pick a route, let the routing policy narrow the eligible targets, run the route's Switchyard algorithm, call the selected target (failing over across its endpoints), then encode the answer for the client.
Providers, targets, routes¶
- A provider is an OpenAI-compatible endpoint with an API key from an environment variable
and optional static
headerssent on every call (some providers require identifying headers). Per-conversation header values, such as a session id, are not supported yet. - A target is a named model served by an ordered list of
{ provider, model }endpoints. - A route is what clients request as
model. It wraps a built-in Switchyard algorithm over targets. A bare target name also works: it behaves as a passthrough route.
| Route type | Behavior |
|---|---|
passthrough |
First target; the rest are fallbacks |
random |
Weighted pick (weights, seed); the rest are fallbacks |
fastest |
The eligible targets ordered by recently observed time to answer, with some exploration (explore, min_samples, max_age_secs, seed); see Fastest routes |
stage_router |
Tool-result signals choose efficient or capable (mode, confidence_threshold) |
llm_classifier |
A judge target decides: capability mode judges task difficulty, escalation mode latches the session to capable after confirmations verdicts |
Algorithms keep per-session state (escalation latches, capable-hold turns). Clients identify a
session with the x-switchyard-session-id header (the engine also recognizes x-claude-code-session-id,
x-nemo-relay-session-id, x-session-id, session-id and Codex's turn metadata); without one,
state-dependent behavior is limited. When none of those carries an id, humpyard also accepts session_id
and then x-session-affinity, which is what OpenClaw sends with session affinity on. An id must be 1 to
256 visible ASCII characters; anything else is ignored, and the request is served without a session.
prompt_cache_key and x-client-request-id are not session ids.
Selecting the route¶
By default the client's model names the route. Selector rules let the operator decide the route
from facts about the request instead, so different clients, keys or agents follow different flows
(for example private, then paid, then public for one client and paid only for CI). Rules are
evaluated top to bottom and the first one that matches wins; with no match the requested model is
the route.
[[select]]
name = "subagents" # optional; shown in x-humpyard-rule
when = { subagent = true }
route = "cheap-first"
[[select]]
when = { profile = "deep" } # the client sent x-humpyard-profile: deep
route = "paid-only"
[[select]]
when = { tag = { team = "infra" } } # x-humpyard-tag-team: infra
route = "paid-only"
[[select]]
when = { key = "ci-*" } # the authenticated key's id
route = "paid-only"
[[select]]
when = { header = { "x-client" = "openclaw" } }
route = "cheap-first"
[[select]]
when = { prompt_tokens = { min = 30000 } } # long prompts go to a long-context tier
route = "long-context"
[[select]]
when = { images = true } # vision-capable models only
route = "vision"
[[select]]
route = "default" # no conditions: matches everything
All conditions of a rule must hold. Text conditions are globs (* matches any text, everything
else is literal and case-sensitive). The conditions are:
| Condition | Matches |
|---|---|
model |
the model the client asked for |
key |
the authenticated key's id (never matches on an open gateway) |
profile |
the client's x-humpyard-profile header |
tag |
{ name = glob } against x-humpyard-tag-<name> headers |
header |
{ name = glob } against any other request header (names are case-insensitive) |
agent, task |
the agent and task ids the client reports (x-switchyard-agent-id, x-switchyard-task-id) |
subagent |
whether the client marked the request as coming from a sub-agent |
stream |
whether the client asked for a streamed response |
prompt_tokens |
{ min, max } (inclusive, either may be omitted) against the gateway's estimate of the prompt size, the same conservative number count_tokens returns |
tools |
whether the request defines tools |
images |
whether the request carries images |
Standard client headers. Clients such as agent harnesses can describe themselves with
x-humpyard-profile: <name> and x-humpyard-tag-<name>: <value>; they mean nothing until a rule
uses them.
Clients can narrow, never widen. A rule only applies if the key may use its route
(allowed_routes); otherwise it is skipped and the next rule, or the requested model, applies, so
a header can neither reach a route the key may not use nor turn a working request into a refusal.
Give any key whose clients you do not fully trust an allowed_routes list. Credential headers
(authorization, x-api-key, cookie, proxy-authorization) cannot be matched. A rule can also
rescue a model name the gateway does not know, which helps clients that hard-code one.
Explaining a decision. POST /v1/route/explain (with your key) says what a request would do,
without calling a provider or spending anything. Describe the request; only model is required:
curl localhost:8080/v1/route/explain -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"model":"agent","headers":{"x-humpyard-profile":"deep"},"subagent":false,"stream":false}'
Add prompt_tokens (the estimate, for example from count_tokens), tools and images to ask
about content-based rules; an unstated size never matches a prompt_tokens rule. The answer has selected (route, rule, and whether it came from a selector or the
requested_model), rules (every rule in order: matched, the mismatches that stopped it, and
key_may_use_route), the key's budget state, the targets the route would use after the routing
policy with each endpoint's health, the route's fallback_on, and an outcome: ok, forbidden,
unknown_model, budget_exhausted or no_eligible_target. It runs the same code as real
requests, so the two agree; it only answers for the calling key.
Targets in the report carry recent (24-hour attempts, failure rate and median latency, from
/v1/stats) so you can see how a route has really been behaving.
Every request also gets an x-humpyard-request-id; the decision log
keeps what was decided (rule, skipped rules, attempts, outcome) so it can be reviewed later.
Responses carry x-humpyard-route (the route that served) and x-humpyard-rule (the rule's name,
select[<index>] when unnamed, or default when the requested model named the route); the log
line has both and the ledger's route column records the route. Selection happens before the budget
check and the routing policy, which then apply to the chosen route. Loading the config fails when
a rule names an unknown route, an unknown condition, a credential header, repeats a name, or
follows a catch-all (it could never match). See ADR 0011.
Auto routing recipe¶
The goal: clients always ask for one model, auto, and the gateway picks the pool from what the
request is. It needs no new feature, only selector rules; examples/auto-routing.toml
is a complete, tested starting point (the test suite loads that very file).
| # | Rule | Condition | Pool |
|---|---|---|---|
| 1 | private-profile / private-tag |
the client sent x-humpyard-profile: private, or x-humpyard-tag-privacy: private |
private-pool (your own hardware) |
| 2 | images |
the request carries images | vision |
| 3 | long-prompt |
estimated prompt of 30,000 tokens or more | long-context |
| 4 | subagent |
the client marked it as a sub-agent turn | cheap |
| 5 | tool-turns |
the request defines tools | capable |
| 6 | everyday |
anything else sent to auto |
cheap |
Things the recipe gets right, and that you must keep when you adapt it:
- Scope every rule with
model = "auto". Selectors see every request. Without that condition, a client that asks forcapabledirectly would be re-routed too. (Tested: a direct request for a pool bypasses the rules.) - Order encodes priority. Privacy is first, so a private request that also has images, tools
and a huge prompt stays private. A sub-agent turn with tools stays cheap because rule 4 comes
before rule 5. Use
explainto check any ordering question before sending traffic:curl localhost:8080/v1/route/explain -H "Authorization: Bearer $KEY" -d '{"model":"auto","tools":true,"subagent":true}'. autois also a real route. It is where a request lands when no rule applies or every matching rule is skipped for the key, so it must be safe for anything.- The private pool has no public fallback here. The private route lists only the local target,
so a failure is an error, never a silent trip to a public provider. If you prefer to degrade to
public, add a public target to that route, knowingly. (A first-class way to declare which classes
a route may fall through to is planned; see
add-auto-routing.) - Keys and the allowlist. A rule only applies if the key may use its route
(
allowed_routes). A key limited toautois therefore never sent to a pool (the rules are skipped andautoitself serves, as tested), and a key that lists the pool routes can also request them directly. There is no "reach pools only throughauto" setting yet; give each consumer the routes it may use, and keep privacy-sensitive routes off keys that should not reach them. - Prompt size is an estimate (the same conservative number
count_tokensreturns), so leave headroom in the threshold.
How a client expresses intent without code changes is in clients.md.
Once traffic flows, GET /v1/decisions?rule=tool-turns and GET /v1/stats?group_by=route show how
often each rule fires and what each pool costs and how long it takes: tune the thresholds from that.
Failover¶
- Within a target, endpoints are tried in order. The gateway moves to the next endpoint on
connection errors, timeouts, HTTP 408, 429 and 5xx. Other 4xx responses stop immediately
(another endpoint would reject the same request). Nothing fails over once a stream has started.
max_retrieson a provider sets extra attempts on that provider before failing over. - Skipping failing endpoints. Failing over on every request would make a dead provider cost a full timeout each time, so each endpoint (provider + model) has a circuit breaker; see Endpoint health.
- Across targets, if every endpoint of the selected target fails, the next target the
algorithm returned is tried. By default falling through happens for a context-window overflow
(including a provider's 413 "request too large"), 429, 403, 408, any 5xx, connection errors and timeouts, and on
fastestand pool routes also for a 404, 410, 401 or 402 (unavailable). Other client errors stop at once and reach the client: a 400 or 422 because another target would reject the same request, and on other routes a 404 or 401 unless the route opts in withfallback_on = ["unavailable"]. If no target answers, the client gets the last error (a timeout is still reported as 504). - Choosing what falls through. A route can limit this with
fallback_on, a list of failure classes (overflow,rate_limit,timeout,server_error,connection,forbidden,unavailable). A failure outside the list ends the request and the client sees the original status and message.fallback_on = []turns target fallback off. Without a list every class falls through exceptunavailable(a 404, 410, 401 or 402, or a 400 naming a missing model), which is included by default only onfastestroutes and pool routes and is opt-in everywhere else. A list is taken exactly as written. This keeps a paid last resort from being spent on a public blip:
[routes.deep-public]
type = "passthrough"
targets = ["pub-deep", "paid-deep"]
fallback_on = ["overflow"] # go to paid only when the prompt does not fit
It applies to every route type and governs the hand-over between targets; endpoints inside one target are a rotation and always fail over. - If everything fails, the client gets the last error: 429/4xx pass through, unreachable is 502, timeout is 504.
Responses carry x-humpyard-target (who served) and x-humpyard-provider (which endpoint).
Provider quota¶
Capped plans (for example OpenCode Go: a monthly fee with usage limits in a 5-hour, a weekly and a monthly window) fail at a limit the gateway cannot otherwise see until the provider answers 429. A provider can name a quota source, and the gateway then reads its usage windows while serving:
quota_poll_secs = 60 # how often (default 60)
quota_stale_secs = 300 # older than this counts as unknown (default 300)
[providers.opencode]
base_url = "https://opencode.ai/zen/go/v1"
api_key_env = "OPENCODE_API_KEY"
quota_source = "opencode-go"
GET /v1/quota shows the latest snapshot per provider: each window's percent used, the provider's
status text, when it resets, and how old the data is. Things to know:
- It is account-level. OpenCode's usage endpoint reports one set of windows for the whole account; it does not say which model is near its own cap. Treat it as "how hot is this provider".
- Advisory, and it fails open. Reading it never delays a request: it runs off the request path with a timeout, and if the provider is slow, down or answers nonsense the snapshot goes stale. Stale, missing or never-polled data is unknown, with the reason shown.
- The key is only sent to the provider's own host, over https (the config is rejected for a plain http provider URL, except to a loopback host), and never appears in logs or responses.
- Reloads keep the snapshot while the provider's URL and source are unchanged.
- History is kept (when a ledger is configured) so a percent can be compared with measured cost:
a poll is stored whenever a window's percent, status or reset time changes, and at least every 15
minutes, for
decision_retention_days.GET /v1/quota/historyreturns the samples, each with the notional cost of the provider's calls since the previous sample of the same window. Because the percent is account-wide, it can move with traffic that did not come through the gateway; compare trends, not single steps. - Nothing routes on it yet. This step only observes. Selector rules that match on quota come
after the percentages have been compared with measured cost (
add-quota-routing).
Target classes¶
A target can carry a class label, conventionally private (your own hardware), public (free
or shared providers) or paid, or one of your own (lowercase letters, digits, - and _):
[targets.local-model]
class = "private"
endpoints = [{ provider = "local", model = "local-coder" }]
Classes change nothing about routing by themselves: a route is strict only if it sets
allowed_classes (Strict routes). They make a quiet failure visible: a private route that
lists a public target after it will, when the private target fails, serve the request from the public
one, and until now nothing said so. With labels:
GET /v1/decisionsaddsfallback_crossed_classesto each decision (truewhen the attempts spanned more than one class,falsewhen they did not,nullwhen a target has no class), and each attempt shows itsclass.POST /v1/route/explainshows each target'sclass, the route'sclassesin order, andfallback_can_cross_classes, so you can see before sending anything that a route may leave its first class.
GET /v1/decisions?outcome=ok plus a look at fallback_crossed_classes answers "how often did my
private traffic end up somewhere public?". To forbid crossing outright, make the route strict.
Strict routes¶
A route can say which classes it may use at all:
[routes.coding-private]
type = "passthrough"
targets = ["local-coder", "paid-coder"]
allowed_classes = ["private"]
Targets whose class is not listed, and targets with no class, are removed from the route before the routing algorithm runs, so no failure, retry or tier substitution can reach them: the public target in the example is never called, not even when the private one is down. The client gets the private target's error, and nothing is rewritten.
- Validated when the config loads. A listed class must be a valid label; every target the route mentions must have a class (an unclassed target on a strict route is an error, not a silent exclusion); and at least one target must have an allowed class.
- Strictness only removes. Health still never tries to deny service (a cooling-down allowed target
is tried as before), budgets narrow within the allowed set, and no selector rule or header adds a
target back. A route without
allowed_classesbehaves exactly as before. - Visible.
explainshows the route'sallowed_classes, theexcluded_targetswith the reason, andfallback_can_cross_classes: false. Each decision lists itsexcluded_targets(GET /v1/decisions), andhumpyard_fallback_blocked_total{route}counts requests that failed on a strict route that had excluded targets, which is the signal that a fallback you forbade would have happened (Metrics). - The cost is redundancy by design. A strict route is only as available as its allowed targets, so alert on the metric if that matters.
Capabilities¶
An endpoint can say what its model can take, so a request is never sent to a target that cannot serve it:
[targets.local-coder]
endpoints = [{ provider = "local", model = "qwen3-coder", context_window = 32768, tools = true, vision = false }]
context_windowis the model's window in tokens,tools = falsesays it does not support tool calls,vision = falsethat it does not accept images. Anything you leave out means unknown, which means capable, so declaring nothing changes nothing.- Before routing, a target is removed from a request's plan when none of its endpoints can serve
it: the request uses tools and every endpoint says
tools = false, it carries images and every endpoint saysvision = false, or its prompt is certainly larger than every declared window. This is the same planning step as strict routes and health, after classes and the routing policy and before health, and it only ever removes targets. - Only when certain. The gateway's prompt estimate errs high on purpose, which would wrongly remove
a target that fits. Capability uses a separate, deliberately low estimate (about one token per six
bytes), so a removal means "even an optimistic tokenizer cannot fit this". A prompt close to the
limit still goes to the target, and the
overflowfallback handles a real overflow as before. - A target stays if any endpoint can serve. Inside a target the endpoints are still tried in order; skipping individual endpoints is not done yet.
- If nothing can serve the request, it is refused before any upstream call with HTTP 400
no_capable_target, and the message names the reason (for example "the request uses tools but its endpoints declare tools = false"). That is a fact you declared, unlike health, so it does not fail open; remove or correct the declaration if it is wrong. - Visible.
explainlistsincapable_targetswith the reason (and showsno_capable_targetwhen it would refuse); inexplain, aprompt_tokensyou pass is used as the estimate. Each decision lists itsincapable_targets(GET /v1/decisions), andhumpyard_capability_skips_total{route,target,reason}counts removals (Metrics). - Where the numbers come from.
humpyard prices importproposescontext_window,toolsandvisionfrom models.dev next to prices, for review;prices checkreports a declaration the source contradicts (Keeping prices current).
Tuning the estimate. The low estimate assumes at least 6 bytes per token, above what any tokenizer averages (2 to 5). To check it against your own traffic, compare the ordinary estimate the gateway recorded with the input tokens the provider reported; the ordinary estimate is about twice the low one, so real counts should stay above half of it:
sqlite3 humpyard.db "SELECT COUNT(*), ROUND(MIN(1.0*u.input_tokens/d.prompt_tokens),2) AS worst,
ROUND(AVG(1.0*u.input_tokens/d.prompt_tokens),2) AS typical
FROM decisions d JOIN usage u ON u.request_id = d.request_id AND u.kind = 'answer'
WHERE d.prompt_tokens > 0 AND u.input_tokens > 0;"
If worst is well below 0.5 for traffic you care about, the lower bound is not a bound there and the
divisor should come down. (prompt_tokens is recorded when a rule or a declared window needs the
estimate.)
Changing the configuration while running¶
Routes, selector rules, targets, providers, keys and budgets can be edited and reloaded without a
restart (SIGHUP, or reload_poll_secs); see deployment.md.
A reload is all or nothing, in-flight requests keep the configuration they started with, and
endpoint health and unchanged routes' session state carry over.
Pools and strengths¶
A target can carry strengths: soft labels for what the model is good at, such as coding, reasoning,
general, fast or agentic. A pool route takes every target with a strength instead of listing them.
[targets.groq-llama]
class = "public"
strengths = ["general", "fast"]
endpoints = [{ provider = "groq", model = "llama-3.3-70b", context_window = 131072 }]
[routes.free-general]
type = "fastest" # also passthrough or random
pool = { strength = "general", min_context_window = 32000 }
- Resolved at load. The members are worked out when the configuration loads or reloads and written into
the route's list in target-name order, so a pool is an ordinary route afterwards: strict classes, policy,
capabilities, health and latency ordering act on it unchanged. Nothing about strengths
is evaluated per request, and a strength never removes a target from a request. A reload that changes a
pool's membership logs
pool membership changed. min_quality(0 to 1) keeps only targets whose declaredqualityis at least that, resolved at load like the window: a target that declares no quality does not qualify, andexplainreports it as left out withquality_undeclared(orquality_below). Aqualityis a number the operator states on the target (quality = 0.74), for example a benchmark score they trust; it is never fetched at runtime.min_context_windowkeeps only targets with an endpoint that declares at least that window. An endpoint with no declared window does not qualify (you are asking which targets are known to be big enough; contrast capability removal, which acts only on certainty).POST /v1/route/explainshows the pool'smembersand each target that has the strength but was left out, with the reasoncontext_window_beloworcontext_window_undeclared.- Rules. A strength is 1 to 32 characters of lowercase letters, digits,
-and_; a target has at most 8, with no repeats.poolcannot be combined withtargets(orweights), takes one strength, and works onpassthrough,fastestandrandomroutes. A pool that resolves to nothing is a configuration error that lists the strengths that exist, so a typo fails at load. - What strengths are not. They are not capabilities (hard limits declared on endpoints) and not request
tags (
x-humpyard-tag-*, which describe the request). See ADR 0021.
A guarded tail: free first, paid only when needed¶
A fastest pool route can end in fixed targets that are reached only on certain failures:
[routes.public-fast]
type = "fastest"
pool = { strength = "general", min_context_window = 32000 }
then = { targets = ["paid-fast", "paid-deep"], on = ["overflow"] } # on defaults to overflow
The pool's members are ordered and fail over among themselves as usual. The tail follows them in written
order, and a failure moves a request from the last member to the tail only for the classes in on
(the same names as fallback_on, such as rate_limit, timeout, overflow); any other failure ends the
request there, so a paid target never serves because a free one was merely rate-limited unless you listed it.
A prompt larger than every member's declared window removes the members in planning, so the first tail target
serves it directly. If every member is only cold (see Endpoint health), they are tried again
before the tail is used. The tail needs a pool, names targets that are not members, and on a strict route
must be of an allowed class. explain shows the tail and its guard. See
ADR 0024.
Fastest routes¶
A route of type = "fastest" lists targets like a passthrough route, but tries them by speed instead of
in the order written. It is meant for pools of interchangeable endpoints whose speed varies, such as free
tiers.
[routes.free-pool]
type = "fastest"
targets = ["groq", "cerebras", "openrouter-free"]
explore = 0.1 # share of requests that try a random eligible target first (default 0.1)
min_samples = 3 # answered attempts before a target's figure is believed (default 3)
max_age_secs = 600 # a figure older than this counts as unmeasured (default 600)
seed = 7 # optional: makes the exploration draws reproducible
# quality_weight = 0.5 # blend declared quality with speed (default 0: speed only)
# latency_target_ms = 2000 # the answer time that earns full speed marks
# unknown_quality = 0.5 # the quality assumed for a target that declares none
- What is ranked. Only the targets still eligible after strict classes, policy, capabilities and health: ordering never adds a removed target back, however fast its figure.
- The figure. One smoothed value per target (the newest sample weighs 0.3), kept in memory and updated from the attempts the gateway already times, so nothing is probed. It is the time until the upstream answered: the full time for a buffered call, and until the response began for a streamed one. Only answered attempts update it; a failed attempt does not make a target look fast or slow, because failures are what health tracking is for. A restart starts empty.
- Weighing quality. By default the order is purely by speed, so a small quick model can lead a pool.
quality_weight(0 to 1, default 0) blends in the declaredquality: each measured target is scored(1 - w) * speed + w * quality, wherespeed = min(1, latency_target_ms / observed_ms)(so a target at or underlatency_target_ms, default 2000, gets full marks and a slower one falls off in proportion) and a target that declares no quality counts asunknown_quality(default 0.5). Targets are tried by descending score, ties by observed speed. With the weight at 0 the order is exactly the speed order, so no existing route changes. A strong model that is somewhat slower leads a weak quick one, but a very slow one still loses: at weight 0.5, quality 0.9 at 20 s scores 0.5, below quality 0.6 at 0.8 s scoring 0.8.explainshows each target's quality, speed score and score. Unmeasured targets are still tried first so they get measured, and exploration is unchanged. - Unmeasured targets go first, in the order written, so a new target is measured before it is judged,
and a figure older than
max_age_secscounts as unmeasured, so a target that was slow ten minutes ago and has been idle since gets another look. A fresh gateway therefore behaves like a passthrough route until it has data. - Exploration. With probability
explorea request tries a random eligible target first and the rest follow in speed order;explore = 0is pure speed ordering. - See it. Each decision records
ranking(order, andreason:fastestorexplore);POST /v1/route/explainshows theorderinga request would use now (without exploring) and each target'scurrent_latency_msandlatency_samples;GET /v1/stats?group_by=targetandGET /v1/healthshow the same figure;humpyard_target_latency_secondsexposes it. - Limits. The figure is a rough ranking. It mixes request sizes, so a target that mostly gets large prompts looks slower, and speed says nothing about quality: put targets of comparable capability in one pool. See ADR 0020.
Endpoint health¶
After failure_threshold consecutive failures of the kind that trigger failover (connection
errors, timeouts, HTTP 408, 429, 5xx) an endpoint is skipped for cooldown_secs. Each time it
trips again the cooldown doubles, up to max_cooldown_secs. When a cooldown ends, exactly one
request is sent as a probe: success returns the endpoint to service at once and resets the
cooldown; failure reopens it for longer.
Answers that describe the endpoint count too: a 404 or 410 (the model is gone there), a 401 or 403 (the key is
rejected or may not use the model), a 402 (the key has no credit left), and a 400 whose error.code or error.type is model_not_found,
model_not_available or model_decommissioned. They cool the endpoint like a 5xx, on every route. Answers
that describe the request (any other 400, and 422) say nothing about the endpoint and are never counted.
A 404, 410, 401 or 402 also moves on to the next endpoint of the same target, and to the next target on fastest and
pool routes (see fallback above), which is what keeps a pool of free models,
some of them removed by their providers, from failing requests. Health still never denies service.
A 413 is different: it says the request is too large for that endpoint (a free plan's per-minute token cap, a body
limit), not that the endpoint is unwell. It moves on like an overflow (the next endpoint of the target, the next
pool member, and a guarded tail that lists overflow, which is the default) but is never counted against the
endpoint's health, so the endpoint keeps serving smaller requests. If nothing takes the request, the client gets the
413.
[health] # all optional; these are the defaults
failure_threshold = 3 # 0 turns health tracking off
cooldown_secs = 30
max_cooldown_secs = 300
max_retry_after_secs = 300 # the longest a stated retry time cools an endpoint
skip_cold_targets = true # leave a cold target out of the plan when another target remains
-
A stated retry time wins over the guess. When a 429 or 503 says when the endpoint will serve again (the error body reads "Please try again in 7.66s", or carries a
retryDelayorretry_afterfield), the endpoint is cooled at once for exactly that long, clamped to between 1 second andmax_retry_after_secs: no waiting forfailure_thresholdfailures, no doubling, and not held tomax_cooldown_secs, because a stated time is not a guess. A later failure streak without a stated time is not escalated by it. Only the first 4 KiB of the body is read, only well-known phrasings are recognized, and anything unreadable is handled as above. TheRetry-Afterheader itself is not read yet: Switchyard 0.3.0 does not expose it on the error it returns (NVIDIA-NeMo/Switchyard#904 adds that), and reading it will take precedence over the body once it does.GET /v1/healthandroute/explainshowcooldown_source(retry_afterorfailures), decisions recordretry_after_mson the attempt, andhumpyard_retry_after_honored_totalcounts them. -
A cold target is skipped when an alternative exists. A target whose endpoints are all cooling down is left out of the route's targets for a request, so the request does not spend its first attempt (and a recorded failure) on a target the gateway already knows is down. A tier emptied this way is served by the other tier, as with any routing policy removal. It happens in the same planning step as strict routes and the budget policy, after them: it never adds a target back, and a strict route skips only within its allowed targets.
- Health never denies service. If skipping would leave nothing (every eligible target is cold, or the only one is), nothing is skipped and the targets are tried as before, soonest-to-reopen first. A request is never refused because of health.
- Recovery is by probe. A target stops being cold when its cooldown ends; the next request that reaches it is the probe, and a success returns it to service. While it is skipped it receives no traffic, so recovery takes at most one request after the cooldown.
- Visible.
explainmarks each targetcoldor not, listshealth_skipped_targets, and sayshealth_fail_openwhen every eligible target was cold. Each decision lists itscold_targets(GET /v1/decisions), andhumpyard_health_skips_total{route,target}counts the skips (Metrics). Setskip_cold_targets = falsefor the old always-in-order behavior. - State is in memory per process: a restart starts everything healthy. Failures after a stream has begun are not counted (nothing fails over then either).
- The log shows each transition ("endpoint is failing; skipping it for the cooldown",
"endpoint recovered"), and
GET /v1/health(with a key when the gateway has keys) lists every endpoint'sstate(healthy,cooling_down,probing),consecutive_failuresandcooldown_remaining_ms. - Not yet: the
Retry-Afterheader is not read (a retry time stated in the error body is, above). See ADR 0009 and ADR 0018.
Runtime overrides¶
An override is a quick, temporary switch: "stop using this target for the next two hours", with no config edit and no reload. It undoes itself, and every create, removal and expiry is recorded with the id of the key that did it.
# stop sending traffic to `deep` for 2 hours (the key needs manage_overrides = true)
curl -s -H "Authorization: Bearer $OPS_KEY" -H 'content-type: application/json' \
-d '{"kind":"disable_target","subject":"deep","reason":"bad answers","expires_in_secs":7200}' \
http://127.0.0.1:8080/v1/overrides
curl -s -H "Authorization: Bearer $OPS_KEY" http://127.0.0.1:8080/v1/overrides
curl -s -X DELETE -H "Authorization: Bearer $OPS_KEY" http://127.0.0.1:8080/v1/overrides/1
- Kinds.
disable_targetremoves a target from every route.drain_providerstops every endpoint of one provider being tried: a target with endpoints on other providers keeps serving through those, and a target whose endpoints are all on that provider is left out.pin_route(with arouteand a target) cuts that route to the target; several pins on one route keep every target they name. - They only remove. An override never adds a target, changes a price or widens a key. It is applied after
strict routes and capabilities and before health, so a
pin cannot reach a target a route's classes forbid, and health sees only what is left. A request,
explainand the decision all use the same overrides. - They expire. Each lasts
expires_in_secs, or[overrides] default_secs(four hours) when omitted, and never more thanmax_secs(a day by default). At the expiry the target is eligible again with nothing else to do. - Visible. Each decision (
GET /v1/decisions) lists inoverridesthe ones that took a target out of its plan (id, kind and subject), including a request they refused.POST /v1/route/explainlistsoverridden_targets, each withoverride_idas the reason it was removed.GET /v1/configlabels a disabled target (or one whose every endpoint is on a drained provider) with the sourceoverride.humpyard_overrides_active{kind}andhumpyard_override_oldest_age_secondsare in the metrics, and the shipped alert rules includeHumpyardOverrideLong, which fires when the oldest override is over six hours old. - Subjects must exist. A target or route that is not in the loaded configuration is refused, and so is a pin
to a target the route does not list. If a reload later removes what an override names, it stays stored, shows
stale: truein the list, and does nothing. - Unlike health, this fails closed. If overrides remove every target a route has left, the request is refused
with 503 (
override_no_target, before any upstream call) and the message says an override caused it. Disabling the only target of a route, or draining the only provider it has, means the route is down until the override expires or is removed, which restores service at once. Health never refuses a request; an override is an operator's explicit instruction, so it does. A drained endpoint is never brought back by health's fail-open rule: when its target's other endpoints are all cooling down they are still tried, but the drained one is not. - Who and where. Overrides need a
[ledger](without one, creating is refused with 409, because nothing would survive a restart) and live on this instance only: with several replicas, each has its own. A gateway with no[keys]allows anyone to manage them, like the rest of the admin surface.
Routing policy¶
A policy decides, per request, which targets are eligible. Ineligible targets are removed from every group the algorithm sees, including its fallbacks.
- Tier substitution: if the policy removes every
capabletarget,capableis served by the eligibleefficienttargets (and the reverse; a missingjudgefalls back to a remaining tier). Limits therefore degrade routing instead of failing requests. - Random routes re-align their weights to the remaining targets.
- If nothing is eligible, the request fails with HTTP 503 naming the route.
The gateway always applies the budget policy (see budgets.md); an embedder-supplied
policy (default AllowAll) is composed with it, and a target must satisfy both. Embedders implement
humpyard::policy::RoutingPolicy and start the server with
server::router_with_policy. A policy receives the route, the session id and request metadata.
Health-aware target eligibility is a planned change built on this seam.