Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10.
|
||||
// Остальные лимиты — этап 6.
|
||||
const (
|
||||
messagesPerMinute = 30
|
||||
messagesBurst = 10
|
||||
)
|
||||
|
||||
// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра.
|
||||
const sweepAt = 1024
|
||||
|
||||
// buckets — token bucket в памяти сервера, по ведру на ключ (ник).
|
||||
// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021).
|
||||
type buckets struct {
|
||||
mu sync.Mutex
|
||||
rate float64 // токенов в секунду
|
||||
burst float64
|
||||
seen map[string]*bucket
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newBuckets(perMinute, burst int) *buckets {
|
||||
return &buckets{
|
||||
rate: float64(perMinute) / 60,
|
||||
burst: float64(burst),
|
||||
seen: make(map[string]*bucket),
|
||||
}
|
||||
}
|
||||
|
||||
// take забирает токен. Второе значение — можно ли; если нет, первое —
|
||||
// сколько ждать до следующего токена.
|
||||
func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
e, ok := b.seen[key]
|
||||
if !ok {
|
||||
if len(b.seen) >= sweepAt {
|
||||
b.sweep(now)
|
||||
}
|
||||
e = &bucket{tokens: b.burst, at: now}
|
||||
b.seen[key] = e
|
||||
}
|
||||
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
|
||||
e.at = now
|
||||
if e.tokens < 1 {
|
||||
return time.Duration((1 - e.tokens) / b.rate * float64(time.Second)), false
|
||||
}
|
||||
e.tokens--
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// refill — сколько токенов набежало. Время назад не идёт: часы могли
|
||||
// прыгнуть, но долг за это выставлять некому.
|
||||
func (b *buckets) refill(since, now time.Time) float64 {
|
||||
d := now.Sub(since)
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Seconds() * b.rate
|
||||
}
|
||||
|
||||
// sweep выкидывает полные вёдра: они уже ничего не помнят. Иначе карта
|
||||
// росла бы на каждый новый ник и не уменьшалась никогда.
|
||||
func (b *buckets) sweep(now time.Time) {
|
||||
for key, e := range b.seen {
|
||||
if e.tokens+b.refill(e.at, now) >= b.burst {
|
||||
delete(b.seen, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
|
||||
// ожидание после отказа сбивало бы клиента с толку.
|
||||
func retryAfter(wait time.Duration) int {
|
||||
if wait < time.Second {
|
||||
return 1
|
||||
}
|
||||
return int(math.Ceil(wait.Seconds()))
|
||||
}
|
||||
Reference in New Issue
Block a user