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
+253
View File
@@ -2,8 +2,11 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -111,3 +114,253 @@ func batchOutcome(err error) string {
}
return "upstream_5xx"
}
// The four /v1/batch handlers (task 07). A job that is unknown or owned by another caller is a
// 404 for every GET and DELETE, never a 403, so a caller cannot probe for another caller's jobs.
// ownership answers the caller for id: a disabled batch is 503, an unknown id or one not owned by
// c is a 404. The empty job returned on either path signals "already answered" to the caller.
func (s *Server) ownership(w http.ResponseWriter, c *auth.Caller, id string) (batch.Job, []batch.TargetStatus) {
if s.Batch == nil {
writeErr(w, 503, "batch disabled", "")
return batch.Job{}, nil
}
j, ts, ok := s.Batch.Status(id)
if !ok || j.Caller != c.Name {
writeErr(w, 404, "not found", "")
return batch.Job{}, nil
}
return j, ts
}
// logBatch writes the one batch line per handler: caller, job_id, targets, then either status or
// reused. It never carries a target URL, hostname or the idem_key.
func (s *Server) logBatch(c *auth.Caller, jobID string, targets int, extra ...any) {
s.Log.Info("batch",
append([]any{
"caller", c.Name,
"job_id", jobID,
"targets", targets,
}, extra...)...,
)
}
func validBatchMode(m string) bool {
switch m {
case "", "clean", "raw", "digest":
return true
}
return false
}
func (s *Server) batchSubmit(w http.ResponseWriter, r *http.Request) {
c := s.caller(w, r)
if c == nil {
return
}
var req struct {
Targets []v1Req `json:"targets"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "invalid batch: bad json", "")
return
}
if len(req.Targets) == 0 {
writeErr(w, 400, "invalid batch: no targets", "")
return
}
targets := make([]batch.Target, 0, len(req.Targets))
for _, t := range req.Targets {
if t.URL == "" {
writeErr(w, 400, "invalid batch: empty url", "")
return
}
if !validBatchMode(t.Mode) {
writeErr(w, 400, "invalid batch: bad mode", "")
return
}
targets = append(targets, batch.Target{
URL: t.URL,
Options: batch.Options{
Mode: t.Mode,
Question: t.Question,
Pages: t.Pages,
Grep: t.Grep,
JQ: t.JQ,
Context: t.Context,
Recover: t.Recover,
},
})
}
for i, t := range targets {
if err := s.Guard.PreCheck(t.URL); err != nil {
s.logBatch(c, "", len(targets), "status", "blocked")
writeErr(w, 422, fmt.Sprintf("blocked: target %d: %v", i, err), "")
return
}
}
id, reused, err := s.Batch.Submit(r.Context(), c, s.callerClass(c), targets)
if err != nil {
s.logBatch(c, "", len(targets), "status", "failed")
switch {
case errors.Is(err, batch.ErrTooManyTargets):
writeErr(w, 422, "too many targets (max 32)", "")
case errors.Is(err, batch.ErrCallerBusy):
w.Header().Set("Retry-After", "30")
writeErr(w, 429, "busy: a batch of yours is still running", "")
case errors.Is(err, batch.ErrBusy):
w.Header().Set("Retry-After", "60")
writeErr(w, 429, "batch jobs busy", "")
case errors.Is(err, batch.ErrNoTargets):
writeErr(w, 400, "invalid batch: no targets", "")
default:
writeErr(w, 500, "internal error", "")
}
return
}
status := "queued"
if reused {
if j, _, ok := s.Batch.Status(id); ok {
status = j.Status
}
}
if reused {
s.logBatch(c, id, len(targets), "reused", true)
} else {
s.logBatch(c, id, len(targets), "status", status)
}
writeJSON(w, 202, map[string]any{
"success": true,
"job_id": id,
"status": status,
"targets": len(targets),
"reused": reused,
})
}
func (s *Server) batchStatus(w http.ResponseWriter, r *http.Request) {
c := s.caller(w, r)
if c == nil {
return
}
id := r.PathValue("id")
j, ts := s.ownership(w, c, id)
if j.ID == "" {
return
}
s.Batch.Touch(id)
s.logBatch(c, id, j.TargetsTotal, "status", j.Status)
writeJSON(w, 200, statusEnvelope(j, ts, r))
}
func (s *Server) batchResult(w http.ResponseWriter, r *http.Request) {
c := s.caller(w, r)
if c == nil {
return
}
id := r.PathValue("id")
j, ts := s.ownership(w, c, id)
if j.ID == "" {
return
}
idx, err := strconv.Atoi(r.PathValue("idx"))
if err != nil || idx < 0 || idx >= j.TargetsTotal {
writeErr(w, 404, "not found", "")
return
}
s.Batch.Touch(id)
t := ts[idx]
out := map[string]any{
"job_id": j.ID,
"idx": t.Idx,
"url": t.URL,
"status": t.Status,
}
if t.Result != nil {
out["result"] = resultEnvelope(t.Result)
}
if t.Error != "" {
out["error"] = t.Error
}
if t.Hint != "" {
out["hint"] = t.Hint
}
s.logBatch(c, id, j.TargetsTotal, "status", t.Status)
writeJSON(w, 200, out)
}
func (s *Server) batchCancel(w http.ResponseWriter, r *http.Request) {
c := s.caller(w, r)
if c == nil {
return
}
id := r.PathValue("id")
j, _ := s.ownership(w, c, id)
if j.ID == "" {
return
}
switch j.Status {
case "done", "partial", "failed", "cancelled":
s.logBatch(c, id, j.TargetsTotal, "status", "failed")
writeErr(w, 409, "job already finished", "")
return
}
if s.Batch.Cancel(id) {
s.logBatch(c, id, j.TargetsTotal, "status", "cancelled")
writeJSON(w, 200, map[string]any{"success": true, "status": "cancelled"})
return
}
s.logBatch(c, id, j.TargetsTotal, "status", "failed")
writeErr(w, 409, "job already finished", "")
}
// statusEnvelope is the summary GET /v1/batch/{id} returns: statuses, never inline results,
// because 32 results can be several megabytes. With ?results=1 each done target carries the
// /v1/fetch envelope (Meta plus content).
func statusEnvelope(j batch.Job, ts []batch.TargetStatus, r *http.Request) map[string]any {
out := map[string]any{
"job_id": j.ID,
"status": j.Status,
"targets_total": j.TargetsTotal,
"targets_done": j.TargetsDone,
"created_at": time.Unix(j.CreatedAt, 0).UTC().Format(time.RFC3339),
"targets": targetSummaries(ts, r),
}
if j.Error != "" {
out["error"] = j.Error
}
return out
}
func targetSummaries(ts []batch.TargetStatus, r *http.Request) []map[string]any {
results := r.URL.Query().Get("results") == "1"
out := make([]map[string]any, 0, len(ts))
for _, t := range ts {
m := map[string]any{
"idx": t.Idx,
"url": t.URL,
"status": t.Status,
}
if t.Error != "" {
m["error"] = t.Error
}
if t.Hint != "" {
m["hint"] = t.Hint
}
if results && t.Status == "done" && t.Result != nil {
m["result"] = resultEnvelope(t.Result)
}
out = append(out, m)
}
return out
}
// resultEnvelope is the /v1/fetch shape: every Meta field plus content.
func resultEnvelope(res *batch.Result) map[string]any {
env := make(map[string]any, len(res.Meta)+1)
for k, v := range res.Meta {
env[k] = v
}
env["content"] = res.Content
return env
}