Files
nmon/internal/api/admit.go
jleung f85c3ef781
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
M3: Go API per UI contract, embedded UI, openapi compat check, nmon-devtools, live gate + screenshots; M4 WP1 reachability and dependency suppression
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 20:34:14 +00:00

100 lines
2.6 KiB
Go

package api
import (
"context"
"net"
"sync"
"time"
"github.com/danielgtaylor/huma/v2"
)
// admission keeps metric-store query load bounded: a per-client cap on
// free-form queries (so one dashboard cannot starve the rest) and a global
// semaphore on every store query (so the UI cannot crowd out the poll path).
// A request over its client cap is refused at once; one waiting for a global
// slot waits up to wait, then is refused. Both are 429 problems.
type admission struct {
global chan struct{}
perClient int
wait time.Duration
mu sync.Mutex
active map[string]int
}
func newAdmission(perClient, global int, wait time.Duration) *admission {
return &admission{global: make(chan struct{}, global), perClient: perClient, wait: wait, active: map[string]int{}}
}
// clientKey carries the caller's identity from the middleware to the handlers.
// Until authentication exists (M6) it is the remote address without the port.
type clientKey struct{}
func clientKeyMiddleware(ctx huma.Context, next func(huma.Context)) {
host, _, err := net.SplitHostPort(ctx.RemoteAddr())
if err != nil {
host = ctx.RemoteAddr()
}
next(huma.WithValue(ctx, clientKey{}, host))
}
func clientOf(ctx context.Context) string {
c, _ := ctx.Value(clientKey{}).(string)
return c
}
func tooMany(msg string) error {
return huma.Error429TooManyRequests(msg)
}
// acquire admits one query. freeForm queries also count against the client's cap.
// The returned release must be called exactly once.
func (a *admission) acquire(ctx context.Context, freeForm bool) (release func(), err error) {
client := clientOf(ctx)
if freeForm && !a.takeClient(client) {
return nil, tooMany("too many concurrent queries from " + client + "; the limit is " + itoa(a.perClient))
}
wctx, cancel := context.WithTimeout(ctx, a.wait)
defer cancel()
select {
case a.global <- struct{}{}:
case <-wctx.Done():
if freeForm {
a.dropClient(client)
}
return nil, tooMany("the metric store is busy; try again shortly")
}
return func() {
<-a.global
if freeForm {
a.dropClient(client)
}
}, nil
}
func (a *admission) takeClient(client string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.active[client] >= a.perClient {
return false
}
a.active[client]++
return true
}
func (a *admission) dropClient(client string) {
a.mu.Lock()
defer a.mu.Unlock()
if a.active[client] <= 1 {
delete(a.active, client)
return
}
a.active[client]--
}
// admit takes a query slot for the request in ctx.
func (s *server) admit(ctx context.Context, freeForm bool) (func(), error) {
return s.adm.acquire(ctx, freeForm)
}