All gates green. Final report: **Verified (phase 167 final pass — all 6 tasks already in `complete/`)** - Live re-measure of the headline criterion on the **1,000-doc live-replica corpus** (restored `data/bor_live_kb_replica.dump.sql` into a scratch DB `bor_audit`, alembic 0011→0027, dropped after; the shared dev DB was never touched): `GET /api/docs/tree` default depth=1 = **8,474 identity → 1,128 B with `Content-Encoding: gzip`** (< 40 KB bar; ~2 MB defect fixed), repeat load **304 / 0 bytes**; depth=8 265,147→23,515; node drill 20,844→1,065; `/api/docs` 246,366→45,104; `/api/documents/content` 892,124→60,776 (`LOOK`, accepted-with-why). Reproduces `AUDIT.md` to within 1 byte. - Transport contracts proven live: `POST /api/chat` **no** `Content-Encoding` + frames arriving live + budget line `… enc=identity duration_ms=7380 stream=1`; HTML still `no-cache` with the `?v=` rewrite (and gzipped); A20 headers on 200 **and** 304; ETag identical across identity/gzip; `depth=0|9|abc`→422; anonymous tree/node→403; unknown folder/source→404 `unknown node`. **Gates (all 100% green)** - `uv run pytest` → **3980 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`compression.py`, `http_cache.py`, `response_budget.py` 100%; `api/docs.py` 99%, one defensive dedupe line). - `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - Phase + regression E2E **in isolation** (`--junitxml`, exit codes): `test_kb_tree_lazy_load` 11, `test_kb_tree` 8, `test_kb_tree_nav` 4, `test_navbar_refresh` 7, `test_document_dates` 6, `test_git_source_dates` 4, `test_sync_summary_visibility` 3, `test_table_ux_polish` 6, `test_sources_midstream_bug` 6, `test_ls_tree_drilldown` 3, `test_smoke` 3 — **0 failures/errors** (the earlier `-q -q` run had simply suppressed pytest's summary line). **Documentation** — README.md + AGENTS.md reviewed in full (1,436 / 80 lines): - Fixed (pre-existing, phase-167-exposed): README *Caching & deploys* claimed "One Starlette middleware (`app/core/caching.py`) applies the rule" while the section now describes three transport layers → rewritten to name caching/compression/http_cache, the real outermost→innermost order, and why `bytes=` is uncompressed. - AGENTS.md rule 5 (UI Structure Check) gained the phase-167 clause: a lazily-drilled level keeps the current level rendered, its in-flight `role="status"` / `role="alert"`+Retry row lives in the same bounded scroll panel, and stat cards read server `totals` (never a walk of a depth-limited tree). - Cross-checked and consistent: every `BOR_*` in `.env.example` is in the README table, every `Settings` field is documented, all 15 README-cited API routes exist in the app, no stale "one fetch / drill client-side" claim remains anywhere. **Notable decisions / deviations** - `compression.py` adds a documented `_RebufferSmallResponses` seam beyond the D1 sketch (the caching layer's `BaseHTTPMiddleware` re-framing otherwise defeats Starlette's `minimum_size` exemption and inflates tiny responses) — integration-pinned, math still Starlette's. - A node-route **404 = vanished folder → honest reset to top level** (per `00_phase.md` D4/D6), not task-06's "alert + Retry"; 500 shows alert+Retry with the level preserved — both pinned, nothing weakened. - Retrieval battery not measurable (shared E2E DB holds no corpus), but `git diff --name-only -- app/rag` is empty, so retrieval is unchanged by construction. `AUDIT.md` left byte-identical (it lives under `todo/`, off-limits to this pass); no git index/history commands run. **Next pending phase:** none — `167_api_payload_diet` is the only entry in `.agents/phases/todo/`; the harness moves it to `complete/` and commits.
379 lines
14 KiB
Python
379 lines
14 KiB
Python
"""Audit the payload of every endpoint that could load a ton of data
|
|
(phase 167, task 05, D5, LOCKED A5).
|
|
|
|
TODO L5 is one sentence: "Fix that **any any other endpoints** that could
|
|
load a ton of data." ``GET /api/docs/tree`` was the reported offender; this
|
|
script is the measured answer for the rest of the API, against the app the
|
|
operator actually runs:
|
|
|
|
uv run python -m scripts.api_payload_audit --base-url http://localhost:8000
|
|
# (or against the deployed app: --base-url https://brain.reeseapps.com)
|
|
|
|
It signs in through the real ``POST /api/login`` (the session cookie is the
|
|
only credential — nothing is hardcoded: ``--password`` or the
|
|
``BOR_ADMIN_PASSWORD`` the app itself reads from ``.env``), walks the
|
|
candidate read routes, and prints one row per endpoint with the IDENTITY and
|
|
the GZIP transfer size, the ratio, the wall time and a verdict against the
|
|
budget (default 256 KB — the phase's "look at it" bar; LOCKED A5: this is a
|
|
report, never a gate, so the exit code is 0 whenever the measurement
|
|
succeeded).
|
|
|
|
**Credentials are password-only on purpose.** The single-admin session is
|
|
``POST /api/login {"password": …}`` (PLAN A10) — there is no username field
|
|
to send, so there is no ``--username``; ``BOR_ADMIN_PASSWORD`` (or
|
|
``--password``) is the whole credential.
|
|
|
|
Both variants are measured on every endpoint because that is the pair the
|
|
byte-budget log line reports (``bytes=`` / ``enc=``): ``Accept-Encoding:
|
|
identity`` gives the payload the route produced, ``Accept-Encoding: gzip``
|
|
gives what the client puts on the wire (``response.num_bytes_downloaded``,
|
|
the raw bytes — ``response.content`` is already decompressed by httpx). The
|
|
routes that publish a validator (the three catalogue reads, phase 167 task
|
|
03) get one extra conditional request each: the expected answer is
|
|
``304`` with an empty body, which is the row's verdict.
|
|
|
|
Run it against the 1,000-document live-replica snapshot (the
|
|
``restore-test-db`` skill) so the numbers are the production order of
|
|
magnitude and not the fixture KB's — the corpus the run measured is printed
|
|
with the table for exactly that reason.
|
|
|
|
Exit codes: **0** the table printed (an over-budget row is a FINDING, shown
|
|
as ``LOOK``, not a failure), **2** a precondition failed — the app
|
|
unreachable, the login rejected, or no credential available.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
#: The phase's "look at it" bar (256 KB uncompressed — D5/A5). Rows over it
|
|
#: are verdict ``LOOK`` and must be fixed in the phase or carry a named
|
|
#: follow-up in ``AUDIT.md``; nothing here rejects a request.
|
|
DEFAULT_BUDGET = 262_144
|
|
|
|
#: Per-request wall-clock ceiling. A large catalogue read on the restored
|
|
#: 1,000-doc replica is fast, but a cold first query can take seconds.
|
|
DEFAULT_TIMEOUT = 60.0
|
|
|
|
|
|
@dataclass
|
|
class Row:
|
|
"""One measured endpoint."""
|
|
|
|
label: str
|
|
path: str
|
|
status: int = 0
|
|
identity_bytes: int | None = None
|
|
identity_ms: int | None = None
|
|
gzip_bytes: int | None = None
|
|
gzip_ms: int | None = None
|
|
revalidated: bool | None = None
|
|
note: str = ""
|
|
|
|
@property
|
|
def ratio(self) -> str:
|
|
"""identity / gzip — how much the compression layer bought (< 1 is
|
|
a win; ``—`` when the route did not compress)."""
|
|
if not self.gzip_bytes or not self.identity_bytes:
|
|
return "—"
|
|
return f"{self.gzip_bytes / self.identity_bytes:.3f}"
|
|
|
|
def verdict(self, budget: int) -> str:
|
|
if self.status == 0:
|
|
# Nothing was measured — the corpus has no such row (an empty KB,
|
|
# no saved chats). Honest gap, not a pass and not a finding.
|
|
return "n/a"
|
|
if self.status == 304:
|
|
return "304"
|
|
if self.status != 200:
|
|
return f"HTTP {self.status}"
|
|
if self.revalidated:
|
|
return "304"
|
|
if (self.identity_bytes or 0) > budget:
|
|
return f"LOOK (> {budget})"
|
|
return "ok"
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
prog="python -m scripts.api_payload_audit",
|
|
description=(
|
|
"Measure the response size (identity AND gzip) of every "
|
|
"candidate large-payload endpoint, against a byte budget."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--base-url",
|
|
default="http://localhost:8000",
|
|
metavar="URL",
|
|
help="app to measure (default: http://localhost:8000)",
|
|
)
|
|
p.add_argument(
|
|
"--password",
|
|
default=None,
|
|
metavar="PASSWORD",
|
|
help=(
|
|
"admin password; falls back to $BOR_ADMIN_PASSWORD (the app's "
|
|
"own variable). The single-admin login has no username (A10)."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--budget",
|
|
type=int,
|
|
default=DEFAULT_BUDGET,
|
|
metavar="BYTES",
|
|
help=f"the LOOK threshold in bytes (default: {DEFAULT_BUDGET})",
|
|
)
|
|
p.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=DEFAULT_TIMEOUT,
|
|
metavar="SECONDS",
|
|
help=f"per-request timeout (default: {DEFAULT_TIMEOUT:g})",
|
|
)
|
|
return p
|
|
|
|
|
|
def _fail(message: str) -> int:
|
|
print(f"api_payload_audit: {message}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
def _get(client: httpx.Client, path: str, accept: str) -> tuple[int, int, int, int]:
|
|
"""GET ``path`` with an explicit ``Accept-Encoding`` →
|
|
``(status, body bytes, wire bytes, ms)``.
|
|
|
|
``body`` is the decoded payload (what the route produced); ``wire`` is
|
|
the raw transfer, which is what the browser pays. They differ exactly
|
|
when the response came back gzipped.
|
|
"""
|
|
started = time.perf_counter()
|
|
response = client.get(path, headers={"Accept-Encoding": accept})
|
|
ms = int(round((time.perf_counter() - started) * 1000))
|
|
return response.status_code, len(response.content), response.num_bytes_downloaded, ms
|
|
|
|
|
|
def measure(
|
|
client: httpx.Client, label: str, path: str, *, conditional: bool = False
|
|
) -> Row:
|
|
"""One endpoint, both encodings, plus the conditional re-request on the
|
|
routes that publish a validator (``conditional=True``)."""
|
|
row = Row(label=label, path=path)
|
|
row.status, row.identity_bytes, _, row.identity_ms = _get(client, path, "identity")
|
|
gzip_status, _, wire, ms = _get(client, path, "gzip")
|
|
row.gzip_bytes, row.gzip_ms = wire, ms
|
|
if gzip_status != row.status: # pragma: no cover - defensive
|
|
row.status = gzip_status
|
|
if conditional:
|
|
# The ETag from a fresh unconditional GET, replayed (fetch does the
|
|
# same thing on the second page view).
|
|
etag = client.get(path, headers={"Accept-Encoding": "identity"}).headers.get("etag")
|
|
if etag is None:
|
|
row.revalidated = False
|
|
else:
|
|
again = client.get(
|
|
path, headers={"Accept-Encoding": "identity", "If-None-Match": etag}
|
|
)
|
|
row.revalidated = again.status_code == 304 and len(again.content) == 0
|
|
return row
|
|
|
|
|
|
def _first_folder(tree: dict[str, Any]) -> tuple[str, str] | None:
|
|
"""``(source, folder path)`` of the first folder node the depth-1 tree
|
|
reports — a representative drill target for the node route."""
|
|
for source in tree.get("sources", []):
|
|
for child in source.get("children", []):
|
|
if child.get("kind") == "folder":
|
|
return str(source.get("name", "")), str(child.get("path", ""))
|
|
return None
|
|
|
|
|
|
def run(args: argparse.Namespace) -> tuple[int, list[Row], list[str]]:
|
|
"""Do the whole audit; returns ``(exit code, rows, corpus lines)``."""
|
|
password = args.password or os.environ.get("BOR_ADMIN_PASSWORD", "")
|
|
if not password:
|
|
return (
|
|
_fail(
|
|
"no admin password — pass --password or set BOR_ADMIN_PASSWORD "
|
|
"(the app's own variable; it is never hardcoded here)"
|
|
),
|
|
[],
|
|
[],
|
|
)
|
|
|
|
client = httpx.Client(
|
|
base_url=args.base_url, timeout=args.timeout, follow_redirects=False
|
|
)
|
|
try:
|
|
login = client.post("/api/login", json={"password": password})
|
|
except httpx.HTTPError as exc:
|
|
return (
|
|
_fail(
|
|
f"cannot reach the app at {args.base_url} ({exc.__class__.__name__}: {exc}) "
|
|
"— start it with `uv run uvicorn app.main:app`"
|
|
),
|
|
[],
|
|
[],
|
|
)
|
|
if login.status_code != 204:
|
|
detail = ""
|
|
try:
|
|
detail = str(login.json().get("detail", ""))
|
|
except Exception: # noqa: BLE001 - a non-JSON error body is still an error
|
|
detail = login.text[:120]
|
|
hint = {
|
|
401: "wrong password (BOR_ADMIN_PASSWORD of the app under test)",
|
|
429: "rate limited (10 failed attempts / 15 min) — wait for the window",
|
|
}.get(login.status_code, "unexpected answer from the real login route")
|
|
return (
|
|
_fail(
|
|
f"login to {args.base_url} failed: HTTP {login.status_code} — "
|
|
f"{hint} {detail}"
|
|
),
|
|
[],
|
|
[],
|
|
)
|
|
|
|
rows: list[Row] = []
|
|
corpus: list[str] = []
|
|
|
|
# --- the corpus the numbers describe (printed with the table) ---------
|
|
docs_response = client.get("/api/docs", headers={"Accept-Encoding": "identity"})
|
|
documents: list[dict[str, Any]] = list(docs_response.json().get("documents", []))
|
|
by_chunks = sorted(documents, key=lambda d: int(d.get("chunks", 0)), reverse=True)
|
|
biggest_doc = by_chunks[0] if by_chunks else None
|
|
chunks_total = sum(int(d.get("chunks", 0)) for d in documents)
|
|
corpus.append(f"documents={len(documents)} chunks_total={chunks_total}")
|
|
|
|
chats: list[dict[str, Any]] = list(
|
|
client.get("/api/chats", headers={"Accept-Encoding": "identity"}).json().get("chats", [])
|
|
)
|
|
biggest_chat = (
|
|
sorted(chats, key=lambda c: int(c.get("message_count", 0)), reverse=True)[0]
|
|
if chats
|
|
else None
|
|
)
|
|
|
|
# --- the candidates ---------------------------------------------------
|
|
rows.append(
|
|
measure(client, "tree depth=1 (default)", "/api/docs/tree?depth=1", conditional=True)
|
|
)
|
|
rows.append(
|
|
measure(client, "tree depth=8 (full)", "/api/docs/tree?depth=8", conditional=True)
|
|
)
|
|
|
|
depth1 = client.get(
|
|
"/api/docs/tree?depth=1", headers={"Accept-Encoding": "identity"}
|
|
).json()
|
|
folder = _first_folder(depth1)
|
|
if folder is not None and folder[0]:
|
|
node_path = f"/api/docs/tree/node?source={folder[0]}&path={folder[1]}&depth=1"
|
|
rows.append(
|
|
measure(client, f"tree node {folder[0]}/{folder[1]}", node_path, conditional=True)
|
|
)
|
|
else: # the honest gap: an empty KB has no drill to measure
|
|
rows.append(
|
|
Row(label="tree node (drill)", path="/api/docs/tree/node", note="no folder in corpus")
|
|
)
|
|
|
|
rows.append(measure(client, "docs catalogue", "/api/docs", conditional=True))
|
|
if biggest_doc is not None:
|
|
content_path = (
|
|
f"/api/documents/content?source={biggest_doc['source']}&path={biggest_doc['path']}"
|
|
)
|
|
row = measure(client, f"document content (biggest: {biggest_doc['path']})", content_path)
|
|
body = client.get(content_path, headers={"Accept-Encoding": "identity"}).json()
|
|
row.note = f"content field {len(str(body.get('content', '')))} chars"
|
|
rows.append(row)
|
|
else:
|
|
rows.append(Row(label="document content", path="/api/documents/content", note="empty KB"))
|
|
|
|
rows.append(measure(client, "saved chats list", "/api/chats"))
|
|
if biggest_chat is not None:
|
|
rows.append(
|
|
measure(
|
|
client,
|
|
f"saved chat (biggest: {int(biggest_chat.get('message_count', 0))} msgs)",
|
|
f"/api/chats/{biggest_chat['id']}",
|
|
)
|
|
)
|
|
else:
|
|
rows.append(Row(label="saved chat (one)", path="/api/chats/{id}", note="no saved chats"))
|
|
|
|
rows.append(measure(client, "git sources", "/api/git-sources"))
|
|
rows.append(measure(client, "suggestions", "/api/suggestions"))
|
|
rows.append(measure(client, "sync status (polled)", "/api/sync/status"))
|
|
rows.append(measure(client, "tokens", "/api/tokens"))
|
|
rows.append(measure(client, "config", "/api/config"))
|
|
rows.append(measure(client, "whoami", "/api/whoami"))
|
|
|
|
corpus.append(f"sources={len(depth1.get('sources', []))}")
|
|
totals = depth1.get("totals") or {}
|
|
if totals:
|
|
corpus.append(
|
|
f"tree totals documents={totals.get('documents')} chunks={totals.get('chunks')}"
|
|
)
|
|
corpus.append(f"saved_chats={len(chats)}")
|
|
corpus.append(f"biggest_doc={biggest_doc['path'] if biggest_doc else '—'}")
|
|
return 0, rows, corpus
|
|
|
|
|
|
def render(rows: list[Row], *, base_url: str, budget: int, corpus: list[str]) -> None:
|
|
"""The table: endpoint, identity bytes, gzip bytes, ratio, ms, verdict."""
|
|
header: tuple[str, ...] = ("endpoint", "identity", "gzip", "ratio", "ms", "verdict")
|
|
table: list[tuple[str, ...]] = [header]
|
|
for row in rows:
|
|
table.append(
|
|
(
|
|
row.label,
|
|
f"{row.identity_bytes:,}" if row.identity_bytes is not None else "—",
|
|
f"{row.gzip_bytes:,}" if row.gzip_bytes is not None else "—",
|
|
row.ratio,
|
|
f"{row.gzip_ms}" if row.gzip_ms is not None else "—",
|
|
row.verdict(budget),
|
|
)
|
|
)
|
|
widths = [max(len(str(r[i])) for r in table) for i in range(len(header))]
|
|
|
|
def _line(values: tuple[Any, ...]) -> str:
|
|
return (
|
|
f" {str(values[0]):<{widths[0]}} {str(values[1]):>10} "
|
|
f"{str(values[2]):>10} {str(values[3]):>{widths[3]}} "
|
|
f"{str(values[4]):>6} {str(values[5])}"
|
|
)
|
|
|
|
print(f"api_payload_audit: base={base_url} budget={budget:,} bytes (LOOK threshold)")
|
|
print(f"api_payload_audit: corpus {' '.join(corpus)}")
|
|
print(_line(header))
|
|
print(f" {'-' * (widths[0] + 44)}")
|
|
for row, source in zip(rows, table[1:], strict=True):
|
|
print(_line(source))
|
|
if row.note:
|
|
print(f" note: {row.note}")
|
|
print(
|
|
"api_payload_audit: ms is the gzip (real-client) variant; identity is the "
|
|
"route's own bytes — the pair the ' :: api ' log line reports."
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
code, rows, corpus = run(args)
|
|
if code != 0 or not rows:
|
|
return code
|
|
render(rows, base_url=args.base_url, budget=args.budget, corpus=corpus)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|