- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
145 lines
4.4 KiB
Go
145 lines
4.4 KiB
Go
// Package httpapi — HTTP-транспорт healthlog: приём пакетов и (позже) read API.
|
|
//
|
|
// Транспорт тонкий: разбирает запрос, зовёт use-case, переводит его ошибку в
|
|
// ответ. Исход операции логирует use-case, а не транспорт.
|
|
package httpapi
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
|
)
|
|
|
|
// Options — зависимости и настройки транспорта.
|
|
type Options struct {
|
|
Ingest *ingest.Service
|
|
Log *slog.Logger
|
|
WriteTokens []string
|
|
MaxBodyMB int
|
|
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
|
|
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
|
|
// сервера», и полагаться на него нельзя, см. handleIngest.
|
|
IngestWriteBudget time.Duration
|
|
}
|
|
|
|
type api struct {
|
|
ingest *ingest.Service
|
|
log *slog.Logger
|
|
writeTokens []string
|
|
maxBody int64
|
|
ingestBudget time.Duration
|
|
}
|
|
|
|
// New собирает HTTP-роутер.
|
|
func New(o Options) http.Handler {
|
|
a := &api{
|
|
ingest: o.Ingest,
|
|
log: o.Log,
|
|
writeTokens: o.WriteTokens,
|
|
maxBody: int64(o.MaxBodyMB) << 20,
|
|
ingestBudget: o.IngestWriteBudget,
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(a.accessLog)
|
|
|
|
r.Get("/healthz", a.handleHealthz)
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
r.With(a.requireWriteToken).Post("/ingest", a.handleIngest)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
// requireWriteToken проверяет токен приёма. Пустой список токенов = проверка
|
|
// выключена: локальный запуск в доверенной сети. О выключенной проверке
|
|
// сервис предупреждает на старте.
|
|
func (a *api) requireWriteToken(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if len(a.writeTokens) == 0 {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if !tokenAllowed(bearer(r), a.writeTokens) {
|
|
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func bearer(r *http.Request) string {
|
|
const prefix = "Bearer "
|
|
h := r.Header.Get("Authorization")
|
|
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
|
|
return h[len(prefix):]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// tokenAllowed сравнивает токен за постоянное время: побайтовое сравнение с
|
|
// ранним выходом утекает длину совпадающего префикса.
|
|
func tokenAllowed(got string, allowed []string) bool {
|
|
ok := false
|
|
for _, want := range allowed {
|
|
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
|
|
ok = true
|
|
}
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// accessLog пишет одну запись на запрос. Рутинно-частые эндпоинты
|
|
// (healthcheck) — на DEBUG, чтобы не забивать аудит.
|
|
func (a *api) accessLog(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
|
|
next.ServeHTTP(ww, r)
|
|
|
|
level := slog.LevelInfo
|
|
if r.URL.Path == "/healthz" {
|
|
level = slog.LevelDebug
|
|
}
|
|
a.log.Log(r.Context(), level, "http request",
|
|
"transport", "http",
|
|
"http.method", r.Method,
|
|
"http.route", routePattern(r),
|
|
"http.status_code", ww.Status(),
|
|
"duration_ms", time.Since(start).Milliseconds())
|
|
})
|
|
}
|
|
|
|
func routePattern(r *http.Request) string {
|
|
if rctx := chi.RouteContext(r.Context()); rctx != nil {
|
|
if p := rctx.RoutePattern(); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
return r.URL.Path
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|