Files
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

1378 lines
56 KiB
Python

"""Unit tests for RPC response decoder."""
import json
import logging
import pytest
from notebooklm.exceptions import DecodingError
from notebooklm.rpc.decoder import (
AuthError,
ClientError,
RateLimitError,
RPCError,
RPCErrorCode,
UnknownRPCMethodError,
byte_count_mismatch_total,
collect_rpc_ids,
decode_response,
extract_rpc_result,
get_error_message_for_code,
parse_chunked_response,
reset_byte_count_mismatch_total,
strip_anti_xssi,
)
from notebooklm.rpc.types import RPCMethod
class TestStripAntiXSSI:
def test_strips_prefix(self):
"""Test removal of anti-XSSI prefix."""
response = ')]}\'\n{"data": "test"}'
result = strip_anti_xssi(response)
assert result == '{"data": "test"}'
def test_no_prefix_unchanged(self):
"""Test response without prefix is unchanged."""
response = '{"data": "test"}'
result = strip_anti_xssi(response)
assert result == response
def test_handles_windows_newlines(self):
"""Test handles CRLF."""
response = ')]}\'\r\n{"data": "test"}'
result = strip_anti_xssi(response)
assert result == '{"data": "test"}'
def test_handles_double_newline(self):
"""Test handles double newline after prefix."""
response = ')]}\'\n\n{"data": "test"}'
result = strip_anti_xssi(response)
assert result.startswith("\n{") or result == '{"data": "test"}'
class TestParseChunkedResponse:
@staticmethod
def _chunk_record(data):
chunk_json = json.dumps(data)
return f"{len(chunk_json.encode('utf-8'))}\n{chunk_json}"
def test_parses_single_chunk(self):
"""Test parsing response with single chunk."""
chunk_data = ["chunk", "data"]
chunk_json = json.dumps(chunk_data)
response = f"{len(chunk_json)}\n{chunk_json}\n"
chunks = parse_chunked_response(response)
assert len(chunks) == 1
assert chunks[0] == ["chunk", "data"]
def test_parses_multiple_chunks(self):
"""Test parsing response with multiple chunks."""
chunk1 = json.dumps(["one"])
chunk2 = json.dumps(["two"])
response = f"{len(chunk1)}\n{chunk1}\n{len(chunk2)}\n{chunk2}\n"
chunks = parse_chunked_response(response)
assert len(chunks) == 2
assert chunks[0] == ["one"]
assert chunks[1] == ["two"]
def test_handles_nested_json(self):
"""Test parsing chunks with nested JSON."""
inner = json.dumps([["nested", "data"]])
chunk = ["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, inner]
chunk_json = json.dumps(chunk)
response = f"{len(chunk_json)}\n{chunk_json}\n"
chunks = parse_chunked_response(response)
assert len(chunks) == 1
assert chunks[0][0] == "wrb.fr"
assert chunks[0][1] == RPCMethod.LIST_NOTEBOOKS.value
def test_empty_response(self):
"""Test empty response returns empty list."""
chunks = parse_chunked_response("")
assert chunks == []
def test_whitespace_only_response(self):
"""Test whitespace-only response returns empty list."""
chunks = parse_chunked_response(" \n\n ")
assert chunks == []
def test_ignores_malformed_chunks(self):
"""Test malformed chunks are ignored when below 10% threshold."""
# Add 10 valid payload records and 1 malformed payload record, keeping
# the payload error rate below the 10% threshold.
valid_chunks = [json.dumps([f"valid{i}"]) for i in range(10)]
valid_parts = "\n".join([f"{len(c)}\n{c}" for c in valid_chunks])
response = f"{valid_parts}\n99\nnot-json\n"
chunks = parse_chunked_response(response)
assert len(chunks) == 10
assert chunks[0] == ["valid0"]
assert chunks[9] == ["valid9"]
def test_logs_debug_but_parses_mismatched_byte_count_with_valid_json(self, caplog):
"""Mismatched byte counts are tolerated when the payload is valid JSON.
The mismatch is logged at DEBUG (not WARNING) because Google's
batchexecute declares a count in a different unit than UTF-8 bytes, so
every multi-chunk live response would otherwise flood CI logs.
"""
valid_parts = "\n".join(self._chunk_record([f"valid{i}"]) for i in range(10))
payload = json.dumps(["wrong-size"])
response = f"{valid_parts}\n{len(payload) + 1}\n{payload}\n"
with caplog.at_level(logging.DEBUG, logger="notebooklm.rpc.decoder"):
chunks = parse_chunked_response(response)
assert chunks == [[f"valid{i}"] for i in range(10)] + [["wrong-size"]]
mismatch_records = [
r
for r in caplog.records
if r.name == "notebooklm.rpc.decoder" and "declares" in r.message
]
assert len(mismatch_records) == 1
assert mismatch_records[0].levelno == logging.DEBUG
assert "payload is" in mismatch_records[0].message
def test_byte_count_mismatch_bumps_counter_without_warning(self, caplog):
"""A byte-count mismatch is the expected case: counter only, no WARNING.
The tolerant parse is unchanged (valid JSON payloads are still
returned) and each mismatch increments the process-wide counter, but no
WARNING is emitted: a mismatch trips on essentially every healthy live
multi-chunk response, so it is tracked silently and surfaced only via
``byte_count_mismatch_total()`` (telemetry alerts on its rate-of-change).
"""
reset_byte_count_mismatch_total()
try:
payload = json.dumps(["wrong-size"])
response = f"{len(payload) + 1}\n{payload}\n"
assert byte_count_mismatch_total() == 0
with caplog.at_level(logging.DEBUG, logger="notebooklm.rpc.decoder"):
chunks = parse_chunked_response(response)
# Tolerant parse preserved: valid JSON still returned.
assert chunks == [["wrong-size"]]
assert byte_count_mismatch_total() == 1
# No WARNING for the expected mismatch; the DEBUG line still fires.
assert not [
r
for r in caplog.records
if r.name == "notebooklm.rpc.decoder" and r.levelno == logging.WARNING
]
assert any(
r.levelno == logging.DEBUG and "declares" in r.message
for r in caplog.records
if r.name == "notebooklm.rpc.decoder"
)
finally:
reset_byte_count_mismatch_total()
def test_byte_count_mismatch_counts_silently_across_records(self, caplog):
"""Repeated mismatches keep counting without ever emitting a WARNING."""
reset_byte_count_mismatch_total()
try:
payload = json.dumps(["wrong-size"])
record = f"{len(payload) + 1}\n{payload}"
response = "\n".join(record for _ in range(50)) + "\n"
with caplog.at_level(logging.WARNING, logger="notebooklm.rpc.decoder"):
chunks = parse_chunked_response(response)
assert chunks == [["wrong-size"]] * 50
assert byte_count_mismatch_total() == 50
# Mismatches are counted silently; none escalate to WARNING.
assert not [
r
for r in caplog.records
if r.name == "notebooklm.rpc.decoder" and r.levelno == logging.WARNING
]
finally:
reset_byte_count_mismatch_total()
def test_matching_byte_count_does_not_bump_counter(self):
"""A correct byte count is not a mismatch and must not bump the counter."""
reset_byte_count_mismatch_total()
try:
payload = json.dumps(["right-size"])
response = f"{len(payload.encode('utf-8'))}\n{payload}\n"
chunks = parse_chunked_response(response)
assert chunks == [["right-size"]]
assert byte_count_mismatch_total() == 0
finally:
reset_byte_count_mismatch_total()
def test_concurrent_mismatches_do_not_lose_increments(self):
"""The counter is lock-guarded, so concurrent parses must not race.
``x += 1`` on a module global is a non-atomic read-modify-write in
CPython, so without the lock concurrent ``parse_chunked_response``
calls (worker threads / multiple per-thread clients) could lose
increments. Drive many threads at one mismatch each and assert the
total equals the number of parses exactly.
"""
import threading
reset_byte_count_mismatch_total()
try:
payload = json.dumps(["wrong-size"])
response = f"{len(payload) + 1}\n{payload}\n"
threads = 16
per_thread = 50
barrier = threading.Barrier(threads)
def worker() -> None:
barrier.wait()
for _ in range(per_thread):
parse_chunked_response(response)
workers = [threading.Thread(target=worker) for _ in range(threads)]
for t in workers:
t.start()
for t in workers:
t.join()
assert byte_count_mismatch_total() == threads * per_thread
finally:
reset_byte_count_mismatch_total()
def test_skips_byte_count_without_payload_below_threshold(self, caplog):
"""A trailing byte-count line without a payload is malformed and skipped."""
valid_parts = "\n".join(self._chunk_record([f"valid{i}"]) for i in range(10))
response = f"{valid_parts}\n42\n"
chunks = parse_chunked_response(response)
assert chunks == [[f"valid{i}"] for i in range(10)]
assert "without payload" in caplog.text
def test_trailing_byte_count_above_framing_threshold_raises(self):
"""A framing-only response fails strict decoding."""
with pytest.raises(RPCError, match="1 of 1 framing records"):
parse_chunked_response("42\n")
def test_skips_payload_split_across_lines_below_threshold(self):
"""A payload split across lines is treated as truncated malformed input."""
valid_parts = "\n".join(self._chunk_record([f"valid{i}"]) for i in range(20))
payload = json.dumps(["split"])
first_part, second_part = payload[:4], payload[4:]
response = f"{valid_parts}\n{len(payload)}\n{first_part}\n{second_part}\n"
chunks = parse_chunked_response(response)
assert chunks == [[f"valid{i}"] for i in range(20)]
def test_skips_extra_non_json_lines_before_and_after_valid_chunk(self):
"""Standalone non-JSON lines are skipped while valid chunks are preserved."""
valid_parts = "\n".join(self._chunk_record([f"valid{i}"]) for i in range(20))
response = f"noise-before\n{valid_parts}\nnoise-after\n"
chunks = parse_chunked_response(response)
assert chunks == [[f"valid{i}"] for i in range(20)]
def test_payload_error_rate_exactly_ten_percent_is_tolerated(self):
"""The payload threshold is exclusive: exactly 10% does not raise."""
valid_parts = "\n".join(self._chunk_record([f"valid{i}"]) for i in range(9))
response = f"{valid_parts}\n99\nnot-json\n"
chunks = parse_chunked_response(response)
assert chunks == [[f"valid{i}"] for i in range(9)]
def test_byte_count_frames_do_not_dilute_malformed_payload_rate(self):
"""The parser raises when payload errors exceed 10%, excluding byte-count frames."""
valid_parts = "\n".join(self._chunk_record([f"valid{i}"]) for i in range(8))
response = f"{valid_parts}\n99\nnot-json\n"
with pytest.raises(RPCError) as exc_info:
parse_chunked_response(response)
assert "1 of 9 payload records malformed" in str(exc_info.value)
assert "18 response records" not in str(exc_info.value)
def test_mixed_payload_and_framing_errors_preserve_strict_threshold(self):
"""Separate payload/framing rates must not loosen mixed malformed streams."""
valid_parts = "\n".join(self._chunk_record([f"valid{i}"]) for i in range(10))
response = f"{valid_parts}\n99\nnot-json\n42\n"
with pytest.raises(RPCError, match="2 of 12 response records"):
parse_chunked_response(response)
class TestExtractRPCResult:
def test_extracts_result_for_rpc_id(self):
"""Test extracting result for specific RPC ID."""
inner_data = json.dumps([["notebook1"]])
chunks = [
["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, inner_data, None, None],
["di", 123], # Some other chunk type
]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result == [["notebook1"]]
def test_returns_none_if_not_found(self):
"""Test returns None if RPC ID not in chunks."""
inner_data = json.dumps([])
chunks = [
["wrb.fr", "other_id", inner_data, None, None],
]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result is None
def test_handles_double_encoded_json(self):
"""Test handles JSON string inside JSON (common pattern)."""
inner_json = json.dumps([["notebook1", "id1"]])
chunks = [
["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, inner_json, None, None],
]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result == [["notebook1", "id1"]]
def test_handles_non_json_string_result(self):
"""Test handles string results that aren't JSON."""
chunks = [
["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, "plain string result", None, None],
]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result == "plain string result"
def test_prefers_last_non_null_frame_for_same_id(self):
"""rt=c emits a null placeholder then the populated frame; the last wins.
Multiple ``wrb.fr`` frames for one RPC ID are legal in streamed
``rt=c`` mode. The decoder must skip past an earlier null placeholder
and return the final populated payload instead of the first match.
"""
placeholder = ["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, None]
final = [
"wrb.fr",
RPCMethod.LIST_NOTEBOOKS.value,
json.dumps([["final-notebook"]]),
None,
None,
None,
]
# Frames may arrive in separate chunks or grouped — cover both.
chunks = [[placeholder], [final]]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result == [["final-notebook"]]
def test_does_not_downgrade_populated_frame_to_later_null(self):
"""A trailing null frame must not clobber an earlier populated result."""
final = [
"wrb.fr",
RPCMethod.LIST_NOTEBOOKS.value,
json.dumps([["real-data"]]),
None,
None,
None,
]
trailing_null = ["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, None]
chunks = [[final], [trailing_null]]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result == [["real-data"]]
def test_single_null_frame_still_returns_none(self):
"""A lone null frame returns None exactly as before (first==last)."""
chunks = [["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, None]]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result is None
def test_raises_on_error_chunk(self):
"""Test raises RPCError for error chunks."""
chunks = [
["er", RPCMethod.LIST_NOTEBOOKS.value, "Some error message", None, None],
]
with pytest.raises(RPCError, match="Some error message"):
extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
def test_error_frame_after_null_placeholder_still_raises(self):
"""An 'er' frame following a null placeholder is terminal and raises."""
placeholder = ["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, None]
error = ["er", RPCMethod.LIST_NOTEBOOKS.value, "Terminal failure", None, None]
chunks = [[placeholder], [error]]
with pytest.raises(RPCError, match="Terminal failure"):
extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
def test_handles_numeric_error_code(self):
"""Test handles numeric error codes."""
chunks = [
["er", RPCMethod.LIST_NOTEBOOKS.value, 403, None, None],
]
with pytest.raises(RPCError):
extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
def test_raises_on_user_displayable_error(self):
"""Test raises RateLimitError when UserDisplayableError is embedded in response.
Google's API returns this pattern for rate limiting, quota exceeded,
and other user-facing restrictions.
"""
# Real-world structure from API rate limit response
error_info = [
8,
None,
[
[
"type.googleapis.com/google.internal.labs.tailwind.orchestration.v1.UserDisplayableError",
[None, None, None, None, [None, [[1]], 2]],
]
],
]
chunks = [
[
"wrb.fr",
RPCMethod.LIST_NOTEBOOKS.value,
None, # null result
None,
None,
error_info,
"generic",
]
]
with pytest.raises(RateLimitError, match="rate limit"):
extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
def test_user_displayable_error_sets_code(self):
"""Test UserDisplayableError sets code to USER_DISPLAYABLE_ERROR."""
error_info = [8, None, [["UserDisplayableError", []]]]
chunks = [["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, error_info]]
with pytest.raises(RateLimitError) as exc_info:
extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert exc_info.value.rpc_code == "USER_DISPLAYABLE_ERROR"
assert "Upstream status code 8 (Resource exhausted)" in str(exc_info.value)
def test_null_result_without_error_info_returns_none(self):
"""Test null result without UserDisplayableError returns None normally."""
chunks = [["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, None]]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result is None
def test_null_result_with_non_error_info_returns_none(self):
"""Test null result with non-error data at index 5 returns None."""
chunks = [["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, [1, 2, 3]]]
result = extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
assert result is None
def test_user_displayable_error_in_dict_structure(self):
"""Test UserDisplayableError detection in dictionary structures.
While the batchexecute protocol typically uses arrays, this ensures
robustness if dict structures ever appear.
"""
error_info = {
"type": "type.googleapis.com/google.internal.labs.tailwind.orchestration.v1.UserDisplayableError",
"details": {"code": 1},
}
chunks = [["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, None, None, None, error_info]]
with pytest.raises(RateLimitError, match="rate limit"):
extract_rpc_result(chunks, RPCMethod.LIST_NOTEBOOKS.value)
def test_allow_null_preserves_passthrough_for_nonzero_codes(self):
"""allow_null=True callers must not see status-code enrichment.
REMOVE_RECENTLY_VIEWED legitimately returns `[13]` at index 5 as part
of a successful no-op response the caller opts into with
allow_null=True (see tests/cassettes/notebooks_remove_from_recent.yaml).
Don't raise in that case.
"""
chunk = json.dumps(
[
"wrb.fr",
RPCMethod.REMOVE_RECENTLY_VIEWED.value,
None,
None,
None,
[13],
"generic",
]
)
raw = f")]}}'\n{len(chunk)}\n{chunk}\n"
result = decode_response(raw, RPCMethod.REMOVE_RECENTLY_VIEWED.value, allow_null=True)
assert result is None
class TestDecodeResponse:
def test_full_decode_pipeline(self):
"""Test complete decode from raw response to result."""
inner_data = json.dumps([["My Notebook", "nb_123"]])
chunk = json.dumps(["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, inner_data, None, None])
raw_response = f")]}}'\n{len(chunk)}\n{chunk}\n"
result = decode_response(raw_response, RPCMethod.LIST_NOTEBOOKS.value)
assert result == [["My Notebook", "nb_123"]]
def test_decode_raises_on_missing_result(self):
"""Test decode raises if RPC ID not found."""
inner_data = json.dumps([])
chunk = json.dumps(["wrb.fr", "other_id", inner_data, None, None])
raw_response = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(RPCError, match="No result found"):
decode_response(raw_response, RPCMethod.LIST_NOTEBOOKS.value)
def test_decode_with_error_response(self):
"""Test decode when response contains error."""
chunk = json.dumps(["er", RPCMethod.LIST_NOTEBOOKS.value, "Authentication failed", None])
raw_response = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(RPCError, match="Authentication failed"):
decode_response(raw_response, RPCMethod.LIST_NOTEBOOKS.value)
def test_decode_complex_nested_data(self):
"""Test decoding complex nested data structures."""
data = {"notebooks": [{"id": "nb1", "title": "Test", "sources": [{"id": "s1"}]}]}
inner = json.dumps(data)
chunk = json.dumps(["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, inner, None, None])
raw_response = f")]}}'\n{len(chunk)}\n{chunk}\n"
result = decode_response(raw_response, RPCMethod.LIST_NOTEBOOKS.value)
assert result["notebooks"][0]["id"] == "nb1"
def test_decode_logs_rpc_ids_at_debug_level(self, caplog):
"""Test decode always logs RPC IDs at DEBUG level."""
import logging
inner_data = json.dumps([["data"]])
chunk = json.dumps(["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, inner_data, None, None])
raw_response = f")]}}'\n{len(chunk)}\n{chunk}\n"
with caplog.at_level(logging.DEBUG, logger="notebooklm.rpc.decoder"):
result = decode_response(raw_response, RPCMethod.LIST_NOTEBOOKS.value)
assert result == [["data"]]
assert "Looking for RPC ID: wXbhsf" in caplog.text
assert "Found RPC IDs in response: ['wXbhsf']" in caplog.text
def test_decode_missing_id_includes_found_ids_in_error(self):
"""Test error includes found_ids when RPC ID not found."""
inner_data = json.dumps([])
chunk = json.dumps(["wrb.fr", "NewMethodId", inner_data, None, None])
raw_response = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(RPCError) as exc_info:
decode_response(raw_response, "OldMethodId")
assert exc_info.value.found_ids == ["NewMethodId"]
assert "NewMethodId" in str(exc_info.value)
assert "may have changed" in str(exc_info.value)
def test_decode_error_response_includes_found_ids(self):
"""Test error response includes found_ids context."""
chunk = json.dumps(["er", RPCMethod.LIST_NOTEBOOKS.value, "Auth failed", None])
raw_response = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(RPCError) as exc_info:
decode_response(raw_response, RPCMethod.LIST_NOTEBOOKS.value)
assert exc_info.value.found_ids == [RPCMethod.LIST_NOTEBOOKS.value]
class TestCollectRpcIds:
def test_collects_single_id(self):
"""Test collecting single RPC ID from chunk."""
inner_data = json.dumps([])
chunks = [["wrb.fr", "TestId", inner_data, None, None]]
ids = collect_rpc_ids(chunks)
assert ids == ["TestId"]
def test_collects_multiple_ids(self):
"""Test collecting multiple RPC IDs from chunks."""
chunk1 = ["wrb.fr", "Id1", json.dumps([]), None, None]
chunk2 = ["wrb.fr", "Id2", json.dumps([]), None, None]
chunks = [chunk1, chunk2]
ids = collect_rpc_ids(chunks)
assert ids == ["Id1", "Id2"]
def test_collects_error_ids(self):
"""Test collecting IDs from error chunks."""
chunks = [["er", "ErrorId", "Error message", None]]
ids = collect_rpc_ids(chunks)
assert ids == ["ErrorId"]
def test_collects_both_success_and_error_ids(self):
"""Test collecting both success and error IDs."""
chunks = [
["wrb.fr", "SuccessId", json.dumps([]), None, None],
["er", "ErrorId", "Error", None],
]
ids = collect_rpc_ids(chunks)
assert ids == ["SuccessId", "ErrorId"]
def test_empty_chunks(self):
"""Test empty chunks returns empty list."""
assert collect_rpc_ids([]) == []
def test_ignores_non_list_chunks(self):
"""Test non-list chunks are ignored."""
chunks = ["string", 123, None, {"dict": True}]
ids = collect_rpc_ids(chunks)
assert ids == []
def test_ignores_malformed_chunks(self):
"""Test malformed chunks are ignored."""
chunks = [
["wrb.fr"], # Missing ID
["wrb.fr", 123], # Non-string ID
[], # Empty
]
ids = collect_rpc_ids(chunks)
assert ids == []
def test_handles_nested_chunks(self):
"""Test handles nested chunk structure."""
inner_chunk = ["wrb.fr", "NestedId", json.dumps([]), None, None]
chunks = [[inner_chunk]]
ids = collect_rpc_ids(chunks)
assert ids == ["NestedId"]
class TestRPCError:
def test_found_ids_stored(self):
"""Test found_ids is stored in exception."""
error = RPCError("message", method_id="Id1", found_ids=["Id2", "Id3"])
assert error.found_ids == ["Id2", "Id3"]
assert error.method_id == "Id1"
def test_found_ids_defaults_to_empty_list(self):
"""Test found_ids defaults to empty list when not provided."""
error = RPCError("message")
assert error.found_ids == []
def test_found_ids_none_becomes_empty_list(self):
"""Test found_ids=None becomes empty list."""
error = RPCError("message", found_ids=None)
assert error.found_ids == []
class TestIssue114Reproduction:
"""Reproduce Issue #114: GET_NOTEBOOK 'No result found' scenarios.
The user reported `notebooklm use` and `notebooklm ask` fail with
'No result found for RPC ID: rLM1Ne' while `notebooklm list` works.
These tests prove each distinct server response scenario that can
trigger this error, and verify improved diagnostic messages.
"""
RPC_ID = RPCMethod.GET_NOTEBOOK.value
def _build_raw(self, body: str) -> str:
"""Wrap body in anti-XSSI prefix."""
return f")]}}'\n{body}"
# Scenario A: Empty response — no chunks at all
def test_scenario_a_empty_response(self):
"""Empty response body after anti-XSSI prefix."""
raw = self._build_raw("")
with pytest.raises(RPCError, match="response contained no RPC data — 0 chunks parsed"):
decode_response(raw, self.RPC_ID)
# Scenario B: Non-RPC JSON — chunks exist but no wrb.fr/er items
def test_scenario_b_non_rpc_json(self):
"""Response has JSON chunks but none contain RPC data."""
chunk = json.dumps({"error": "something"})
body = f"{len(chunk)}\n{chunk}\n"
raw = self._build_raw(body)
with pytest.raises(RPCError, match="response contained no RPC data — 1 chunks parsed"):
decode_response(raw, self.RPC_ID)
# Scenario C: Null result data — wrb.fr found with matching ID but result is None
def test_scenario_c_null_result_data(self):
"""wrb.fr item has matching RPC ID but null result data."""
chunk = json.dumps(["wrb.fr", self.RPC_ID, None, None, None, None])
body = f"{len(chunk)}\n{chunk}\n"
raw = self._build_raw(body)
with pytest.raises(RPCError, match="returned null result data"):
decode_response(raw, self.RPC_ID)
# Scenario D: Short item (2 elements) — wrb.fr found but skipped by extract_rpc_result
def test_scenario_d_short_item(self):
"""wrb.fr item has only 2 elements, skipped by extract_rpc_result."""
chunk = json.dumps(["wrb.fr", self.RPC_ID])
body = f"{len(chunk)}\n{chunk}\n"
raw = self._build_raw(body)
# Short items are skipped by extract_rpc_result (len < 3),
# but collect_rpc_ids still finds the ID (len >= 2)
with pytest.raises(RPCError, match="returned null result data"):
decode_response(raw, self.RPC_ID)
def test_all_scenarios_include_method_id(self):
"""All failure scenarios set method_id on the exception."""
raw_empty = self._build_raw("")
with pytest.raises(RPCError) as exc_info:
decode_response(raw_empty, self.RPC_ID)
assert exc_info.value.method_id == self.RPC_ID
def test_null_result_includes_found_ids(self):
"""Null result scenario includes found_ids for debugging."""
chunk = json.dumps(["wrb.fr", self.RPC_ID, None, None, None, None])
body = f"{len(chunk)}\n{chunk}\n"
raw = self._build_raw(body)
with pytest.raises(RPCError) as exc_info:
decode_response(raw, self.RPC_ID)
assert self.RPC_ID in exc_info.value.found_ids
class TestNullResultStatusCodeEnrichment:
"""Verify decode_response enriches null-result errors with status codes.
Issues #114 / #294 saw GET_NOTEBOOK return a wrb.fr entry where result_data
is null and index 5 carries a bare `[code]`. These tests pin the new
enrichment path: NOT_FOUND / PERMISSION_DENIED must surface as ClientError
(which ``notebooklm._runtime.helpers.is_auth_error`` treats as non-auth),
other codes stay as RPCError, and the account-routing hint avoids legacy
auth-word substrings.
"""
RPC_ID = RPCMethod.GET_NOTEBOOK.value
# Keep this legacy auth-word list in sync with
# ``notebooklm._runtime.helpers.AUTH_ERROR_PATTERNS``.
_AUTH_PATTERNS = ("authentication", "expired", "unauthorized", "login", "re-authenticate")
def _build_raw(self, error_info: list | None) -> str:
chunk = json.dumps(["wrb.fr", self.RPC_ID, None, None, None, error_info, "generic"])
return f")]}}'\n{len(chunk)}\n{chunk}\n"
def _assert_no_auth_patterns(self, message: str) -> None:
lower = message.lower()
for pattern in self._AUTH_PATTERNS:
assert pattern not in lower, (
f"Message contains AUTH_ERROR_PATTERN {pattern!r}: would trigger "
f"spurious auth-refresh retry in is_auth_error: {message!r}"
)
def test_not_found_raises_client_error(self):
"""[5] → ClientError with rpc_code=5, 'Not found', authuser hint."""
with pytest.raises(ClientError) as exc_info:
decode_response(self._build_raw([5]), self.RPC_ID)
assert exc_info.value.rpc_code == 5
assert exc_info.value.method_id == self.RPC_ID
assert self.RPC_ID in exc_info.value.found_ids
message = str(exc_info.value)
assert "Not found" in message
assert "status code 5" in message
assert "authuser" in message.lower()
assert "#114" in message and "#294" in message
self._assert_no_auth_patterns(message)
def test_permission_denied_raises_client_error(self):
"""[7] → ClientError with rpc_code=7, 'Permission denied'."""
with pytest.raises(ClientError) as exc_info:
decode_response(self._build_raw([7]), self.RPC_ID)
assert exc_info.value.rpc_code == 7
message = str(exc_info.value)
assert "Permission denied" in message
assert "status code 7" in message
self._assert_no_auth_patterns(message)
def test_internal_code_raises_plain_rpc_error(self):
"""[13] with allow_null=False → RPCError (not ClientError) with rpc_code=13.
The account-routing hint (mentioning authuser / issues #114, #294) is
only meaningful for NOT_FOUND / PERMISSION_DENIED. Other codes like
INTERNAL must not carry it — it would mislead users about the cause.
"""
with pytest.raises(RPCError) as exc_info:
decode_response(self._build_raw([13]), self.RPC_ID)
assert not isinstance(exc_info.value, ClientError)
assert exc_info.value.rpc_code == 13
message = str(exc_info.value)
assert "status code 13" in message
assert "Internal" in message
assert "authuser" not in message.lower()
assert "#114" not in message and "#294" not in message
self._assert_no_auth_patterns(message)
def test_unauthenticated_code_does_not_become_auth_error(self):
"""[16] Unauthenticated stays plain RPCError — we do not infer auth.
The bare code is too ambiguous (see the REMOVE_RECENTLY_VIEWED [13]
success cassette) to auto-route into auth-refresh. Stay conservative.
"""
with pytest.raises(RPCError) as exc_info:
decode_response(self._build_raw([16]), self.RPC_ID)
assert not isinstance(exc_info.value, ClientError)
assert not isinstance(exc_info.value, AuthError)
assert exc_info.value.rpc_code == 16
message = str(exc_info.value)
# The label itself contains no AUTH_ERROR_PATTERNS substring; guard it.
self._assert_no_auth_patterns(message)
def test_out_of_range_code_falls_through_to_generic_error(self):
"""[99] is outside 0-16 gRPC range → no enrichment, generic message."""
with pytest.raises(RPCError) as exc_info:
decode_response(self._build_raw([99]), self.RPC_ID)
assert not isinstance(exc_info.value, ClientError)
assert exc_info.value.rpc_code is None
message = str(exc_info.value)
assert "returned null result data" in message
assert "status code" not in message
def test_multi_element_error_info_falls_through(self):
"""[5, null, 'x'] is not the bare form — no enrichment."""
with pytest.raises(RPCError) as exc_info:
decode_response(self._build_raw([5, None, "x"]), self.RPC_ID)
assert not isinstance(exc_info.value, ClientError)
assert exc_info.value.rpc_code is None
message = str(exc_info.value)
assert "returned null result data" in message
assert "status code" not in message
def test_allow_null_suppresses_enrichment_for_client_error_codes(self):
"""allow_null=True must short-circuit even for [5] / [7].
Fire-and-forget callers (REMOVE_RECENTLY_VIEWED, RENAME_NOTEBOOK, share)
opt into null results. They must not trip on enrichment.
"""
for code in (5, 7, 13, 16, 99):
result = decode_response(self._build_raw([code]), self.RPC_ID, allow_null=True)
assert result is None, f"allow_null=True leaked for code {code}"
def test_enriched_messages_surface_found_ids(self):
"""found_ids must appear in the message text, not just the attribute.
The base RPCError.__str__ does not append found_ids, so embedding it in
the message keeps the strongest drift/debug signal visible in plain logs
and tracebacks across all three null-result enrichment branches.
"""
for error_info in ([5], [13], [99]):
with pytest.raises(RPCError) as exc_info:
decode_response(self._build_raw(error_info), self.RPC_ID)
message = str(exc_info.value)
assert "Found IDs:" in message
assert self.RPC_ID in message
def test_boolean_error_info_is_not_treated_as_status_code(self):
"""[true] must not be accepted as code 1 — bool is a subclass of int.
``json.loads('[true]')`` yields ``[True]`` and ``isinstance(True, int)``
is ``True`` in Python, so a lax type check would misread a boolean as
status code 1 (CANCELLED). Guard with ``type(...) is int``.
"""
with pytest.raises(RPCError) as exc_info:
decode_response(self._build_raw([True]), self.RPC_ID)
assert not isinstance(exc_info.value, ClientError)
assert exc_info.value.rpc_code is None
message = str(exc_info.value)
assert "returned null result data" in message
assert "status code" not in message
class TestAuthError:
def test_auth_error_is_rpc_error_subclass(self):
"""AuthError should be a subclass of RPCError for backwards compatibility."""
from notebooklm.rpc import AuthError, RPCError
error = AuthError("Authentication expired")
assert isinstance(error, RPCError)
assert isinstance(error, AuthError)
def test_auth_error_message(self):
"""AuthError should preserve message and attributes."""
from notebooklm.rpc import AuthError
error = AuthError("Token expired", method_id="abc123")
assert str(error) == "Token expired"
assert error.method_id == "abc123"
class TestUnknownRPCMethodErrorRouting:
"""The 'requested rpc_id missing but other IDs found' branch routes to
``UnknownRPCMethodError`` (a ``DecodingError`` subclass), not plain
``RPCError``. Other null-result branches must keep raising ``RPCError``.
"""
def _build_raw_missing(self) -> tuple[str, str, list[str]]:
"""Build a response where requested ID is missing but another is present."""
requested = "OldMethodId"
actual = "NewMethodId"
inner = json.dumps([])
chunk = json.dumps(["wrb.fr", actual, inner, None, None])
raw = f")]}}'\n{len(chunk)}\n{chunk}\n"
return raw, requested, [actual]
def test_missing_id_raises_unknown_rpc_method_error(self):
raw, requested, found = self._build_raw_missing()
with pytest.raises(UnknownRPCMethodError) as exc_info:
decode_response(raw, requested)
err = exc_info.value
assert err.method_id == requested
assert err.found_ids == found
# raw_response must match the legacy RPCError preview semantics.
assert err.raw_response is not None
assert isinstance(err.raw_response, str)
# Message text unchanged.
assert "may have changed" in str(err)
assert requested in str(err)
def test_unknown_method_error_catchable_as_rpc_error(self):
raw, requested, _ = self._build_raw_missing()
with pytest.raises(RPCError):
decode_response(raw, requested)
def test_unknown_method_error_catchable_as_decoding_error(self):
raw, requested, _ = self._build_raw_missing()
with pytest.raises(DecodingError):
decode_response(raw, requested)
def test_unknown_method_error_catchable_as_specific_type(self):
raw, requested, _ = self._build_raw_missing()
with pytest.raises(UnknownRPCMethodError):
decode_response(raw, requested)
def test_status_code_branch_still_plain_rpc_error(self):
"""Negative test: status-code [13] branch must NOT reroute."""
rpc_id = RPCMethod.GET_NOTEBOOK.value
chunk = json.dumps(["wrb.fr", rpc_id, None, None, None, [13], "generic"])
raw = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(RPCError) as exc_info:
decode_response(raw, rpc_id)
# Must be plain RPCError (or ClientError for 5/7), but NEVER UnknownRPCMethodError.
assert not isinstance(exc_info.value, UnknownRPCMethodError)
def test_null_result_branch_still_plain_rpc_error(self):
"""Negative test: null-result-no-status branch must NOT reroute."""
rpc_id = RPCMethod.GET_NOTEBOOK.value
chunk = json.dumps(["wrb.fr", rpc_id, None, None, None, None])
raw = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(RPCError) as exc_info:
decode_response(raw, rpc_id)
assert not isinstance(exc_info.value, UnknownRPCMethodError)
def test_no_rpc_data_branch_still_plain_rpc_error(self):
"""Negative test: empty-chunks branch must NOT reroute."""
raw = ")]}'\n"
with pytest.raises(RPCError) as exc_info:
decode_response(raw, "AnyId")
assert not isinstance(exc_info.value, UnknownRPCMethodError)
def test_preserves_byte_for_byte_payload_of_legacy_branch(self):
"""raw_response, method_id, and found_ids match legacy RPCError shape."""
raw, requested, found = self._build_raw_missing()
with pytest.raises(UnknownRPCMethodError) as exc_info:
decode_response(raw, requested)
err = exc_info.value
# Same fields legacy RPCError populated.
assert err.method_id == requested
assert err.found_ids == found
assert err.raw_response is not None
# raw_response preview cap (80 chars + "..." = 83) preserved by the
# base RPCError contract (NOTEBOOKLM_DEBUG=1 opts into full body).
assert len(err.raw_response) <= 83
class TestAllowNullDoesNotMaskDrift:
"""Issue #1158: ``allow_null=True`` must not swallow method-ID drift or
anti-bot/redirect walls as a benign ``None``.
``allow_null`` only sanctions a ``wrb.fr`` frame that genuinely carried a
null payload (the requested ``rpc_id`` *is* present). An *absent* RPC ID
(drift) or a body with no RPC frames at all (anti-bot wall) is categorically
different and must still raise, even for opt-in callers.
"""
def test_allow_null_still_raises_on_method_id_drift(self):
"""Requested ID missing but another ID present → UnknownRPCMethodError,
even with allow_null=True."""
requested = "OldMethodId"
actual = "NewMethodId"
inner = json.dumps([])
chunk = json.dumps(["wrb.fr", actual, inner, None, None])
raw = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(UnknownRPCMethodError) as exc_info:
decode_response(raw, requested, allow_null=True)
err = exc_info.value
assert err.method_id == requested
assert err.found_ids == [actual]
assert "may have changed" in str(err)
def test_allow_null_still_raises_when_no_rpc_data(self):
"""Empty/anti-bot body with no RPC frames → RPCError, even with
allow_null=True."""
raw = ")]}'\n"
with pytest.raises(RPCError) as exc_info:
decode_response(raw, "AnyId", allow_null=True)
assert "response contained no RPC data" in str(exc_info.value)
assert not isinstance(exc_info.value, UnknownRPCMethodError)
def test_allow_null_still_raises_on_non_rpc_json_body(self):
"""A parseable but non-RPC JSON body (e.g. a redirect/error page) yields
no found_ids → RPCError, even with allow_null=True."""
chunk = json.dumps({"redirect": "https://accounts.google.com/"})
raw = f")]}}'\n{len(chunk)}\n{chunk}\n"
with pytest.raises(RPCError) as exc_info:
decode_response(raw, RPCMethod.CREATE_ARTIFACT.value, allow_null=True)
assert "response contained no RPC data" in str(exc_info.value)
assert not isinstance(exc_info.value, UnknownRPCMethodError)
def test_allow_null_returns_none_for_present_but_null_frame(self):
"""Regression guard: when the requested ID IS present with a null
payload, allow_null=True still returns None (the legitimate use case)."""
rpc_id = RPCMethod.CREATE_ARTIFACT.value
chunk = json.dumps(["wrb.fr", rpc_id, None, None, None])
raw = f")]}}'\n{len(chunk)}\n{chunk}\n"
result = decode_response(raw, rpc_id, allow_null=True)
assert result is None
class TestMalformedChunkResilience:
"""safe_index hot-path traversal must tolerate malformed chunks.
None of these inputs should raise; the decoder API surface (collect_rpc_ids,
extract_rpc_result) length-guards each chunk *before* invoking
``safe_index``, so structurally-empty/short chunks are skipped rather than
triggering a strict-decode ``UnknownRPCMethodError``.
"""
RPC_ID = RPCMethod.LIST_NOTEBOOKS.value
def test_collect_rpc_ids_handles_empty_chunk(self):
"""Empty list chunk is skipped without indexing into it."""
assert collect_rpc_ids([[]]) == []
def test_collect_rpc_ids_handles_non_list_chunk(self):
"""Non-list chunks (str / int / dict / None) are skipped silently."""
assert collect_rpc_ids(["nope", 123, None, {"k": "v"}]) == []
def test_collect_rpc_ids_handles_short_item(self):
"""Items shorter than 2 elements are skipped before indexing item[1]."""
chunks: list = [["wrb.fr"]] # missing rpc_id at index 1
assert collect_rpc_ids(chunks) == []
def test_collect_rpc_ids_handles_non_list_inner_item(self):
"""Non-list items inside a nested chunk are skipped."""
chunks: list = [["string-item", 42, None]]
# First element is a string, so items becomes [chunk] (the outer list),
# which has len=3 and item[0]="string-item" doesn't match tags. No raise.
assert collect_rpc_ids(chunks) == []
def test_collect_rpc_ids_handles_deeply_nested_malformed(self):
"""Deeply nested malformed structures do not crash the traversal."""
chunks: list = [[[None]], [[1, 2]], [[]], [[[]]], [["wrb.fr"]]]
# None of these have a (tag, rpc_id) pair with a valid string rpc_id.
assert collect_rpc_ids(chunks) == []
def test_extract_rpc_result_handles_empty_chunk(self):
"""Empty chunks are skipped in extract_rpc_result."""
assert extract_rpc_result([[]], self.RPC_ID) is None
def test_extract_rpc_result_handles_non_list_chunk(self):
"""Non-list chunks are skipped in extract_rpc_result."""
assert extract_rpc_result(["nope", 123, None], self.RPC_ID) is None
def test_extract_rpc_result_handles_missing_index_5(self):
"""wrb.fr items with len < 6 don't trigger UserDisplayableError lookup."""
# Item has exactly 3 elements: tag, rpc_id, null result. No index 5.
chunks: list = [["wrb.fr", self.RPC_ID, None]]
# Should return None (the null result) without raising IndexError.
assert extract_rpc_result(chunks, self.RPC_ID) is None
def test_extract_rpc_result_handles_short_item(self):
"""Items with len < 3 are skipped before any indexing."""
# Items shorter than 3 elements should be skipped — no IndexError.
chunks: list = [["wrb.fr", self.RPC_ID]]
assert extract_rpc_result(chunks, self.RPC_ID) is None
def test_extract_rpc_result_handles_deeply_nested_malformed(self):
"""Pathological nesting must not raise in extract_rpc_result."""
chunks: list = [
[],
[[]],
[None, None, None],
[["wrb.fr"]],
[["wrb.fr", self.RPC_ID]], # len < 3 — skipped
]
assert extract_rpc_result(chunks, self.RPC_ID) is None
def test_decode_response_handles_malformed_chunk_for_status_lookup(self):
"""_find_wrb_status (via decode_response) must not crash on malformed.
When decode_response hits the "null result data, look up status"
branch, the chunk list it scans may include malformed entries. None
of these should raise.
"""
# First a malformed chunk, then the legitimate null-result entry that
# decode_response will report on. Use GET_NOTEBOOK to trigger the
# status-lookup branch.
rpc_id = RPCMethod.GET_NOTEBOOK.value
good = json.dumps(["wrb.fr", rpc_id, None, None, None, None])
bad_short = json.dumps(["wrb.fr"]) # malformed, len < 6
bad_empty = json.dumps([])
body = (
f"{len(bad_short)}\n{bad_short}\n{len(bad_empty)}\n{bad_empty}\n{len(good)}\n{good}\n"
)
raw = f")]}}'\n{body}"
# decode_response will still raise RPCError for the null result, but
# the malformed-chunk traversal must not raise IndexError on its way
# there.
with pytest.raises(RPCError, match="returned null result data"):
decode_response(raw, rpc_id)
class TestGetErrorMessageForCode:
"""Parametrized coverage for ``get_error_message_for_code``.
Covers every known code in ``_ERROR_CODE_MESSAGES``, the ``None``
sentinel, the 4xx/5xx fallback ranges, and out-of-range codes.
"""
@pytest.mark.parametrize(
("code", "expected_substring", "expected_retryable"),
[
pytest.param(
RPCErrorCode.INVALID_REQUEST,
"Invalid request parameters",
False,
id="invalid_request_400",
),
pytest.param(
RPCErrorCode.UNAUTHORIZED,
"Authentication required",
False,
id="unauthorized_401",
),
pytest.param(
RPCErrorCode.FORBIDDEN,
"Insufficient permissions",
False,
id="forbidden_403",
),
pytest.param(
RPCErrorCode.NOT_FOUND,
"Requested resource not found",
False,
id="not_found_404",
),
pytest.param(
RPCErrorCode.RATE_LIMITED,
"rate limit exceeded",
True,
id="rate_limited_429",
),
pytest.param(
RPCErrorCode.SERVER_ERROR,
"Server error occurred",
True,
id="server_error_500",
),
],
)
def test_error_code_known_returns_mapped_message(
self, code: int, expected_substring: str, expected_retryable: bool
) -> None:
"""Every known mapped code returns its tailored message + retry flag."""
message, is_retryable = get_error_message_for_code(int(code))
assert expected_substring in message
assert is_retryable is expected_retryable
def test_error_code_400_invalid_request(self) -> None:
"""400 maps to the INVALID_REQUEST table entry, not the 4xx fallback."""
message, is_retryable = get_error_message_for_code(400)
assert message == "Invalid request parameters. Check your input and try again."
assert is_retryable is False
def test_error_code_401_unauthorized(self) -> None:
message, is_retryable = get_error_message_for_code(401)
assert "notebooklm login" in message
assert is_retryable is False
def test_error_code_403_forbidden(self) -> None:
message, is_retryable = get_error_message_for_code(403)
assert message == "Insufficient permissions for this operation."
assert is_retryable is False
def test_error_code_404_not_found(self) -> None:
message, is_retryable = get_error_message_for_code(404)
assert message == "Requested resource not found."
assert is_retryable is False
def test_error_code_429_rate_limit(self) -> None:
message, is_retryable = get_error_message_for_code(429)
assert "rate limit" in message.lower()
assert is_retryable is True
def test_error_code_500_server_error(self) -> None:
message, is_retryable = get_error_message_for_code(500)
assert "Server error" in message
assert is_retryable is True
def test_error_code_none_returns_generic_unknown(self) -> None:
"""``None`` sentinel returns the generic non-retryable message."""
message, is_retryable = get_error_message_for_code(None)
assert message == "Unknown error occurred."
assert is_retryable is False
@pytest.mark.parametrize(
("code", "expected_message", "expected_retryable"),
[
pytest.param(
402,
"Client error 402. Check your request parameters.",
False,
id="unmapped_4xx_402",
),
pytest.param(
418,
"Client error 418. Check your request parameters.",
False,
id="unmapped_4xx_418_teapot",
),
pytest.param(
422,
"Client error 422. Check your request parameters.",
False,
id="unmapped_4xx_422",
),
pytest.param(
499,
"Client error 499. Check your request parameters.",
False,
id="unmapped_4xx_499_upper_edge",
),
],
)
def test_error_code_unmapped_4xx_generic_client_error(
self, code: int, expected_message: str, expected_retryable: bool
) -> None:
"""Unknown 400-499 codes get the generic client-error fallback."""
message, is_retryable = get_error_message_for_code(code)
assert message == expected_message
assert is_retryable is expected_retryable
@pytest.mark.parametrize(
("code", "expected_message", "expected_retryable"),
[
pytest.param(
501,
"Server error 501. This is usually temporary - try again later.",
True,
id="unmapped_5xx_501",
),
pytest.param(
502,
"Server error 502. This is usually temporary - try again later.",
True,
id="unmapped_5xx_502_bad_gateway",
),
pytest.param(
503,
"Server error 503. This is usually temporary - try again later.",
True,
id="unmapped_5xx_503",
),
pytest.param(
599,
"Server error 599. This is usually temporary - try again later.",
True,
id="unmapped_5xx_599_upper_edge",
),
],
)
def test_error_code_unmapped_5xx_generic_server_error(
self, code: int, expected_message: str, expected_retryable: bool
) -> None:
"""Unknown 500-599 codes get the generic retryable server-error fallback."""
message, is_retryable = get_error_message_for_code(code)
assert message == expected_message
assert is_retryable is expected_retryable
@pytest.mark.parametrize(
("code", "expected_message"),
[
pytest.param(0, "Error code: 0", id="zero_edge_case"),
pytest.param(1, "Error code: 1", id="positive_below_4xx_grpc_cancelled"),
pytest.param(13, "Error code: 13", id="grpc_internal_13"),
pytest.param(200, "Error code: 200", id="success_range_200"),
pytest.param(399, "Error code: 399", id="just_below_4xx_399"),
pytest.param(600, "Error code: 600", id="just_above_5xx_600"),
pytest.param(999, "Error code: 999", id="three_digit_999"),
pytest.param(123456, "Error code: 123456", id="very_large_code"),
pytest.param(-1, "Error code: -1", id="negative_code"),
],
)
def test_error_code_out_of_range_generic_fallback(
self, code: int, expected_message: str
) -> None:
"""Codes outside 400-599 get the bare ``Error code: <n>`` fallback (non-retryable)."""
message, is_retryable = get_error_message_for_code(code)
assert message == expected_message
assert is_retryable is False
def test_returns_tuple_of_str_and_bool(self) -> None:
"""Return contract: always ``tuple[str, bool]`` regardless of input."""
for code in (None, 0, 400, 429, 500, 502, 999, -1):
message, is_retryable = get_error_message_for_code(code)
assert isinstance(message, str)
assert isinstance(is_retryable, bool)
def test_every_rpc_error_code_enum_value_covered_or_fallback(self) -> None:
"""Each ``RPCErrorCode`` enum value resolves to a non-empty message.
Belt-and-suspenders: future additions to ``RPCErrorCode`` either land
in ``_ERROR_CODE_MESSAGES`` or fall through to the range-based
fallback. Either way ``get_error_message_for_code`` must produce a
non-empty human-readable message.
"""
for code in RPCErrorCode:
message, is_retryable = get_error_message_for_code(int(code))
assert isinstance(message, str) and message
assert isinstance(is_retryable, bool)