отработаны находки ревью кода
Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки. Все закрыты регрессионными тестами. - схема точки сна определяется по самой точке, а не по индексу в исходном массиве: одна пропущенная точка меняла эпизод и сводку местами - доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (восемь прогонов — семь состояний) - граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо max_body_mb - столкновение — расхождение канонических форм, а не байтов; WARN с координатами объекта; payload без HTML-экранирования - выравнивание по местной метке: получасовые зоны уводили часовую выгрузку в minute - доставка из одних суточных сводок больше не отвергается целиком - единицы не переписываются молча; счётчик считает сохранённые точки - все выходы Fold логируются, исход пишется на переживающем отмену контексте Четыре развилки вынесены блокерами в беклог.
This commit is contained in:
@@ -179,6 +179,46 @@ func TestIngestRejectsOversizedBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Граница обязана стоять на РАСПАКОВАННОМ теле, а не только на сжатом.
|
||||
//
|
||||
// Измерено на прежней версии: 400 КиБ сжатого тела разворачивались в 400 МиБ,
|
||||
// принимались со статусом 200 и выедали гигабайт. При потолке степени сжатия
|
||||
// gzip около 1030:1 и штатных 64 МиБ речь о десятках гигабайт на запрос, а
|
||||
// параллельные складываются. Цена отказа здесь наивысшая в проекте: приём —
|
||||
// единственное место, где поток существует, и доставка, не попавшая в архив,
|
||||
// не попадает в журнал вовсе.
|
||||
func TestIngestRejectsGzipBomb(t *testing.T) {
|
||||
h, st := newAPI(t, nil)
|
||||
|
||||
// Лимит в тесте — 1 МиБ (см. newAPI). Тело валидное, просто очень длинное
|
||||
// и сжимается почти нацело.
|
||||
payload := `{"data":{"metrics":[]},"pad":"` + strings.Repeat(" ", 8<<20) + `"}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write([]byte(payload)); err != nil {
|
||||
t.Fatalf("подготовка gzip: %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatalf("подготовка gzip: %v", err)
|
||||
}
|
||||
if buf.Len() >= 1<<20 {
|
||||
t.Fatalf("сжатое тело %d байт — уже больше лимита, тест проверяет не то", buf.Len())
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", &buf)
|
||||
req.Header.Set("Content-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("статус = %d, ожидался 413: распакованное тело больше лимита", rec.Code)
|
||||
}
|
||||
if n, _ := st.CountDeliveries(t.Context()); n != 0 { //nolint:errcheck // проверяем только счётчик
|
||||
t.Errorf("доставок = %d, ожидалось 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestChecksTokenWhenConfigured(t *testing.T) {
|
||||
h, _ := newAPI(t, []string{"верный-токен"})
|
||||
|
||||
@@ -236,7 +276,7 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
h := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, fold.New(arch, st, log), log),
|
||||
Ingest: ingest.New(arch, st, fold.New(arch, st, 0, log), log),
|
||||
Log: log,
|
||||
WriteTokens: writeTokens,
|
||||
MaxBodyMB: 1,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user