Files
sift/docs/plans/p5a/01-kind-fetch.md
T
kyleandClaude Opus 5.5 cfde35397a P5a plan: PDF documents — seven Ornith tasks with acceptance tests first
docs/plans/p5a: 01 kind+fetch limits, 02 pdftext child + runner, 03 dcache PDF tables,
04 pdfpool, 05 pdfdoc render (map/pages/grep), 06 api, 07 digest citations, metrics, CLI,
skill, unit, README. Given tests compile against a panic-only skeleton and fail there for the
intended reasons; PDFs for tests are built by internal/pdftest (given), plus two qpdf-encrypted
fixtures.

Owner changes for the run: vendor go-pdfkit/extract v0.3.0 + reader v0.6.0 (pinned via
tools.go until task 02); AGENTS.md, tools/run-plan.sh (enforces a Co-Authored-By trailer for
the model), docs/implementer-log.md, `make gate`; gofmt (whitespace) on clean.go and guard;
TestUnsupportedType uses a zip now that PDFs are supported; requestDeadline 90→210 s and
writeTimeout 120→240 s for the PDF path's worst case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:47:38 -07:00

101 lines
4.8 KiB
Markdown

# P5a task 01: recognise PDFs; give them a larger body cap, a longer deadline, and revalidation
**Branch:** `p5a` (`git switch -c p5a master` if it does not exist, else `git switch p5a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `clean, fetch: recognise PDFs; 100 MB / 120 s for PDFs; FetchIf revalidates`
## Goal
Today every response that is not HTML, JSON or text is `415 unsupported content type`. After this
task `clean.Kind` names PDFs, and the fetcher lets a PDF be as large as 100 MB and take 120 s
while a web page keeps 5 MB and 20 s. `FetchIf` sends `If-None-Match` / `If-Modified-Since` so a
cached document can be revalidated with a 304. Nothing uses these yet (task 06 does).
## Files
- Copy: `internal/clean/kind_test.go`, `internal/fetch/pdf_test.go`
- Create: `internal/fetch/pdf.go` (the PDF rule and `FetchIf` live here; keep `fetch.go`'s
changes to what the new file needs)
- Modify: `internal/clean/clean.go`, `internal/fetch/fetch.go`, `docs/implementer-log.md`
## Interfaces
```go
package clean
// Kind classifies a fetched body: "html", "json", "text", "pdf" or "other".
func Kind(contentType string, body []byte) string
package fetch
const (
DefaultPDFMaxBody = 100 << 20
DefaultPDFTimeout = 120 * time.Second
)
// in Fetcher:
PDFMaxBody int64 // 0 = DefaultPDFMaxBody
PDFTimeout time.Duration // 0 = DefaultPDFTimeout
// FetchIf is Fetch with validators: a non-empty etag is sent as If-None-Match and a non-empty
// lastModified as If-Modified-Since. A 304 answer returns a Resource with Status 304, an empty
// Body and a nil error. With both empty it is exactly Fetch.
func (f *Fetcher) FetchIf(ctx context.Context, rawURL, etag, lastModified string) (*Resource, error)
```
## Rules
1. **`Kind`** (the media type is lower-cased and its parameters dropped, as `MediaKind` does):
- `application/pdf` or `application/x-pdf` → `"pdf"`, whatever the body.
- `text/html`, `application/xhtml+xml` → `"html"`; `application/json` or `*+json` → `"json"`;
`text/plain`, `text/markdown`, `text/csv` → `"text"` (as today).
- `application/octet-stream` or `binary/octet-stream` → `"pdf"` if `%PDF-` occurs within the
first 1024 bytes of the body, else `"other"`.
- empty Content-Type → `"pdf"` if `%PDF-` occurs within the first 1024 bytes, else today's
`Sniff` (html / json / text).
- anything else → `"other"`.
`MediaKind` also returns `"pdf"` for the two PDF types; its other answers do not change.
2. **Fetch limits.** A response is a PDF when its Content-Type is a PDF type, or when it is empty
or octet-stream and the body starts (within 1024 bytes) with `%PDF-`. For a PDF the body cap
is `PDFMaxBody` and the deadline is `PDFTimeout`, both counted from the start of the request;
for anything else they stay `MaxBody` and `Timeout`. Over the cap is the existing
`413 too large (>N MB decompressed)` with N from the cap that applied.
3. **How to get the deadline right.** `Fetch` today makes one context with `Timeout`, and the
shared `http.Client` has `Timeout` set too — both would cut a PDF off at 20 s. Make the
client's `Timeout` at least the PDF deadline (or unset it and rely on contexts), give the
request a context with the **PDF** deadline, and cancel it at the ordinary deadline unless the
response has been recognised as a PDF by then (`time.AfterFunc` + a flag you set once the
headers, or the first bytes of an octet-stream body, say PDF). Read the body through
`io.LimitReader` with the cap that applies (+1 to detect overflow), as today.
4. `FetchIf` goes through the same guard, client, limits and error mapping as `Fetch`; only the
two request headers differ. Do not copy `Fetch`: factor the shared part.
## Facts
- The tests reach an `httptest` server on 127.0.0.1 through a `guard.Guard` with
`AllowPrefixes: 127.0.0.0/8`; the guard itself is not yours to change.
- `internal/fetch` has no other tests; `internal/api` tests exercise `Fetch` through the API and
must stay green.
## Steps
- [ ] **1.** Branch as above; copy the two given tests.
- [ ] **2. See them fail:** `go test -count=1 ./internal/clean/ ./internal/fetch/` → `Kind`,
`FetchIf`, `DefaultPDFMaxBody` undefined.
- [ ] **3.** `clean.go`. **4.** `fetch/pdf.go` and the smallest changes to `fetch.go`.
- [ ] **5.** `gofmt -w`; `go test -race -count=3 ./internal/clean/ ./internal/fetch/ ./internal/api/` → `ok`.
- [ ] **6.** `make gate`. **7.** Row `p5a/01-kind-fetch`; commit.
```sh
git add internal/clean internal/fetch docs/implementer-log.md
git commit -F .state/commit-msg.txt
```
## Done when
- The given tests and every earlier test pass under `-race -count=3`; `make gate` prints
`gate: ok`; given files byte-identical.
## Stop and report if
- A given test needs a change to `internal/guard`.