09e9f3545f
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
126 lines
5.1 KiB
Python
126 lines
5.1 KiB
Python
"""Regression test for the httpx connection-pool tuning via ConnectionLimits.
|
|
|
|
Audit item #3 (`thread-safety-concurrency-audit.md` §3, also §19):
|
|
Pre-fix, runtime configuration used by ``NotebookLMClient.__init__`` /
|
|
``from_storage`` did not pass a ``limits=`` kwarg to ``httpx.AsyncClient(...)``,
|
|
defaulting to httpx's `~100 / 20-per-host` pool.
|
|
Heavy fan-out workloads (FastAPI services sharing a client across many
|
|
concurrent requests, large `wait_for_sources` batches) tripped
|
|
`httpx.PoolTimeout`.
|
|
|
|
Post-fix: a stable `ConnectionLimits` dataclass on
|
|
`notebooklm.types` exposes pool tuning, defaults to
|
|
`max_connections=100, max_keepalive_connections=50,
|
|
keepalive_expiry=30.0`, and is plumbed through runtime configuration used by
|
|
``NotebookLMClient.__init__`` / ``from_storage``.
|
|
|
|
The test asserts the limits passed to `httpx.AsyncClient` match the
|
|
configured values. A real-pool stress test (200-way fan-out against
|
|
a stdlib http.server) is the audit's preferred verification, but the
|
|
deterministic constructor-args check is sufficient and avoids the
|
|
infrastructure overhead of an in-process server in the test suite.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import notebooklm._runtime.init as _runtime_init
|
|
from notebooklm import NotebookLMClient
|
|
from notebooklm.types import ConnectionLimits
|
|
|
|
# pool-config + patch-based tuning tests; no HTTP, no cassette.
|
|
# Opt out of the tier-enforcement hook in tests/integration/conftest.py.
|
|
pytestmark = pytest.mark.allow_no_vcr
|
|
|
|
|
|
def test_connection_limits_dataclass_defaults() -> None:
|
|
"""The defaults match the audit's recommendation for typical fan-out."""
|
|
limits = ConnectionLimits()
|
|
assert limits.max_connections == 100
|
|
assert limits.max_keepalive_connections == 50
|
|
assert limits.keepalive_expiry == 30.0
|
|
|
|
|
|
def test_connection_limits_to_httpx_limits_round_trip() -> None:
|
|
"""to_httpx_limits() preserves all three fields."""
|
|
cl = ConnectionLimits(
|
|
max_connections=200,
|
|
max_keepalive_connections=80,
|
|
keepalive_expiry=15.0,
|
|
)
|
|
hl = cl.to_httpx_limits()
|
|
assert isinstance(hl, httpx.Limits)
|
|
assert hl.max_connections == 200
|
|
assert hl.max_keepalive_connections == 80
|
|
assert hl.keepalive_expiry == 15.0
|
|
|
|
|
|
async def test_default_limits_passed_to_async_client(auth_tokens, monkeypatch) -> None:
|
|
"""No explicit ``limits=`` -> runtime config uses ConnectionLimits() defaults."""
|
|
captured: dict[str, httpx.Limits | None] = {"limits": None}
|
|
calls = {"count": 0}
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def _capturing_client(**kwargs: object) -> httpx.AsyncClient:
|
|
calls["count"] += 1
|
|
captured["limits"] = kwargs.get("limits") # type: ignore[assignment]
|
|
return real_async_client(**kwargs) # type: ignore[arg-type]
|
|
|
|
# ADR-0007 Form-2: object-form patch against the locally-imported
|
|
# `_runtime.init` seam alias. `_runtime_init.httpx` is the same module
|
|
# object the production factory reads `AsyncClient` off of, so patching
|
|
# the attribute here intercepts default-path client construction.
|
|
monkeypatch.setattr(_runtime_init.httpx, "AsyncClient", _capturing_client)
|
|
async with NotebookLMClient(auth_tokens):
|
|
pass
|
|
|
|
# Bite-check: the injected seam was actually exercised.
|
|
assert calls["count"] >= 1
|
|
captured_limits = captured["limits"]
|
|
assert isinstance(captured_limits, httpx.Limits)
|
|
assert captured_limits.max_connections == 100
|
|
assert captured_limits.max_keepalive_connections == 50
|
|
assert captured_limits.keepalive_expiry == 30.0
|
|
|
|
|
|
async def test_custom_limits_passed_to_async_client(auth_tokens, monkeypatch) -> None:
|
|
"""Explicit `limits=ConnectionLimits(...)` -> AsyncClient sees those values."""
|
|
custom = ConnectionLimits(
|
|
max_connections=500,
|
|
max_keepalive_connections=100,
|
|
keepalive_expiry=10.0,
|
|
)
|
|
captured: dict[str, httpx.Limits | None] = {"limits": None}
|
|
calls = {"count": 0}
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def _capturing_client(**kwargs: object) -> httpx.AsyncClient:
|
|
calls["count"] += 1
|
|
captured["limits"] = kwargs.get("limits") # type: ignore[assignment]
|
|
return real_async_client(**kwargs) # type: ignore[arg-type]
|
|
|
|
# ADR-0007 Form-2: object-form patch against the locally-imported
|
|
# `_runtime.init` seam alias (see the default-limits test above).
|
|
monkeypatch.setattr(_runtime_init.httpx, "AsyncClient", _capturing_client)
|
|
async with NotebookLMClient(auth_tokens, limits=custom):
|
|
pass
|
|
|
|
# Bite-check: the injected seam was actually exercised.
|
|
assert calls["count"] >= 1
|
|
captured_limits = captured["limits"]
|
|
assert isinstance(captured_limits, httpx.Limits)
|
|
assert captured_limits.max_connections == 500
|
|
assert captured_limits.max_keepalive_connections == 100
|
|
assert captured_limits.keepalive_expiry == 10.0
|
|
|
|
|
|
def test_connection_limits_is_frozen() -> None:
|
|
"""Frozen dataclass — callers can't accidentally mutate after passing in."""
|
|
import dataclasses
|
|
|
|
limits = ConnectionLimits()
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
limits.max_connections = 999 # type: ignore[misc]
|