chore: import upstream snapshot with attribution
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
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
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
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user