отработаны находки ревью кода

Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера,
4 «сейчас», 2 развилки. Все закрыты регрессионными тестами.

- схема точки сна определяется по самой точке, а не по индексу в исходном
  массиве: одна пропущенная точка меняла эпизод и сводку местами
- доставка сворачивается одной транзакцией: частичное состояние было
  недетерминированным (восемь прогонов — семь состояний)
- граница размера на распакованном теле: 400 КиБ gzip разворачивались в
  400 МиБ мимо max_body_mb
- столкновение — расхождение канонических форм, а не байтов; WARN с
  координатами объекта; payload без HTML-экранирования
- выравнивание по местной метке: получасовые зоны уводили часовую выгрузку
  в minute
- доставка из одних суточных сводок больше не отвергается целиком
- единицы не переписываются молча; счётчик считает сохранённые точки
- все выходы Fold логируются, исход пишется на переживающем отмену контексте

Четыре развилки вынесены блокерами в беклог.
This commit is contained in:
av
2026-08-01 19:02:05 +03:00
parent 32f21044e4
commit cd7a4c1493
23 changed files with 1340 additions and 175 deletions
+23 -3
View File
@@ -105,6 +105,18 @@ func (a *api) safeHeaders(r *http.Request) map[string][]string {
return out
}
// readBody читает тело запроса, при необходимости распаковывая gzip.
//
// Граница стоит на РАСПАКОВАННОМ потоке, а не только на сжатом. Иначе
// `MaxBytesReader` ограничивает то, что приехало по сети, а в память попадает
// то, что из этого развернулось: измерено — 400 КиБ сжатого тела дают 400 МиБ,
// принимаются со статусом 200 и выедают гигабайт. Потолок степени сжатия gzip
// около 1030:1, так что при штатных 64 МиБ речь о десятках гигабайт, и
// параллельные запросы складываются.
//
// Цена отказа здесь наивысшая в проекте: приём — единственное место, где поток
// вообще существует. Доставка, не попавшая в архив, не попадает и в журнал —
// телефон её не перешлёт.
func readBody(w http.ResponseWriter, r *http.Request, maxBody int64) ([]byte, error) {
var src io.Reader = http.MaxBytesReader(w, r.Body, maxBody)
@@ -114,22 +126,30 @@ func readBody(w http.ResponseWriter, r *http.Request, maxBody int64) ([]byte, er
return nil, errBadGzip
}
defer func() { _ = gz.Close() }()
src = gz
// Лишний байт — способ отличить «ровно по границе» от «больше неё»,
// не читая всё до конца.
src = io.LimitReader(gz, maxBody+1)
}
body, err := io.ReadAll(src)
if err != nil {
return nil, err //nolint:wrapcheck // разбирается writeReadError по типу
}
if int64(len(body)) > maxBody {
return nil, errTooLargeUnpacked
}
return body, nil
}
var errBadGzip = errors.New("тело не распаковывается как gzip")
var (
errBadGzip = errors.New("тело не распаковывается как gzip")
errTooLargeUnpacked = errors.New("распакованное тело больше допустимого размера")
)
func writeReadError(w http.ResponseWriter, err error) {
var tooLarge *http.MaxBytesError
switch {
case errors.As(err, &tooLarge):
case errors.As(err, &tooLarge), errors.Is(err, errTooLargeUnpacked):
writeError(w, http.StatusRequestEntityTooLarge, "пакет больше допустимого размера")
case errors.Is(err, errBadGzip):
writeError(w, http.StatusBadRequest, "тело не распаковывается как gzip")