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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user