fix(mcp): free-reject ordering and rate-limit coverage on target proxy

Hoist the Upgrade->501 and traversal->400 checks in handleTargetProxy
above checkAndCount, matching handlePassthrough's own order
(403 -> 501 -> 400 -> limit). A request rejected before the upstream
call must never burn the caller's or their group's rate-limit quota
for a call that was never going to reach the target. Updated the
function's doc comment to state and justify the order.

Add TestHandleTargetProxy_RateLimited_Returns429WithRetryAfter:
group requestsPerMinute:1, two hits on /mcp/{name}/x -> first 200,
second 429 with Retry-After, upstream called exactly once.

Add TestHandleTargetProxy_JSONResponse_OnlyRequestCounterMoves: a
usage-shaped JSON response from the target leaves token/cost
counters at 0 while the request-rate counter moves to 1, proving
the target proxy never tees or accounts a response the way native
passthrough does.
This commit is contained in:
2026-08-20 12:27:07 +01:00
parent 0e73558f5c
commit 304c5ebf3f
2 changed files with 127 additions and 11 deletions
+13 -11
View File
@@ -139,11 +139,13 @@ func (g *Gateway) resolveTarget(kind, name string, grp *group) (targetURL string
// is never teed off for token/cost usage extraction.
//
// Checks run: unknown name (404) before group authorization (403) before
// rate limits (429/503) before the capability/path checks (Upgrade→501,
// traversal→400) that gate the proxy call itself — a caller addressing a
// target that does not exist, or that they may not use, or that their
// group is already over budget on, learns that before any detail of how
// they tried to use it.
// capability/path checks (Upgrade→501, traversal→400) before rate limits
// (429/503) — matching handlePassthrough's own order (403 → 501 → 400 →
// limit), with the unknown-name check slotted in first since a target
// proxy request can fail that in a way native passthrough never can. A
// request rejected on any check before checkAndCount must never burn the
// caller's or their group's rate-limit quota for a call that was never
// going to reach the upstream.
func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *user, grp *group, kind, name, rest string) {
targetURL, allowed, known := g.resolveTarget(kind, name, grp)
if !known {
@@ -155,12 +157,6 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u
return
}
scopes := buildLimitScopes(u, grp)
if violation := g.limiter.checkAndCount(scopes); violation != nil {
writeLimitViolation(w, violation)
return
}
if r.Header.Get("Upgrade") != "" {
writeOAIError(w, http.StatusNotImplemented, "invalid_request_error", "websocket/upgrade passthrough not supported")
return
@@ -170,6 +166,12 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u
return
}
scopes := buildLimitScopes(u, grp)
if violation := g.limiter.checkAndCount(scopes); violation != nil {
writeLimitViolation(w, violation)
return
}
upstreamURL := targetURL
if rest != "" {
upstreamURL += "/" + rest