Files
grendervill 4c3736b5fb feat(instance): метрики и healthcheck инстанса для дашборда
Раздел «Инстанс» получает живые показатели сервера (AGENT.md 7.19).

- `internal/sysinfo`: загрузка процессора по cgroup v2 (`cpu.stat`/`cpu.max`)
  с откатом на `/proc/stat`, память по лимиту контейнера либо `/proc/meminfo`,
  место на разделе данных и размер базы вместе с журналом WAL;
- `GET /api/v1/instance/metrics` (только администратор инстанса): метрики,
  состояние (`ok|warn|failed`), проверки app/database/disk/storage/voice/
  gateway, версия и время работы; недоступные источники не ломают ответ, а
  попадают в проверку `metrics`;
- тяжёлые проверки (БД, SFU, запись) кэшируются на 5 секунд: ручку можно
  опрашивать раз в секунду;
- `voice.IssueRoomList` и `AdminClient.Ping` проверяют доступность RoomService
  и валидность ключей LiveKit (та же связка, что ломала модерацию);
- у метрик свой лимит частоты, они исключены из общего лимита API
  (`RateLimiter.MiddlewareExcept`), иначе открытый раздел съедал бы половину
  бюджета запросов;
- тесты: `internal/sysinfo` (cgroup, /proc, память, диск, размеры файлов) и
  `internal/server/api_metrics_test.go` (доступ, состав метрик, кэш, 429).
2026-09-20 21:26:17 +03:00

170 lines
5.0 KiB
Go

package httpx
import (
"net/http"
"strconv"
"sync"
"time"
)
// RateLimiter — ограничитель частоты запросов по ключу (IP или пользователь).
// Реализация — token bucket без внешних зависимостей: 1 vCPU инстансу
// достаточно простого счётчика в памяти (AGENT.md 8.6).
type RateLimiter struct {
mu sync.Mutex
buckets map[string]*rateBucket
// rate — токенов в секунду, burst — максимальный запас.
rate float64
burst float64
// now подменяется в тестах, чтобы не ждать реального времени.
now func() time.Time
}
type rateBucket struct {
tokens float64
seen time.Time
}
// NewRateLimiter создаёт лимитер: perMinute запросов в минуту с запасом burst.
func NewRateLimiter(perMinute, burst int) *RateLimiter {
if perMinute <= 0 {
perMinute = 60
}
if burst <= 0 {
burst = perMinute
}
return NewRateLimiterWindow(perMinute, time.Minute, burst)
}
// NewRateLimiterWindow создаёт лимитер на произвольное окно: нужно для
// суточных квот (например, 10 приглашений в день, AGENT.md 8.6).
func NewRateLimiterWindow(limit int, window time.Duration, burst int) *RateLimiter {
if limit <= 0 {
limit = 1
}
if window <= 0 {
window = time.Minute
}
if burst <= 0 {
burst = limit
}
return &RateLimiter{
buckets: map[string]*rateBucket{},
rate: float64(limit) / window.Seconds(),
burst: float64(burst),
now: time.Now,
}
}
// SetClock подменяет источник времени (тесты).
func (l *RateLimiter) SetClock(now func() time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
l.now = now
}
// Allow проверяет запрос и возвращает паузу до следующей попытки.
func (l *RateLimiter) Allow(key string) (bool, time.Duration) {
if key == "" {
key = "unknown"
}
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
bucket, ok := l.buckets[key]
if !ok {
// Попутная уборка: словарь не должен расти бесконечно (AGENT.md 9.1).
if len(l.buckets) > 4096 {
l.sweepLocked(now)
}
bucket = &rateBucket{tokens: l.burst, seen: now}
l.buckets[key] = bucket
}
elapsed := now.Sub(bucket.seen).Seconds()
if elapsed > 0 {
bucket.tokens = minFloat(l.burst, bucket.tokens+elapsed*l.rate)
}
bucket.seen = now
if bucket.tokens < 1 {
missing := 1 - bucket.tokens
retryAfter := time.Duration(missing / l.rate * float64(time.Second))
if retryAfter < time.Second {
retryAfter = time.Second
}
return false, retryAfter
}
bucket.tokens--
return true, 0
}
// sweepLocked удаляет записи, неактивные больше десяти минут.
func (l *RateLimiter) sweepLocked(now time.Time) {
cutoff := now.Add(-10 * time.Minute)
for key, bucket := range l.buckets {
if bucket.seen.Before(cutoff) {
delete(l.buckets, key)
}
}
}
// Middleware ограничивает частоту запросов по ключу keyFn.
func (l *RateLimiter) Middleware(keyFn func(*http.Request) string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
allowed, retryAfter := l.Allow(keyFn(r))
if !allowed {
WriteRateLimited(w, retryAfter)
return
}
next.ServeHTTP(w, r)
})
}
}
// MiddlewareExcept ограничивает частоту запросов везде, кроме перечисленных
// путей: у живого дашборда инстанса свой лимит, иначе опрос раз в секунду
// съедал бы половину общего бюджета API (AGENT.md 8.6).
func (l *RateLimiter) MiddlewareExcept(keyFn func(*http.Request) string, except ...string) Middleware {
skip := make(map[string]struct{}, len(except))
for _, path := range except {
skip[path] = struct{}{}
}
limited := l.Middleware(keyFn)
return func(next http.Handler) http.Handler {
handler := limited(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := skip[r.URL.Path]; ok {
next.ServeHTTP(w, r)
return
}
handler.ServeHTTP(w, r)
})
}
}
// WriteRateLimited отдаёт 429 в конверте API с подсказкой по паузе (§8.5, §8.6).
func WriteRateLimited(w http.ResponseWriter, retryAfter time.Duration) {
milliseconds := retryAfter.Milliseconds()
if milliseconds <= 0 {
milliseconds = 1000
}
w.Header().Set("Retry-After", strconv.FormatInt((milliseconds+999)/1000, 10))
WriteJSON(w, http.StatusTooManyRequests, map[string]any{
"error": map[string]any{
"code": "rate_limited",
"message": "too many requests",
"retry_after_ms": milliseconds,
},
})
}
func minFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}