Files
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:23:59 +07:00

101 lines
2.9 KiB
Go

package httpapi
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
)
const requestBodyLimit int64 = 8 << 20
// withRequestBodyLimit must run inside authMiddleware. Reading the complete
// bounded body before dispatch prevents a valid JSON prefix from committing a
// mutation before a later size/read error is discovered. Content-Type is not a
// security boundary; CSV retains its additional row/field checks in its handler.
func withRequestBodyLimit(next http.Handler) http.Handler {
return withBodyLimitAndConcurrency(requestBodyLimit, requestBodyConcurrency(), next)
}
func withBodyLimit(limit int64, next http.Handler) http.Handler {
return withBodyLimitAndConcurrency(limit, 4, next)
}
func requestBodyConcurrency() int {
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv("EVOBGP_API_BODY_MAX_CONCURRENT")))
if err == nil && n >= 1 && n <= 64 {
return n
}
return 4
}
func withBodyLimitAndConcurrency(limit int64, concurrency int, next http.Handler) http.Handler {
slots := make(chan struct{}, concurrency)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
original := r.Body
if original != nil {
defer func() { _ = original.Close() }()
}
if r.ContentLength > limit {
writeRequestBodyTooLarge(w)
return
}
if r.Context().Err() != nil {
writeProblem(w, http.StatusRequestTimeout, "Request Timeout", "request body read cancelled")
return
}
if r.Body == nil || r.Body == http.NoBody {
next.ServeHTTP(w, r)
return
}
select {
case slots <- struct{}{}:
defer func() { <-slots }()
default:
w.Header().Set("Retry-After", "1")
writeProblem(w, http.StatusTooManyRequests, "Too Many Requests", "request body processing capacity is full")
return
}
bounded := http.MaxBytesReader(w, r.Body, limit)
data, err := io.ReadAll(requestContextReader{ctx: r.Context(), reader: bounded})
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeRequestBodyTooLarge(w)
return
}
var timeout net.Error
if r.Context().Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &timeout) && timeout.Timeout()) {
writeProblem(w, http.StatusRequestTimeout, "Request Timeout", "request body read cancelled")
return
}
if err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "could not read request body")
return
}
r.Body = io.NopCloser(bytes.NewReader(data))
r.ContentLength = int64(len(data))
next.ServeHTTP(w, r)
})
}
func writeRequestBodyTooLarge(w http.ResponseWriter) {
writeProblem(w, http.StatusRequestEntityTooLarge, "Content Too Large", "request body exceeds the 8 MiB limit")
}
type requestContextReader struct {
ctx context.Context
reader io.Reader
}
func (r requestContextReader) Read(p []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(p)
}