09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Waiting to run
Test / Test (macos-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.14) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.14) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.14) (push) Blocked by required conditions
227 lines
8.3 KiB
Python
227 lines
8.3 KiB
Python
"""U3: error projection from ``classify`` to HTTP status + typed envelope."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from notebooklm import exceptions as exc
|
|
from notebooklm.server._errors import _redact, error_response
|
|
from notebooklm.server.app import create_app
|
|
|
|
from .conftest import TEST_TOKEN
|
|
from .fakes import FakeClient
|
|
|
|
|
|
class _RaisingNotebooks:
|
|
def __init__(self, error: BaseException) -> None:
|
|
self._error = error
|
|
|
|
async def list(self) -> list[object]:
|
|
raise self._error
|
|
|
|
|
|
def _client_raising(error: BaseException) -> TestClient:
|
|
fake = FakeClient()
|
|
fake.notebooks = _RaisingNotebooks(error) # type: ignore[assignment]
|
|
|
|
@asynccontextmanager
|
|
async def factory() -> AsyncIterator[FakeClient]:
|
|
yield fake
|
|
|
|
app = create_app(client_factory=factory)
|
|
headers = {"Authorization": f"Bearer {TEST_TOKEN}", "Host": "127.0.0.1"}
|
|
client = TestClient(
|
|
app, headers=headers, client=("127.0.0.1", 5555), raise_server_exceptions=False
|
|
)
|
|
client.__enter__()
|
|
return client
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "status", "category"),
|
|
[
|
|
(exc.ClientError("missing", rpc_code=5), 404, "not_found"),
|
|
(exc.ClientError("missing", rpc_code="5"), 404, "not_found"),
|
|
(exc.RateLimitError("slow down"), 429, "rate_limited"),
|
|
(exc.AuthError("expired"), 401, "auth"),
|
|
(exc.ValidationError("bad"), 400, "validation"),
|
|
(exc.RPCError("decode failed"), 502, "rpc"),
|
|
(RuntimeError("boom"), 500, "unexpected"),
|
|
],
|
|
)
|
|
def test_exception_projects_to_status_and_category(
|
|
error: BaseException, status: int, category: str
|
|
) -> None:
|
|
client = _client_raising(error)
|
|
try:
|
|
resp = client.get("/v1/notebooks")
|
|
finally:
|
|
client.__exit__(None, None, None)
|
|
assert resp.status_code == status
|
|
body = resp.json()
|
|
assert body["error"]["category"] == category
|
|
|
|
|
|
def test_status_5_preserves_the_scrubbed_message() -> None:
|
|
"""The 404 body carries the scrubbed account-routing hint (not dropped)."""
|
|
client = _client_raising(exc.ClientError("wrong authuser hint", rpc_code=5))
|
|
try:
|
|
resp = client.get("/v1/notebooks")
|
|
finally:
|
|
client.__exit__(None, None, None)
|
|
assert resp.status_code == 404
|
|
assert "wrong authuser hint" in resp.json()["error"]["message"]
|
|
|
|
|
|
def test_status_7_is_not_routed_to_404() -> None:
|
|
"""Code 7 (permission-denied) stays a generic RPC → 502, not 404."""
|
|
client = _client_raising(exc.ClientError("denied", rpc_code=7))
|
|
try:
|
|
resp = client.get("/v1/notebooks")
|
|
finally:
|
|
client.__exit__(None, None, None)
|
|
assert resp.status_code == 502
|
|
assert resp.json()["error"]["category"] == "rpc"
|
|
|
|
|
|
def _error_body(exc_obj: BaseException) -> dict[str, object]:
|
|
import json
|
|
|
|
return json.loads(error_response(exc_obj).body.decode())["error"]
|
|
|
|
|
|
def test_error_body_carries_retriable_flag() -> None:
|
|
# A retriable category (rate-limit) and a non-retriable one (validation) both
|
|
# surface the neutral ``retriable`` flag so an agent client can branch a backoff.
|
|
retriable = error_response(exc.RateLimitError("slow down"))
|
|
assert retriable.status_code == 429
|
|
assert _error_body(exc.RateLimitError("slow down"))["retriable"] is True
|
|
assert _error_body(exc.ValidationError("bad"))["retriable"] is False
|
|
|
|
|
|
def test_error_body_carries_hint_where_present() -> None:
|
|
assert "hint" in _error_body(exc.RateLimitError("slow down"))
|
|
# A category with no hint (RPC) omits the field entirely.
|
|
assert "hint" not in _error_body(exc.RPCError("decode failed"))
|
|
|
|
|
|
def test_not_found_body_carries_near_miss_candidates() -> None:
|
|
"""A failed name lookup surfaces near-miss candidates + a 'Did you mean' hint (#1787)."""
|
|
err = exc.NotebookNotFoundError("Scientific")
|
|
err.candidates = [{"id": "37fe5c1d", "title": "Scientific PDF Parsing"}]
|
|
body = _error_body(err)
|
|
assert body["candidates"] == [{"id": "37fe5c1d", "title": "Scientific PDF Parsing"}]
|
|
assert body["hint"].startswith("Did you mean:")
|
|
|
|
|
|
def test_not_found_body_without_candidates_omits_the_field() -> None:
|
|
body = _error_body(exc.NotebookNotFoundError("Scientific"))
|
|
assert "candidates" not in body
|
|
|
|
|
|
def test_http_error_response_enriches_mapped_status() -> None:
|
|
"""A status that maps to a neutral ErrorCategory (411 → validation) carries
|
|
retriable + hint, drawn from the shared _app tables."""
|
|
import json
|
|
|
|
from notebooklm.server._errors import http_error_response
|
|
|
|
body = json.loads(http_error_response(411, "Content-Length required").body.decode())["error"]
|
|
assert body["category"] == "validation"
|
|
assert body["retriable"] is False
|
|
assert "hint" in body
|
|
|
|
|
|
def test_http_error_response_protocol_only_status_omits_enrichment() -> None:
|
|
"""An HTTP-protocol-only status (409 conflict) has no neutral category, so it
|
|
carries just category + message — no retriable / hint."""
|
|
import json
|
|
|
|
from notebooklm.server._errors import http_error_response
|
|
|
|
body = json.loads(http_error_response(409, "already exists").body.decode())["error"]
|
|
assert body["category"] == "conflict"
|
|
assert "retriable" not in body
|
|
assert "hint" not in body
|
|
|
|
|
|
def test_home_directory_path_is_redacted_in_body() -> None:
|
|
# A file/upload error carrying a local home path must not leak the OS username
|
|
# (PII / host disclosure) on the REST surface — the shared redactor masks it.
|
|
resp = error_response(exc.ValidationError("open /home/secretuser/private/x.pdf failed"))
|
|
body = resp.body.decode()
|
|
assert "secretuser" not in body
|
|
assert "/home/***" in body
|
|
|
|
|
|
def test_long_message_is_truncated() -> None:
|
|
long = "x " * 400
|
|
resp = error_response(exc.RPCError(long))
|
|
body = resp.body.decode()
|
|
assert "…" in body
|
|
# The redacted message is capped well under the raw length.
|
|
assert len(_redact(long)) <= 301
|
|
|
|
|
|
def test_route_404_carries_retriable_and_hint(authed_client: object) -> None:
|
|
"""A hand-raised HTTPException (in-route 404) carries the SAME retriable + hint
|
|
enrichment as a classified library error, not just {category, message}."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
assert isinstance(authed_client, TestClient)
|
|
resp = authed_client.get("/v1/notebooks/nb-1/sources/nope/content")
|
|
assert resp.status_code == 404
|
|
err = resp.json()["error"]
|
|
assert err["category"] == "not_found"
|
|
assert err["retriable"] is False
|
|
assert "hint" in err and isinstance(err["hint"], str)
|
|
|
|
|
|
def test_route_422_carries_retriable_and_hint(authed_client: object) -> None:
|
|
"""A request-body 422 (RequestValidationError → hand-raised path) is enriched."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
assert isinstance(authed_client, TestClient)
|
|
resp = authed_client.post("/v1/notebooks/nb-1/chat", json={})
|
|
assert resp.status_code == 422
|
|
err = resp.json()["error"]
|
|
assert err["category"] == "validation"
|
|
assert err["retriable"] is False
|
|
assert "hint" in err
|
|
|
|
|
|
def test_auth_401_carries_retriable_and_hint(app: object) -> None:
|
|
"""The auth dependency's 401 (a hand-raised HTTPException) is enriched too."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
headers = {"Authorization": "Bearer wrong-token", "Host": "127.0.0.1"}
|
|
with TestClient(
|
|
app, headers=headers, client=("127.0.0.1", 5555), raise_server_exceptions=False
|
|
) as client:
|
|
resp = client.get("/v1/notebooks")
|
|
assert resp.status_code == 401
|
|
err = resp.json()["error"]
|
|
assert err["category"] == "auth"
|
|
assert err["retriable"] is False
|
|
assert "hint" in err
|
|
|
|
|
|
def test_request_validation_message_has_no_source_paths(authed_client: object) -> None:
|
|
"""A malformed body → 422 envelope with a compact field summary, NOT
|
|
``str(exc)`` (which embeds server file paths / frame info under pydantic v2)."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
assert isinstance(authed_client, TestClient)
|
|
resp = authed_client.post("/v1/notebooks/nb-1/chat", json={})
|
|
assert resp.status_code == 422
|
|
body = resp.json()
|
|
assert body["error"]["category"] == "validation"
|
|
message = body["error"]["message"]
|
|
# The missing field is named, but no server path / source file leaks.
|
|
assert "question" in message
|
|
assert ".py" not in message and "/home/" not in message and 'File "' not in message
|