Spaces:
Running
Running
Download backend/tests/test_bot_access_serial.py from ColdAshSage/machineread: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/ColdAshSage/machineread/resolve/main/backend/tests/test_bot_access_serial.py
- Command line
-
hf download hf://spaces/ColdAshSage/machineread/backend/tests/test_bot_access_serial.py
-
curl -L -o test_bot_access_serial.py https://huggingface.co/spaces/ColdAshSage/machineread/resolve/main/backend/tests/test_bot_access_serial.py
13.1 kB
| """serialized bot probes with 429 retry. | |
| These tests target :func:`app.checks.bot_access._probe_bots` and | |
| :func:`app.checks.bot_access._probe_one_bot` directly so we can assert the | |
| new pacing + retry contract without spinning up the full check or doing real | |
| HTTP. Sequencing is verified via mock side effects (``call_in_flight`` and | |
| ``call_order``) rather than wall-clock timing, because we mock | |
| ``asyncio.sleep`` and do not want the suite to take ~4.5 real seconds. | |
| """ | |
| import asyncio | |
| import sys | |
| import unittest | |
| from pathlib import Path | |
| from unittest.mock import AsyncMock, patch | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| from app.checks import bot_access | |
| from app.checks.bot_access import ( | |
| _RETRY_DELAY, | |
| _STAGGER_DELAY, | |
| _probe_bots, | |
| _probe_one_bot, | |
| ) | |
| from app.fetching import FetchResult | |
| from backend.tests.fixtures import EXAMPLE_URL, make_audit_context, make_fetch_result | |
| # A small deterministic matrix β we do not need the full 9-bot registry here. | |
| # The production code reads ``_MATRIX_BOTS`` from the registry, but for these | |
| # tests the function takes the matrix as an argument so we can keep the test | |
| # small and explicit. | |
| _TEST_MATRIX = ["BotA", "BotB", "BotC"] | |
| _TEST_USER_AGENTS = { | |
| "BotA": "BotA/1.0 (+https://example.com/a)", | |
| "BotB": "BotB/1.0 (+https://example.com/b)", | |
| "BotC": "BotC/1.0 (+https://example.com/c)", | |
| } | |
| def _ok_fetch(url: str = EXAMPLE_URL, status_code: int = 200, text: str = "<html><body>ok</body></html>") -> FetchResult: | |
| """Return a minimal successful ``FetchResult`` for the test url.""" | |
| return make_fetch_result(url, text, status_code=status_code) | |
| def _body_with_words(word_count: int = 100) -> str: | |
| """Return a body with at least ``word_count`` words so the thin-response | |
| check in ``_blocked_reason`` does not flag it as blocked. | |
| """ | |
| sentence = "Accessible homepage content for agents and search. " | |
| repeats = max(1, word_count // len(sentence.split())) | |
| return "<html><body>" + sentence * repeats + "</body></html>" | |
| def _baseline() -> tuple[FetchResult, bot_access.RoutingSnapshot, int]: | |
| """Return a (baseline_fetch, baseline_snapshot, baseline_words) triple.""" | |
| fetch = _ok_fetch(text=_body_with_words(120)) | |
| snapshot = bot_access._routing_snapshot(fetch) | |
| return fetch, snapshot, snapshot.visible_words | |
| class ProbeBotsSequentialTests(unittest.IsolatedAsyncioTestCase): | |
| """Probes must run sequentially with a stagger between each, not via ``gather``.""" | |
| async def test_probes_execute_sequentially_not_concurrently(self) -> None: | |
| """With ``asyncio.gather`` the in-flight count would hit the matrix length. | |
| With the new serial loop it should never exceed 1. | |
| """ | |
| baseline_fetch, baseline_snapshot, baseline_words = _baseline() | |
| call_order: list[str] = [] | |
| in_flight = 0 | |
| max_in_flight = 0 | |
| started = asyncio.Event() | |
| can_finish = asyncio.Event() | |
| bot_body = _body_with_words(120) | |
| async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult: | |
| nonlocal in_flight, max_in_flight | |
| in_flight += 1 | |
| max_in_flight = max(max_in_flight, in_flight) | |
| call_order.append(f"start:{user_agent}") | |
| if len(call_order) == 1: | |
| # First probe: signal start, then hold until released. This | |
| # gives any subsequent (concurrent) probe a chance to run | |
| # before we return, which ``gather`` would exploit. With the | |
| # new serial loop no second probe enters until ``can_finish`` | |
| # is set and we exit. | |
| started.set() | |
| await can_finish.wait() | |
| call_order.append(f"end:{user_agent}") | |
| in_flight -= 1 | |
| return _ok_fetch(text=bot_body) | |
| with ( | |
| patch("app.checks.bot_access.fetch_url", new=fake_fetch), | |
| patch("app.checks.bot_access.asyncio.sleep", new=AsyncMock()), | |
| ): | |
| task = asyncio.create_task( | |
| _probe_bots( | |
| EXAMPLE_URL, | |
| _TEST_USER_AGENTS, | |
| _TEST_MATRIX, | |
| baseline_fetch=baseline_fetch, | |
| baseline_snapshot=baseline_snapshot, | |
| baseline_words=baseline_words, | |
| ) | |
| ) | |
| # Give the first probe time to enter and signal. | |
| await started.wait() | |
| # Tiny yield to let any rogue concurrent probe sneak in. Under | |
| # ``asyncio.gather`` it would; under the serial loop it would not. | |
| await asyncio.sleep(0) | |
| await asyncio.sleep(0) | |
| self.assertEqual(in_flight, 1, "Second probe ran while the first was still in-flight (gather burst!)") | |
| can_finish.set() | |
| blocked, routing_differences, accessible_count = await task | |
| # Under the serial contract, max concurrency is exactly 1. | |
| self.assertEqual(max_in_flight, 1) | |
| # All 3 probes finished. | |
| self.assertEqual(accessible_count, 3) | |
| self.assertEqual(blocked, []) | |
| self.assertEqual(routing_differences, []) | |
| # The call order must interleave start/end for each bot (no two | |
| # starts in a row before any end), proving serial execution. | |
| starts = [c for c in call_order if c.startswith("start:")] | |
| ends = [c for c in call_order if c.startswith("end:")] | |
| self.assertEqual(starts[0], ends[0].replace("end:", "start:")) | |
| self.assertEqual(len(starts), len(_TEST_MATRIX)) | |
| self.assertEqual(len(ends), len(_TEST_MATRIX)) | |
| async def test_stagger_delay_applied_between_probes(self) -> None: | |
| """``asyncio.sleep`` is called with ``_STAGGER_DELAY`` between probes, | |
| exactly ``len(matrix) - 1`` times in the initial wave. | |
| """ | |
| baseline_fetch, baseline_snapshot, baseline_words = _baseline() | |
| sleep_calls: list[float] = [] | |
| async def fake_sleep(seconds: float) -> None: | |
| sleep_calls.append(seconds) | |
| async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult: | |
| return _ok_fetch(text=_body_with_words(120)) | |
| with ( | |
| patch("app.checks.bot_access.fetch_url", new=fake_fetch), | |
| patch("app.checks.bot_access.asyncio.sleep", new=fake_sleep), | |
| ): | |
| await _probe_bots( | |
| EXAMPLE_URL, | |
| _TEST_USER_AGENTS, | |
| _TEST_MATRIX, | |
| baseline_fetch=baseline_fetch, | |
| baseline_snapshot=baseline_snapshot, | |
| baseline_words=baseline_words, | |
| ) | |
| # 3 probes -> 2 staggers between them. | |
| self.assertEqual(sleep_calls, [_STAGGER_DELAY] * (len(_TEST_MATRIX) - 1)) | |
| # Sanity: the stagger constant is the configured 0.5s. | |
| self.assertEqual(_STAGGER_DELAY, 0.5) | |
| class ProbeOneBotRetryTests(unittest.IsolatedAsyncioTestCase): | |
| """HTTP 429 must trigger exactly one retry after ``_RETRY_DELAY``.""" | |
| async def test_429_first_call_retried_uses_second_result(self) -> None: | |
| """First call returns 429, second returns 200. The retry result is used.""" | |
| call_log: list[str] = [] | |
| sleep_log: list[float] = [] | |
| async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult: | |
| call_log.append(user_agent) | |
| if len(call_log) == 1: | |
| return _ok_fetch(status_code=429) | |
| return _ok_fetch(status_code=200) | |
| async def fake_sleep(seconds: float) -> None: | |
| sleep_log.append(seconds) | |
| with ( | |
| patch("app.checks.bot_access.fetch_url", new=fake_fetch), | |
| patch("app.checks.bot_access.asyncio.sleep", new=fake_sleep), | |
| ): | |
| result = await _probe_one_bot(EXAMPLE_URL, _TEST_USER_AGENTS["BotA"]) | |
| # Exactly one retry β fetch_url called twice, with the same UA. | |
| self.assertEqual(len(call_log), 2) | |
| self.assertEqual(call_log[0], call_log[1]) | |
| # The retry delay was awaited once, with the configured value. | |
| self.assertEqual(sleep_log, [_RETRY_DELAY]) | |
| # And the returned FetchResult is the second (200) one, not the 429. | |
| self.assertEqual(result.status_code, 200) | |
| async def test_429_retry_exhausted_counted_as_blocked(self) -> None: | |
| """Both calls return 429 β caller receives 429 β counted as blocked.""" | |
| async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult: | |
| return _ok_fetch(status_code=429) | |
| baseline_fetch, baseline_snapshot, baseline_words = _baseline() | |
| call_count = 0 | |
| async def counting_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult: | |
| nonlocal call_count | |
| call_count += 1 | |
| return _ok_fetch(status_code=429) | |
| with ( | |
| patch("app.checks.bot_access.fetch_url", new=counting_fetch), | |
| patch("app.checks.bot_access.asyncio.sleep", new=AsyncMock()), | |
| ): | |
| blocked, _, accessible_count = await _probe_bots( | |
| EXAMPLE_URL, | |
| _TEST_USER_AGENTS, | |
| _TEST_MATRIX, | |
| baseline_fetch=baseline_fetch, | |
| baseline_snapshot=baseline_snapshot, | |
| baseline_words=baseline_words, | |
| ) | |
| # Every probe 429'd, so each one retried once β 2 * 3 = 6 fetch calls. | |
| self.assertEqual(call_count, 2 * len(_TEST_MATRIX)) | |
| self.assertEqual(accessible_count, 0) | |
| self.assertEqual(len(blocked), len(_TEST_MATRIX)) | |
| # Each entry follows ``"BotName (HTTP 429)"`` β the literal reason | |
| # emitted by ``_blocked_reason`` when status_code is in {401,403,429,503}. | |
| for entry in blocked: | |
| self.assertIn("HTTP 429", entry) | |
| async def test_429_retry_distinct_from_stagger_delay(self) -> None: | |
| """The retry sleep uses ``_RETRY_DELAY`` (2.0s) and the inter-probe | |
| sleep uses ``_STAGGER_DELAY`` (0.5s); they must never be conflated. | |
| """ | |
| sleep_log: list[float] = [] | |
| async def fake_sleep(seconds: float) -> None: | |
| sleep_log.append(seconds) | |
| # Force every probe to 429 so both delays fire on every iteration. | |
| async def always_429(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult: | |
| return _ok_fetch(status_code=429) | |
| baseline_fetch, baseline_snapshot, baseline_words = _baseline() | |
| with ( | |
| patch("app.checks.bot_access.fetch_url", new=always_429), | |
| patch("app.checks.bot_access.asyncio.sleep", new=fake_sleep), | |
| ): | |
| await _probe_bots( | |
| EXAMPLE_URL, | |
| _TEST_USER_AGENTS, | |
| _TEST_MATRIX, | |
| baseline_fetch=baseline_fetch, | |
| baseline_snapshot=baseline_snapshot, | |
| baseline_words=baseline_words, | |
| ) | |
| # Per probe: 1 retry sleep at _RETRY_DELAY. Between probes: | |
| # (matrix_len - 1) stagger sleeps at _STAGGER_DELAY. | |
| expected = ( | |
| [_RETRY_DELAY] * len(_TEST_MATRIX) # one retry per probe | |
| + [_STAGGER_DELAY] * (len(_TEST_MATRIX) - 1) # N-1 staggers | |
| ) | |
| # The retry sleeps happen *inside* each probe (before the next stagger), | |
| # so the actual order is: retry_1, stagger, retry_2, stagger, retry_3. | |
| # We only need to assert the values and counts here, not the order. | |
| self.assertEqual(sorted(sleep_log), sorted(expected)) | |
| self.assertEqual(sleep_log.count(_RETRY_DELAY), len(_TEST_MATRIX)) | |
| self.assertEqual(sleep_log.count(_STAGGER_DELAY), len(_TEST_MATRIX) - 1) | |
| # And the two constants really are distinct, so a regression that | |
| # accidentally reuses one for the other would show up here too. | |
| self.assertNotEqual(_STAGGER_DELAY, _RETRY_DELAY) | |
| class CheckBotAccessContractTests(unittest.IsolatedAsyncioTestCase): | |
| """End-to-end: ``check_bot_access`` still scores correctly under serialization.""" | |
| async def test_passing_matrix_still_scores_6_of_6(self) -> None: | |
| """Sanity check that the serialization wrapper preserves the existing | |
| ``pass`` / score=6 outcome on a healthy matrix. | |
| """ | |
| body = "<html><body>" + ("healthy homepage content for agents. " * 80) + "</body></html>" | |
| context = make_audit_context(homepage_html=body) | |
| async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult: | |
| return _ok_fetch(text=body) | |
| with ( | |
| patch("app.checks.bot_access.fetch_url", new=fake_fetch), | |
| patch("app.checks.bot_access.asyncio.sleep", new=AsyncMock()), | |
| ): | |
| from app.checks.bot_access import check_bot_access | |
| result = await check_bot_access(context) | |
| self.assertEqual(result.state, "pass") | |
| self.assertEqual(result.score, 6) | |
| self.assertEqual(result.max_score, 6) | |
| if __name__ == "__main__": | |
| unittest.main() | |