Conversation
A route names one upstream, so a provider having a bad afternoon is the route having a bad afternoon. This adds a second: four nullable columns and a small type that puts them in order. A RoutePlan is routing metadata and nothing else. Which upstreams, in what order, and the *name* of the environment variable each one's credential comes from - never a body, never a secret, never a connection. That split is the point of the type existing: the order can be decided, logged and traced without any of the things the proxy owns being in scope. Not a routing policy engine, and not the first step towards one. No scoring, no weighting, no plugin point, no arbitrary-length candidate graph. One optional fallback is enough to survive a single upstream refusing service and small enough that the order reads off one row. Four nullable columns rather than a candidates table, because a route may have exactly one fallback and a table would model a cardinality this does not have, then need a constraint to take it back. Every existing row gets '' and plans a single candidate, which is the decision the gateway already made. No backfill, no operator action, no migration window. The fallback names its own header and env var. A fallback is usually a different provider, so sharing the primary's variable would be the wrong default and the kind of wrong that sends one provider's key to another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The order has to be a property of the configuration rather than of the moment a request arrived, or two requests a second apart take different paths and nobody can reproduce either. PlanRoute builds it from the immutable snapshot the router already reads. The upstream allowlist check now runs over every candidate. A fallback is the second place this gateway would attach the shared credential and open a connection, so a fallback pointed at the metadata service is the same bug with one more step in front of it. A refused candidate refuses the whole route rather than being quietly dropped back to one upstream. Serving the request anyway would hide the misconfiguration until the day the fallback was actually needed, which is the worst available day to find out. The route is still installed alongside the plan: the proxy is not the only thing that reads it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proxy spends one attempt budget across the plan's candidates. A fallback is an attempt out of the route's existing 1+retry_max ceiling, not a second retry budget: doubling what one request can do to the upstreams it touches is the amplification maxAttemptsHardCap exists to prevent. One attempt of that ceiling is reserved for the fallback when the request is eligible. Without it the primary spends the whole budget retrying itself and the fallback is configuration that can never fire, which is worse than not having the feature - the console says the route has a standby and it does not. Not when the ceiling is a single attempt: the primary gets that one, or a misconfigured route would contact nothing. Failover needs all of: an idempotent method, a buffered body, a client that is still there, no bytes written yet, an attempt left, and a primary that failed with a transport error, an open breaker, or 502/503/504. The list that does not qualify is the interesting half. POST never fails over - a completion may already have been generated and billed, and a 503 is not evidence that it was not. A 401 means the credential is wrong, a 403 means the caller may not, a 429 means slow down: asking a second provider gets the same answer out of somebody else's quota. A missing credential is this gateway's own misconfiguration and a second upstream is not the fix. The primary's refusal is held rather than discarded while the fallback is tried. It is the response the client received before this change, and throwing it away would turn a relayed 503 into a manufactured 502 whenever the fallback also failed. Credentials are injected per candidate, breakers are keyed per host, and the log and span name the candidate order and the chosen one by host - never a URL query, which is where several providers take their key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fire `add-route -fallback`, plus `set-fallback` and `clear-fallback` for a route that already exists. The upstream and its credential source move together, because a fallback left pointing at a new provider with the previous provider's env var would send one provider's key to another. Four configurations are refused rather than stored: A fallback on a route with no retries. The fallback is an attempt out of the route's 1+retries ceiling, so a route with one attempt gives it to the primary and the standby can never be reached. Better an error at configuration time than an outage the standby did not cover. A fallback equal to the primary. Not a safety property, an honesty one: it buys nothing and reads in the console as redundancy. A fallback the upstream allowlist refuses, checked here as well as at request time for the same reason the primary is. Credentials with no fallback upstream to send them to, which is a row somebody will later read as "the fallback is configured". The management surface shows the fallback by upstream and by the *name* of its environment variable. Same rule as the primary: a name is safe to display, a value never leaves the gateway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most of these are about the second upstream NOT being contacted. A fallback that fires when it should not sends somebody's request, and somebody's money, to a provider that was never asked to answer it. The four that matter most: The primary drains the body and returns 503; the fallback still receives the byte-identical original. A gateway that replayed a spent reader would send an empty request and get back something plausible and wrong. A POST gets a 503 and the fallback receives zero requests. An unknown-length request cannot be replayed and the fallback receives zero requests. A response starts streaming and then dies mid-body; the fallback receives zero requests. Once a status line is on the wire there is no un-sending it, and a second upstream's answer spliced onto the first one's is something the client has no way to detect. Each was confirmed to fail with the corresponding guard removed. POST and the unbuffered body turned out to be defended twice over - once by the attempt ceiling and once by the eligibility check - so killing either of those tests takes removing both. Also here: one spend hold and one settlement survive a successful failover, and a failover where both upstreams refuse still closes the hold once and does not bill it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four arms of 300 requests: a failing primary with and without a fallback, and an all-healthy pair to price the overhead when the fallback is never needed. With a healthy primary the fallback is contacted zero times and the request costs the same one attempt, so there is no measurable overhead to carry. Against the scripted 503 the plan answers all 300 and the single upstream answers none. Zero body-integrity failures in every arm. The latency gap in the failing case is the retry backoff and not cleverness: the single-upstream arm waits and asks the same dead upstream again, while the plan goes straight to a different one with no backoff between candidates. The asserts are on the deterministic properties. The numbers are reported, not asserted - a latency threshold in a unit test is a flake with a date on it. What this is: two httptest servers and a proxy, in one process, on one machine. The outage is a handler that was told to return 503. Not a datacenter, not a provider, not a network partition, not a real failover. The success rates are arithmetic about a scripted condition rather than evidence about availability, and none of it says anything about LLM traffic, which is POST and never fails over at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mechanism, the one-fallback limit, the exact eligibility list, the shared attempt ceiling and the single budget hold. The section leads with the boundary rather than the feature, because the question a reader actually has is whether their LLM traffic is about to be sent to a second provider. It is not: POST never fails over, and the paragraph saying so is longer than the one describing when failover does happen. The measured table is here with its limits attached, including that the p50 gap is the retry backoff rather than anything clever, and that a scripted 503 in one process is not availability. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
A route may now name one optional fallback upstream. The router turns the
row into an ordered route plan - primary, then at most one fallback - and the
proxy walks it.
This is an independently designed extension of this project's existing route,
retry and credential-injection model.
Router versus proxy
The plan is routing metadata and nothing else: which upstreams, in what
order, and the name of the environment variable each one's credential comes
from. It never holds, reads or borrows a request byte, and never holds a secret.
The proxy stays the only component allowed to read or buffer a request body,
inject a provider credential, open an upstream connection, retry, hedge, fail
over, stream a response, or decide whether bytes are replayable.
That split is why the candidate order can be logged and traced without a body or
a credential going near a log line.
One fallback, not a routing policy
No scoring, no weighting, no plugin point, no arbitrary-length candidate graph,
no policy registry, no feature flag. One optional fallback is the complete
scope: enough to survive a single upstream refusing service, small enough that
the order reads off one database row.
Exact failover eligibility
Failover happens only when all of these hold:
502, 503 or 504.
It does not happen for: POST, an unbuffered or over-limit body, 401, 403,
429 or any other 4xx, a partially streamed response, a cancelled client, or a
credential the gateway does not have. Hedging is unchanged and races the primary
only.
POST is the one worth stating plainly: an LLM completion may already have been
generated and billed upstream, and "it returned 503" is not evidence that it did
nothing.
Shared attempt and budget semantics
A fallback is an attempt spent out of the route's existing
1 + retry_maxceiling, not a second retry budget. One attempt of that ceiling is reserved
for the fallback so it can actually be reached - which is why a fallback
requires
retries >= 1, and why configuring one without it is refused ratherthan stored as config that silently never fires.
Budget is untouched: one spend hold and one settlement per request, however
many upstreams it touched. Tested directly.
Configuration
Smallest backward-compatible representation: four nullable-by-default columns on
routes(migration005). Every existing row gets'', plans a singlecandidate, and behaves exactly as before. No backfill, no operator action, no
migration window. The existing statement-level
routes_notifytrigger alreadycovers the new columns.
tollgate-admin add-route -tenant acme -prefix /api/ -upstream http://upstream-a:9000 \ -retries 1 -fallback http://upstream-b:9000 \ -fallback-auth-header x-api-key -fallback-auth-env BACKUP_API_KEY tollgate-admin set-fallback -route 3 -upstream http://upstream-b:9000 tollgate-admin clear-fallback -route 3The fallback names its own header and environment variable. A fallback is
usually a different provider, and one shared env var is how one provider's key
reaches another one.
Four configurations are refused rather than stored: a fallback on a route with
no retries, a fallback equal to the primary, a fallback the upstream allowlist
refuses, and fallback credentials with no fallback upstream. The router also
runs the upstream allowlist check over every candidate - a fallback pointed
at the metadata service is the same SSRF bug with one more step in front of it.
Negative controls
All four are tests, and each was confirmed to fail when the corresponding guard
was deliberately removed:
POST and the unbuffered body turned out to be defended twice over - once by the
attempt ceiling (
attemptCeilingreturns 1 when a request cannot be repeated)and once by the eligibility check. Removing either alone leaves the controls
green; removing both turns them red. That is defence in depth, and it is
recorded rather than presented as one guard.
Also covered: 401/403/429 never fail over, a cancelled client never starts a
fallback, the fallback uses only its own credential source, the primary's
credential never reaches the fallback, no caller credential reaches either
upstream, a missing fallback credential does not send an unauthenticated
request, both candidates see the same path, and the log names the candidate
order and chosen candidate while leaking neither credential nor body.
Test commands
All pass.
go vet ./...clean,gofmt -l .empty, andgo test -race ./internal/proxy ./internal/middleware -count=1passes.The entire pre-existing suite passes unchanged, which is the
"single-upstream routes behave exactly as before" invariant.
Evaluation
go test ./internal/proxy -run TestOrderedRoutePlanEvaluation -v, 300 requestsper arm, artifact at
results/route-plan-eval.json:With a healthy primary the fallback is contacted zero times and the request
costs the same one attempt, so there is no measurable overhead when it is not
needed. The latency gap in the failing case is the retry backoff, not
cleverness: the single-upstream arm waits and asks the same dead upstream again,
while the plan goes straight to a different one with no backoff between
candidates.
The test asserts on the deterministic properties and merely reports the numbers.
A latency threshold in a unit test is a flake with a date on it.
Limitations
httptestservers and a proxy, in one process, on one machine. The"outage" is a handler that was told to return 503.
success rates are arithmetic about a scripted condition, not evidence about
availability.
what is being measured. The breaker-open failover path has its own unit test.
attempts at one upstream, and sharing one ceiling between that and a second
upstream is a larger change than one fallback.
🤖 Generated with Claude Code