Spaces:
Sleeping
Sleeping
File size: 13,142 Bytes
7d7841f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 | """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()
|