feat(config): advertise concurrency via RateLimit-Policy with autodetect

The gateway now sends the concurrency limit as a RateLimit-Policy
field from draft-ietf-httpapi-ratelimit-headers. The value is
"concurrency";q=<n>;qu="concurrent-requests". The invented
X-Concurrency-Limit header is gone.

The new concurrencyAutodetect option is off by default. When on,
the gateway learns a limit for each configured provider. A 429, 503,
504 or upstream timeout sets the limit to half the in-flight
requests, with a minimum of 1. Four consecutive successes add 1.
At 64 the limit counts as unknown again.

Responses served via a provider advertise the smaller of
maxConcurrency and the learned value. An explicit maxConcurrency
always wins. Routing, retry and failover do not change.
This commit is contained in:
2026-10-18 13:44:08 +01:00
parent 3e3517eb62
commit 644b8115c8
7 changed files with 356 additions and 30 deletions
+14 -11
View File
@@ -12,7 +12,6 @@ import (
"net/url"
"os"
"runtime"
"strconv"
"strings"
"sync"
"time"
@@ -184,12 +183,15 @@ type Config struct {
// resolved (404 unknown model) and is not listed by GET /v1/models. It
// removes the sorted-first bare-id collision winner. Defaults to false.
RequireModelPrefix bool `json:"requireModelPrefix,omitempty"`
// MaxConcurrency, when > 0, is advertised on every response as
// X-Concurrency-Limit so clients that honour it (omps) cap their own
// in-flight requests to this gateway. It documents what the upstreams
// can serve at once; the gateway does not queue or reject on it. 0
// (the default) sends no header. Negative values are rejected at
// construction.
// ConcurrencyAutodetect, when true, learns each provider's concurrency
// from overload answers (429/503/504/timeout) and advertises
// min(MaxConcurrency, learned). Defaults to false.
ConcurrencyAutodetect bool `json:"concurrencyAutodetect,omitempty"`
// MaxConcurrency, when > 0, is advertised on every response as a
// RateLimit-Policy concurrent-requests quota
// (draft-ietf-httpapi-ratelimit-headers). The gateway does not queue or
// reject on it. It wins over any learned value. 0 (the default) sends
// nothing. Negative values are rejected at construction.
MaxConcurrency int `json:"maxConcurrency,omitempty"`
}
@@ -789,6 +791,7 @@ type Gateway struct {
teeBudget *teeBudget
fanoutGate chan struct{}
failoverHealth *requestHealthTracker
concurrency *concurrencyLearner
targetHealth *targetHealthTracker
limiter *limiter
registry *modelRegistry
@@ -971,6 +974,9 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
}
g.failover = failoverCfg
g.failoverHealth = newRequestHealthTracker()
if config.ConcurrencyAutodetect {
g.concurrency = newConcurrencyLearner(config.MaxConcurrency)
}
// Security audit run-1: nothing validates the PRODUCT of the two
// retry axes, which compose multiplicatively at runtime. Surface it
@@ -1358,14 +1364,11 @@ func attachUsersFile(auth *authStore, path string, log gatewayLogger) error {
}
// ServeHTTP is the internal router. It grows in later tasks.
// concurrencyLimitHeader advertises Config.MaxConcurrency to clients.
const concurrencyLimitHeader = "X-Concurrency-Limit"
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
sw := &statusTrackingWriter{ResponseWriter: w}
defer recoverPanic(sw, g)
if g.cfg.MaxConcurrency > 0 {
sw.Header().Set(concurrencyLimitHeader, strconv.Itoa(g.cfg.MaxConcurrency))
sw.Header().Set(rateLimitPolicyHeader, concurrencyPolicyValue(g.cfg.MaxConcurrency))
}
// pruneRejectionsAfterReload only runs when maybeReload just performed
// a REAL reload (users_file.go's own doc comment explains why it must