R3: backend rate-limit observability — ratewatch, auto-flag, admin throttled view
- accounts.flagged_high_rate_at baked into the R1 baseline (no prod data; the contour schema is wiped after merge); jet regenerated — the regen also picks up the previously missing game_drafts/game_hidden models. - account.Store: FlagHighRate (set-once), ClearHighRateFlag, the flag in GetByID/ListUsers and a ListFlaggedHighRate review queue. - New internal/ratewatch: ingests the gateway rejection reports, keeps a bounded in-memory episode window for the console and applies the conservative auto-flag (1000 rejected / 10 min, BACKEND_HIGHRATE_FLAG_*). - POST /api/v1/internal/ratelimit/report (network-trusted, like sessions/resolve). - Admin console: Throttled page (episodes + flagged accounts), a high-rate badge in the user list, the marker + operator clear action on the user card. - Tests: ratewatch unit suite, report-route handler test, renderer cases, integration coverage for the store round-trip and the console flow.
This commit is contained in:
@@ -37,6 +37,11 @@ func (s *Server) registerRoutes() {
|
||||
// before delivering an out-of-app notification.
|
||||
in.POST("/push-target", s.handlePushTarget)
|
||||
}
|
||||
if s.ratewatch != nil {
|
||||
// The gateway's periodic rate-limiter rejection summary (R3): feeds the
|
||||
// admin console's throttled view and the high-rate auto-flag.
|
||||
s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
|
||||
}
|
||||
u := s.user
|
||||
if s.accounts != nil {
|
||||
u.GET("/profile", s.handleProfile)
|
||||
@@ -120,10 +125,8 @@ func gameIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
// X-Forwarded-For (the first hop), falling back to the direct peer.
|
||||
func clientIP(c *gin.Context) string {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
first, _, _ := strings.Cut(xff, ",")
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"scrabble/backend/internal/adminconsole"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
@@ -48,6 +49,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.GET("/users", s.consoleUsers)
|
||||
gm.GET("/users/:id", s.consoleUserDetail)
|
||||
gm.POST("/users/:id/message", s.consoleUserMessage)
|
||||
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
|
||||
gm.GET("/throttled", s.consoleThrottled)
|
||||
gm.GET("/games", s.consoleGames)
|
||||
gm.GET("/games/:id", s.consoleGameDetail)
|
||||
gm.GET("/complaints", s.consoleComplaints)
|
||||
@@ -117,7 +120,8 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
||||
}
|
||||
view.Items = append(view.Items, adminconsole.UserRow{
|
||||
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
||||
Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt),
|
||||
Language: it.PreferredLanguage, Guest: it.IsGuest,
|
||||
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
|
||||
})
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
@@ -257,6 +261,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
if acc.MergedInto != uuid.Nil {
|
||||
view.MergedInto = acc.MergedInto.String()
|
||||
}
|
||||
if !acc.FlaggedHighRateAt.IsZero() {
|
||||
view.FlaggedHighRateAt = fmtTime(acc.FlaggedHighRateAt)
|
||||
}
|
||||
if view.HasStats {
|
||||
if st, err := s.accounts.GetStats(ctx, id); err == nil {
|
||||
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints}
|
||||
@@ -551,6 +558,56 @@ func (s *Server) consolePostBroadcast(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// consoleThrottled renders the rate-limit observability page: the recent
|
||||
// gateway-reported throttle episodes (in-memory, reset on a backend restart)
|
||||
// and the accounts currently carrying the soft high-rate flag (R3).
|
||||
func (s *Server) consoleThrottled(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var view adminconsole.ThrottledView
|
||||
if s.ratewatch != nil {
|
||||
cfg := s.ratewatch.Config()
|
||||
view.FlagThreshold = cfg.FlagThreshold
|
||||
view.FlagWindow = cfg.FlagWindow.String()
|
||||
for _, ep := range s.ratewatch.Recent() {
|
||||
row := adminconsole.ThrottleEpisodeRow{
|
||||
Class: ep.Class, Key: ep.Key, Rejected: ep.Rejected,
|
||||
FirstSeen: fmtTime(ep.FirstSeen), LastSeen: fmtTime(ep.LastSeen),
|
||||
}
|
||||
if ep.Class == ratewatch.ClassUser {
|
||||
if id, err := uuid.Parse(ep.Key); err == nil {
|
||||
row.UserID = id.String()
|
||||
}
|
||||
}
|
||||
view.Episodes = append(view.Episodes, row)
|
||||
}
|
||||
}
|
||||
flagged, err := s.accounts.ListFlaggedHighRate(ctx)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
for _, fa := range flagged {
|
||||
view.Flagged = append(view.Flagged, adminconsole.FlaggedAccountRow{
|
||||
ID: fa.ID.String(), DisplayName: fa.DisplayName, FlaggedAt: fmtTime(fa.FlaggedHighRateAt),
|
||||
})
|
||||
}
|
||||
s.renderConsole(c, "throttled", "throttled", "Throttled", view)
|
||||
}
|
||||
|
||||
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
|
||||
// reversible review action (R3).
|
||||
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.accounts.ClearHighRateFlag(c.Request.Context(), id); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
|
||||
}
|
||||
|
||||
// variantVersions builds the per-variant resident-version summary from the registry.
|
||||
func (s *Server) variantVersions() []adminconsole.VariantVersions {
|
||||
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
)
|
||||
|
||||
// rateLimitReportRequest mirrors the gateway's periodic rejection summary: every
|
||||
// entry aggregates one limiter key (class + key) over the report window.
|
||||
type rateLimitReportRequest struct {
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
Entries []rateLimitReportEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// rateLimitReportEntry is one (class, key) aggregate of the report.
|
||||
type rateLimitReportEntry struct {
|
||||
Class string `json:"class"`
|
||||
Key string `json:"key"`
|
||||
Rejected int `json:"rejected"`
|
||||
}
|
||||
|
||||
// handleRateLimitReport ingests one gateway rejection report into the rate
|
||||
// watch — the admin console's throttled view and the high-rate auto-flag (R3).
|
||||
// Internal, gateway-only: like sessions/resolve it trusts the network segment.
|
||||
// Malformed individual entries are skipped by the watch itself.
|
||||
func (s *Server) handleRateLimitReport(c *gin.Context) {
|
||||
var req rateLimitReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid rate-limit report")
|
||||
return
|
||||
}
|
||||
entries := make([]ratewatch.Entry, 0, len(req.Entries))
|
||||
for _, e := range req.Entries {
|
||||
entries = append(entries, ratewatch.Entry{Class: e.Class, Key: e.Key, Rejected: e.Rejected})
|
||||
}
|
||||
s.ratewatch.Ingest(c.Request.Context(), entries)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
@@ -57,6 +58,23 @@ func TestResolveSessionRejectsEmptyToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitReportEndpoint covers the internal R3 report route: a malformed
|
||||
// body is a 400, a valid report lands in the rate watch with 204.
|
||||
func TestRateLimitReportEndpoint(t *testing.T) {
|
||||
watch := ratewatch.New(ratewatch.DefaultConfig(), nil, nil)
|
||||
s := New(":0", Deps{RateWatch: watch})
|
||||
if rec := do(t, s, http.MethodPost, "/api/v1/internal/ratelimit/report", `{bad`, nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("malformed report = %d, want 400", rec.Code)
|
||||
}
|
||||
body := `{"window_seconds":30,"entries":[{"class":"user","key":"` + uuid.NewString() + `","rejected":7}]}`
|
||||
if rec := do(t, s, http.MethodPost, "/api/v1/internal/ratelimit/report", body, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("report = %d, want 204", rec.Code)
|
||||
}
|
||||
if eps := watch.Recent(); len(eps) != 1 || eps[0].Rejected != 7 {
|
||||
t.Fatalf("watch episodes = %+v, want one entry with rejected=7", eps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPlayRejectsBadDirection(t *testing.T) {
|
||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||
path := "/api/v1/user/games/" + uuid.New().String() + "/play"
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
@@ -71,6 +72,10 @@ type Deps struct {
|
||||
// nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured"
|
||||
// notice).
|
||||
Connector *connector.Client
|
||||
// RateWatch ingests the gateway's rate-limiter rejection reports (R3): the
|
||||
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
||||
// disables the internal report endpoint and the console view.
|
||||
RateWatch *ratewatch.Watch
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -93,6 +98,7 @@ type Server struct {
|
||||
registry *engine.Registry
|
||||
dictDir string
|
||||
connector *connector.Client
|
||||
ratewatch *ratewatch.Watch
|
||||
console *adminconsole.Renderer
|
||||
|
||||
public *gin.RouterGroup
|
||||
@@ -133,6 +139,7 @@ func New(addr string, deps Deps) *Server {
|
||||
registry: deps.Registry,
|
||||
dictDir: deps.DictDir,
|
||||
connector: deps.Connector,
|
||||
ratewatch: deps.RateWatch,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
}
|
||||
s.registerProbes(engine)
|
||||
|
||||
Reference in New Issue
Block a user