mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +00:00
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:
+13
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user