phase: 167_api_payload_diet
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.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""Integration: the byte-budget log line on the REAL app stack (phase 167, task 05).
|
||||
|
||||
``tests/unit/test_response_budget.py`` pins the middleware on a bare ASGI
|
||||
app; this file pins what only the real stack can prove —
|
||||
|
||||
* the line describes the REAL ``GET /api/docs/tree``: ``bytes=`` equals the
|
||||
JSON the route produced, ``enc=identity`` for an identity client and
|
||||
``enc=gzip`` through the actual ``SelectiveGZipMiddleware`` (the
|
||||
reading that works because the middleware sits INSIDE the compressor),
|
||||
* a conditional revalidation of that route logs ``status=304 bytes=0`` —
|
||||
the phase-167 win visible in the log, and
|
||||
* the level follows ``log_large_response_bytes`` (DEBUG under it, INFO at
|
||||
or over it, and ``/api/health`` under the 64 KB default), while
|
||||
non-``/api/`` paths (the shell page through the caching + compression
|
||||
layers) write nothing.
|
||||
|
||||
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
import app.core.response_budget as budget_mod
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Chunk, Document
|
||||
|
||||
_LINE_RE = re.compile(
|
||||
r"^api method=(?P<method>[A-Z]+) path=(?P<path>\S+) status=(?P<status>\d+) "
|
||||
r"bytes=(?P<bytes>\d+) enc=(?P<enc>\S+) duration_ms=(?P<ms>\d+)$"
|
||||
)
|
||||
|
||||
_counter = itertools.count()
|
||||
|
||||
|
||||
def _clean(db) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_corpus(db, count: int = 50) -> None:
|
||||
"""``count`` indexed docs — the tree response lands well above the
|
||||
860-byte gzip floor (the corpus that motivates the phase is ~1 k docs)."""
|
||||
_clean(db)
|
||||
base = datetime.now(UTC)
|
||||
for i in range(count):
|
||||
doc = Document(
|
||||
source="budget-corpus",
|
||||
path=f"docs/sub-{i % 5}/file-{i:02d}.md",
|
||||
full_path=f"/tmp/budget-corpus/docs/sub-{i % 5}/file-{i:02d}.md",
|
||||
title=f"Doc {i:02d}",
|
||||
content=f"# Doc {i:02d}\n\nBody of document {i:02d}.",
|
||||
content_hash=f"{next(_counter):064d}",
|
||||
indexed_at=base,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=doc.id,
|
||||
position=0,
|
||||
content=f"chunk of doc {i:02d}",
|
||||
embedding=[0.01] * 768,
|
||||
)
|
||||
)
|
||||
db.commit() # the endpoint session reads committed data only
|
||||
|
||||
|
||||
def _budget_lines(
|
||||
caplog: pytest.LogCaptureFixture, path: str | None = None
|
||||
) -> list[tuple[int, str]]:
|
||||
"""``(levelno, message)`` for the budget logger, optionally narrowed to
|
||||
one request path (the ``admin_client`` fixture's own login is a real
|
||||
``/api/*`` response too)."""
|
||||
lines = [
|
||||
(record.levelno, record.getMessage())
|
||||
for record in caplog.records
|
||||
if record.name == "app.response_budget"
|
||||
]
|
||||
if path is None:
|
||||
return lines
|
||||
return [(level, message) for level, message in lines if f" path={path} " in message]
|
||||
|
||||
|
||||
def test_tree_response_logs_the_exact_line_for_the_route_bytes(
|
||||
admin_client: TestClient, db, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""``bytes=`` is what the route produced — with an identity client that
|
||||
is exactly the body the caller received."""
|
||||
_seed_corpus(db)
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
response = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "identity"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs/tree")
|
||||
assert len(lines) == 1
|
||||
match = _LINE_RE.match(lines[0][1])
|
||||
assert match is not None, lines[0][1]
|
||||
assert match.group("method") == "GET"
|
||||
assert match.group("status") == "200"
|
||||
assert int(match.group("bytes")) == len(response.content)
|
||||
assert match.group("enc") == "identity"
|
||||
assert int(match.group("ms")) >= 0
|
||||
finally:
|
||||
_clean(db)
|
||||
|
||||
|
||||
def test_gzipped_tree_logs_enc_gzip_with_the_uncompressed_bytes(
|
||||
admin_client: TestClient, db, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The D5 reading, end to end: the client received gzip and the log says
|
||||
``enc=gzip`` while ``bytes=`` stays the ROUTE's size — which is why the
|
||||
middleware is wired inside the compression layer."""
|
||||
_seed_corpus(db)
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
identity = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "identity"}
|
||||
)
|
||||
response = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.content == identity.content
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs/tree")
|
||||
assert len(lines) == 2
|
||||
identity_line, gzip_line = (
|
||||
_LINE_RE.match(lines[0][1]),
|
||||
_LINE_RE.match(lines[1][1]),
|
||||
)
|
||||
assert identity_line is not None and identity_line.group("enc") == "identity"
|
||||
assert gzip_line is not None and gzip_line.group("enc") == "gzip"
|
||||
# Same route bytes in both lines…
|
||||
assert identity_line.group("bytes") == gzip_line.group("bytes")
|
||||
# …and that is the ROUTE's size, not the compressed transfer.
|
||||
assert int(gzip_line.group("bytes")) == len(identity.content)
|
||||
finally:
|
||||
_clean(db)
|
||||
|
||||
|
||||
def test_conditional_revalidation_logs_zero_bytes(
|
||||
admin_client: TestClient, db, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The phase-167 win is visible, not hidden: the 304 costs one line with
|
||||
``bytes=0``."""
|
||||
_seed_corpus(db)
|
||||
try:
|
||||
fresh = admin_client.get("/api/docs/tree?depth=1")
|
||||
assert fresh.status_code == 200
|
||||
etag = fresh.headers["etag"]
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
revalidated = admin_client.get(
|
||||
"/api/docs/tree?depth=1", headers={"If-None-Match": etag}
|
||||
)
|
||||
assert revalidated.status_code == 304
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs/tree")
|
||||
assert len(lines) == 1
|
||||
match = _LINE_RE.match(lines[0][1])
|
||||
assert match is not None
|
||||
assert match.group("status") == "304"
|
||||
assert match.group("bytes") == "0"
|
||||
finally:
|
||||
_clean(db)
|
||||
|
||||
|
||||
def test_level_follows_the_configured_budget(
|
||||
admin_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""``/api/health`` sits under the 65536 default → DEBUG; the same
|
||||
response with ``log_large_response_bytes=0`` is INFO (the loud mode). The
|
||||
middleware reads the setting per response, so this is the operator's
|
||||
runtime knob, not a boot-time one."""
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
assert admin_client.get("/api/health").status_code == 200
|
||||
assert [level for level, _ in _budget_lines(caplog, "/api/health")] == [logging.DEBUG]
|
||||
|
||||
monkeypatch.setattr(
|
||||
budget_mod,
|
||||
"get_settings",
|
||||
lambda: Settings(_env_file=None, log_large_response_bytes=0), # pyright: ignore[reportCallIssue]
|
||||
)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
assert admin_client.get("/api/health").status_code == 200
|
||||
assert [level for level, _ in _budget_lines(caplog, "/api/health")] == [logging.INFO]
|
||||
|
||||
|
||||
def test_html_page_writes_no_budget_line(
|
||||
admin_client: TestClient, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The shell page rides the caching rewrite AND the compression layer
|
||||
and still writes nothing — the budget measures ``/api/*`` only."""
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
response = admin_client.get("/sources.html", headers={"Accept-Encoding": "gzip"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
|
||||
assert not any(" path=/sources.html " in message for _, message in _budget_lines(caplog))
|
||||
|
||||
|
||||
def test_anonymous_403_is_measured_like_any_other_response(
|
||||
client: TestClient, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The gate is a response too — an unauthenticated catalogue read logs
|
||||
its status (and the byte line is what makes a chatty 403 visible)."""
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
assert client.get("/api/docs").status_code == 403
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs")
|
||||
assert len(lines) == 1
|
||||
match: Any = _LINE_RE.match(lines[0][1])
|
||||
assert match is not None
|
||||
assert match.group("status") == "403"
|
||||
|
||||
|
||||
def test_the_module_level_app_carries_the_middleware() -> None:
|
||||
"""Smoke: the wiring pin in ``tests/unit/test_response_budget.py`` covers
|
||||
the ORDER on a fresh ``create_app()``; this proves the module-level ``app``
|
||||
the whole suite (and the dev server) runs against has the layer too."""
|
||||
names = [getattr(mw.cls, "__name__", "") for mw in fastapi_app.user_middleware]
|
||||
assert "ResponseBudgetMiddleware" in names
|
||||
Reference in New Issue
Block a user