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:
@@ -76,8 +76,13 @@ type Account struct {
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// foreign keys of a shared finished game stay valid (Stage 11).
|
||||
MergedInto uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||
// zero time for an unflagged account, otherwise when the gateway-reported
|
||||
// rate-limiter rejections first crossed the sustained threshold (R3). An
|
||||
// operator clears it in the admin console; it never gates any request.
|
||||
FlaggedHighRateAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Identity is one of an account's platform/email identities, surfaced on the
|
||||
@@ -422,6 +427,43 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
|
||||
// the account is not already flagged — the first sustained episode wins, and a
|
||||
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
|
||||
// a profile edit, so updated_at is untouched; it never gates any request (R3).
|
||||
// It reports whether the flag was newly set.
|
||||
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||
SET(postgres.TimestampzT(at.UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(id)).
|
||||
AND(table.Accounts.FlaggedHighRateAt.IS_NULL()),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: flag high rate %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: flag high rate rows %s: %w", id, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ClearHighRateFlag removes the high-rate marker — the operator's reversible
|
||||
// action in the admin console. Clearing an unflagged account is a no-op.
|
||||
func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||
SET(postgres.NULL).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: clear high-rate flag %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
||||
// user authenticated through. It is called on every Telegram login — new and
|
||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
||||
@@ -452,6 +494,10 @@ func modelToAccount(row model.Accounts) Account {
|
||||
if row.ServiceLanguage != nil {
|
||||
serviceLanguage = *row.ServiceLanguage
|
||||
}
|
||||
var flaggedHighRateAt time.Time
|
||||
if row.FlaggedHighRateAt != nil {
|
||||
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||
}
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
@@ -467,6 +513,7 @@ func modelToAccount(row model.Accounts) Account {
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
FlaggedHighRateAt: flaggedHighRateAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user