api: /v1/batch — submit, status summary, one result, cancel

The HTTP surface over s.Batch. New internal/api/batch.go: batchSubmit (POST)
decodes {"targets":[{url,mode,question,pages,grep,jq,context,recover}]} flat as
/v1/fetch does, 400 "invalid batch" on bad json / empty list / empty url / bad
mode, then PreCheck per target -> 422 "blocked: target <idx>: <reason>" with no
job, then Submit mapping ErrTooManyTargets->422, ErrCallerBusy->429 Retry-After
30, ErrBusy->429 Retry-After 60, ErrNoTargets->400, and 202
{success,job_id,status,targets,reused}. batchStatus (GET), batchResult
(GET {id}/{idx}) and batchCancel (DELETE) share ownership: unknown or not
c.Name -> 404, never 403; status is a summary (no inline results unless
?results=1), idx validated 0..targets_total-1 else 404, cancel of a terminal
job -> 409 else 200 {success,status:cancelled}. api.go scoped to rule 2:
batchMaxBody (256 KiB), the four routes in New, and ServeHTTP applying the
per-path cap once and collapsing every batch path to a fixed metrics label so a
job id never becomes a label. Each handler logs one batch line, never a URL,
hostname or idem_key.

Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
This commit is contained in:
2026-09-26 22:59:05 -07:00
co-authored by ornith-1.5-35b-a3b
parent 72e0828360
commit 692a334c09
4 changed files with 566 additions and 1 deletions
+32 -1
View File
@@ -37,6 +37,10 @@ const (
writeTimeout = 240 * time.Second // P5a: a PDF may take 120 s to download + 20 s queued + 60 s to extract
requestDeadline = 210 * time.Second // P5a: the PDF path's worst case (see writeTimeout); other paths keep their own shorter timeouts
// batchMaxBody is the largest body POST /v1/batch accepts: 32 targets, each with a
// large question, exceed the 64 KiB cap of the other endpoints.
batchMaxBody = 256 << 10
// DefaultPDFMaxUpload is the largest body POST /v1/extract accepts: an uploaded
// PDF, addressed by its hash and extracted once per content.
DefaultPDFMaxUpload = 100 << 20
@@ -108,6 +112,10 @@ func New(version string, g *guard.Guard, f *fetch.Fetcher, keys *auth.Store, log
s.Mux.HandleFunc("POST /v1/extract", s.v1extract)
s.Mux.HandleFunc("POST /v1/search", s.v1search)
s.Mux.HandleFunc("POST /v2/search", s.v2search)
s.Mux.HandleFunc("POST /v1/batch", s.batchSubmit)
s.Mux.HandleFunc("GET /v1/batch/{id}", s.batchStatus)
s.Mux.HandleFunc("GET /v1/batch/{id}/{idx}", s.batchResult)
s.Mux.HandleFunc("DELETE /v1/batch/{id}", s.batchCancel)
return s
}
@@ -125,8 +133,11 @@ func (s *Server) HTTPServer(addr string) *http.Server {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var limit int64 = maxReqBody
if r.URL.Path == "/v1/extract" {
switch r.URL.Path {
case "/v1/extract":
limit = int64(s.pdfMaxUpload())
case "/v1/batch":
limit = batchMaxBody
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
switch r.URL.Path {
@@ -144,9 +155,29 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.Prom.Observe(r.URL.Path, time.Since(start), code)
return
}
if strings.HasPrefix(r.URL.Path, "/v1/batch") {
cw := &codeWriter{ResponseWriter: w}
start := time.Now()
s.Mux.ServeHTTP(cw, r)
code := cw.code
if code == 0 {
code = http.StatusOK
}
s.Prom.Observe(batchObservePath(r.URL.Path), time.Since(start), code)
return
}
s.Mux.ServeHTTP(w, r)
}
// batchObservePath collapses a batch path to a fixed label so a job id never becomes a
// metric label value: the submit path stays, every {id}/{idx} variant becomes {id}.
func batchObservePath(path string) string {
if path == "/v1/batch" {
return "/v1/batch"
}
return "/v1/batch/{id}"
}
type codeWriter struct {
http.ResponseWriter
code int