Files
healthlog/internal/httpapi/ingest.go
T
av cd7a4c1493 отработаны находки ревью кода
Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера,
4 «сейчас», 2 развилки. Все закрыты регрессионными тестами.

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

Четыре развилки вынесены блокерами в беклог.
2026-08-01 19:02:05 +03:00

160 lines
6.6 KiB
Go

package httpapi
import (
"compress/gzip"
"errors"
"io"
"net/http"
"strings"
"git.vakhrushev.me/av/healthlog/internal/ingest"
)
// ingestResponse — что видит Health Auto Export в ответ на принятый пакет.
type ingestResponse struct {
DeliveryID string `json:"delivery_id"`
Bytes int64 `json:"bytes"`
SHA256 string `json:"sha256"`
}
// handleIngest принимает пакет от Health Auto Export.
//
// Код ответа отражает ДОСТАВКУ, а не разбор: 200 означает «тело сохранено в
// архив», и этого достаточно, потому что разобрать сохранённое можно всегда.
func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
body, err := readBody(w, r, a.maxBody)
if err != nil {
writeReadError(w, err)
return
}
res, err := a.ingest.Accept(r.Context(), body, a.metaFromHeaders(r))
if err != nil {
// Ошибку уже залогировал use-case; транспорт только переводит её в ответ.
if errors.Is(err, ingest.ErrMalformed) {
writeError(w, http.StatusBadRequest, "некорректный формат пакета")
return
}
writeError(w, http.StatusInternalServerError, "внутренняя ошибка")
return
}
writeJSON(w, http.StatusOK, ingestResponse{
DeliveryID: res.DeliveryID,
Bytes: res.Bytes,
SHA256: res.SHA256,
})
}
// metaFromHeaders достаёт то, что автоматизация Health Auto Export
// рассказывает о себе своими заголовками. Именованные поля — те, по которым
// ходят запросы; полный набор кладётся рядом, потому что документация HAE
// заведомо неполна.
func (a *api) metaFromHeaders(r *http.Request) ingest.Meta {
return ingest.Meta{
AutomationName: r.Header.Get("automation-name"),
AutomationID: r.Header.Get("automation-id"),
Aggregation: r.Header.Get("automation-aggregation"),
Period: r.Header.Get("automation-period"),
SessionID: r.Header.Get("session-id"),
Headers: a.safeHeaders(r),
}
}
// secretHeaders — заголовки, которые не сохраняем никогда, как бы ни был
// настроен отправитель.
var secretHeaders = map[string]bool{
"authorization": true,
"proxy-authorization": true,
"cookie": true,
"x-api-key": true,
"api-key": true,
"x-auth-token": true,
}
// redacted подменяет значение, которое оказалось секретом.
const redacted = "[redacted]"
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
//
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
// совпавшее с настроенным токеном, подменяется — токен можно положить в
// заголовок с произвольным именем, и угадать его мы не можем.
func (a *api) safeHeaders(r *http.Request) map[string][]string {
out := make(map[string][]string, len(r.Header)+1)
for name, values := range r.Header {
if secretHeaders[strings.ToLower(name)] {
out[name] = []string{redacted}
continue
}
safe := make([]string, len(values))
for i, v := range values {
if tokenAllowed(v, a.writeTokens) || tokenAllowed(strings.TrimPrefix(v, "Bearer "), a.writeTokens) {
safe[i] = redacted
continue
}
safe[i] = v
}
out[name] = safe
}
// Host в r.Header не попадает — а знать, на какое имя пришёл запрос,
// полезно, когда перед сервисом появится прокси.
if r.Host != "" {
out["Host"] = []string{r.Host}
}
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)
if strings.Contains(strings.ToLower(r.Header.Get("Content-Encoding")), "gzip") {
gz, err := gzip.NewReader(src)
if err != nil {
return nil, errBadGzip
}
defer func() { _ = gz.Close() }()
// Лишний байт — способ отличить «ровно по границе» от «больше неё»,
// не читая всё до конца.
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")
errTooLargeUnpacked = errors.New("распакованное тело больше допустимого размера")
)
func writeReadError(w http.ResponseWriter, err error) {
var tooLarge *http.MaxBytesError
switch {
case errors.As(err, &tooLarge), errors.Is(err, errTooLargeUnpacked):
writeError(w, http.StatusRequestEntityTooLarge, "пакет больше допустимого размера")
case errors.Is(err, errBadGzip):
writeError(w, http.StatusBadRequest, "тело не распаковывается как gzip")
default:
writeError(w, http.StatusBadRequest, "не удалось прочитать тело запроса")
}
}