6.1 KiB
03-api — /v1/batch endpoints in internal/api
Wire the batch runner into the API server. This is the HTTP surface and the only place the guard,
auth, caps-as-HTTP-statuses, and restricted-caller redaction meet the runner. New code goes in a
new file internal/api/batch.go; do not grow api.go.
Endpoints
| Method | Path | Result |
|---|---|---|
POST /v1/batch |
submit a list of targets | 202 job created/reused, 400 malformed, 422 a target refused up front or over a cap, 429 caller/jobs busy |
GET /v1/batch/{id} |
status + per-target results | 200 (results inline), 401/404 |
DELETE /v1/batch/{id} |
cancel | 200 cancelled, 401/404 |
Register them in New and add /v1/batch, /v1/batch/ to the ServeHTTP fast-path switch
(no requestDeadline — submit/poll are quick; the worker is not under any request context).
Request/response shapes (exact JSON — the given test compares these)
POST /v1/batch:
{"targets": [{"url": "…", "mode": "clean", "question": "…", "pages": "…", "grep": "…",
"jq": "…", "context": true, "recover": false}, …]}
Each target carries the same fields as /v1/fetch minus the outer url semantics (each target is
its own URL). A targets list is required and non-empty; otherwise 400.
Response 202:
{"success": true, "job_id": "…", "status": "queued", "targets": 3, "reused": false}
GET /v1/batch/{id}:
{"job_id": "…", "status": "done", "targets_total": 3, "targets_done": 3,
"created_at": "…", "targets": [
{"idx": 0, "url": "…", "status": "done", "result": { …/v1/fetch envelope… }},
{"idx": 1, "url": "…", "status": "failed", "error": "blocked: private address …", "hint": "browser"}
]}
result is the /v1/fetch-shaped envelope (content, mode, untrusted, source, warnings, sizes,
timings, and pdf/digest/context when present), written by the runner's FetchFunc adapter. A
failed target carries error and optional hint instead of result. Results are inline
regardless of status, once a target is finished; a pending target has status: "pending" and no
result/error.
DELETE /v1/batch/{id} → 200 {"success": true, "status": "cancelled"}.
Refactor of existing code (explicit — this is the one task that edits working code)
v1fetch today inlines the whole per-target pipeline: guard → doFetch → recover →
render/digest → context → envelope. The batch worker needs the same pipeline for one target. Do
not copy-paste that pipeline into the batch adapter — extract it into one shared method that
both v1fetch and the runner's FetchFunc call:
func (s *Server) fetchTarget(ctx context.Context, c *auth.Caller, t batch.Target) (batch.Result, error)
v1fetch is refactored to call it, keeping its exact response shape and behavior — the existing
v1fetch tests must pass unchanged. The runner is wired as a new field s.Batch *batch.Runner,
and its FetchFunc is a closure over s.fetchTarget bound to the authenticated caller. This
refactor is called out explicitly for reviewer attention: flag any behavioral drift between the
two callers. The per-target result is capped by the output cap before it is stored.
Rules
- Guard pre-check at submit. Run
guard.ParseURL(the no-resolution pre-check) on every target URL before accepting. If any target fails, refuse the whole submit with422and the offending index + reason (blocked: …). This is the "refused up front" gate — the connect-time guard (private IP, rebinding) cannot run until fetch time and surfaces as a per-targetfailed, not a submit refusal. - Body cap. The global
MaxBytesReadercaps bodies atmaxReqBody(64 KiB);/v1/batchgets its own larger limit (256 KiB) so 32 targets with questions fit. Apply it in the handler. - Caps → statuses. Map the runner's typed errors:
ErrTooManyTargets→422;ErrCallerBusyandErrBusy→429with aRetry-Afterwhen sensible. Idempotent reuse returns202withreused: true. - Ownership.
GET/DELETEresolve the job by id, then requirejob.caller == c.Name; else404(not403— do not reveal that another caller's job exists). The job id is the random opaque token;idem_keyis never returned or logged. - Restricted redaction. Pass the caller (not just its name) into the runner so each target's
fetch goes through
doFetchwith the real*auth.Caller; the existinglogHost/logReason/logErrthen blank host, reason, and error detail automatically. The batch handler's own log line logscaller,job_id, target count, and status — never a target URL or hostname, for any caller. - The adapter. The runner's
FetchFuncis the shareds.fetchTarget(see the refactor section above), bound to the authenticated*auth.Caller. It wrapsdoFetch+recover+ digest + context + envelope, keyed to onebatch.Target, returningbatch.Result(whoseMetacarries the envelope's source/digest/pdf/context/warnings/sizes/timings). The per-target result is capped by the output cap before it is stored. - Job status on the wire uses the runner's vocabulary (
queued|running|done|partial|failed| cancelled); unknown id →404. All three endpoints require a bearer key via the existingcallerhelper (MetricsOnly→403).
Given
Copy _files/api/batch_test.go → internal/api/batch_test.go. It builds a Server with s.Batch
wired over an in-memory dcache (pinning the s.Batch *batch.Runner field and the
s.fetchTarget adapter), then drives the endpoints. Asserts, at least:
- submit →
202with ajob_id; resubmit of the same body → same id,reused: true; - a target the guard pre-check rejects (non-80/443 port) →
422and no job created; - 33 targets →
422; - a private-address target (passes pre-check, blocked at connect) → per-target
failedwitherrorcontainingblocked, job aggregatepartial; GETby another caller's key →404;DELETEby the owner →200, thenGET→cancelled;
Gate
make gate green; go test ./internal/api/ green under -race.