Files
teng-lin--notebooklm-py/tests/server/test_errors.py
T
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

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