Skip to content

Give a route one optional fallback upstream, via a metadata-only ordered plan - #30

Open
lgoyal6 wants to merge 9 commits into
mainfrom
codex/tollgate-ordered-route-plan
Open

lgoyal6 wants to merge 9 commits into
mainfrom
codex/tollgate-ordered-route-plan

Conversation

@lgoyal6

@lgoyal6 lgoyal6 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

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:

  • the method is idempotent (GET/HEAD/OPTIONS) and the body was buffered;
  • the client context is live and the request is inside its total deadline;
  • no response bytes have been written to the client yet;
  • an attempt remains inside the route's existing ceiling;
  • and the primary failed with a transport error, an open circuit breaker, or a
    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_max
ceiling, 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 rather
than 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 (migration 005). Every existing row gets '', plans a single
candidate, and behaves exactly as before. No backfill, no operator action, no
migration window. The existing statement-level routes_notify trigger already
covers 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 3

The 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:

Control Expected Result
primary consumes the body, then returns 503 fallback receives the byte-identical body passes; fails when the fallback is sent a different reader
POST receives 503 fallback receives zero requests passes; fails when both the ceiling and the eligibility guard are removed
unknown-length, non-replayable request fails fallback receives zero requests passes; fails under the same pair of mutations
response begins streaming, then fails fallback receives zero requests passes; fails when a served response is made abandonable

POST and the unbuffered body turned out to be defended twice over - once by the
attempt ceiling (attemptCeiling returns 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

go test ./internal/proxy ./internal/middleware ./internal/store ./internal/admin -count=1
go test ./... -count=1
go test ./internal/proxy -run 'TestOrderedRoutePlanEvaluation' -count=1 -v
git diff --check

All pass. go vet ./... clean, gofmt -l . empty, and
go test -race ./internal/proxy ./internal/middleware -count=1 passes.

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 requests
per arm, artifact at results/route-plan-eval.json:

arm success attempts/req fallbacks p50 p95 p99 body-integrity failures
failing primary, single upstream 0.00% 2.00 0 14.756ms 25.143ms 26.273ms 0
failing primary, ordered plan 100.00% 2.00 300 0.151ms 0.335ms 0.486ms 0
all healthy, single upstream 100.00% 1.00 0 0.041ms 0.061ms 0.183ms 0
all healthy, ordered plan 100.00% 1.00 0 0.040ms 0.053ms 0.106ms 0

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

  • 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, a provider, a network partition, or a real failover. The
    success rates are arithmetic about a scripted condition, not evidence about
    availability.
  • Says nothing about LLM traffic, which is POST and never fails over.
  • Latency is loopback wall-clock: measured, not deterministic.
  • The breaker is configured not to open inside an evaluation run, so the plan is
    what is being measured. The breaker-open failover path has its own unit test.
  • Hedging and failover are not combined. Hedging is already two in-flight
    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

lgoyal6 and others added 9 commits September 12, 2026 00:16
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>
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.

1 participant