signal-bot/tests/test_latency.py
Krzysztof kuhy Rudnicki 4af32f8bd5 Handle frames side by side, and budget the bot's own share of a reply
Bot.listen awaited every frame to completion, so one Commons fetch or one
attachment upload held up every text reply behind it (DOCS-latency.md).
Each frame now runs in its own task, never more than LIMIT at once; the
reader waits for a slot before spawning, so a burst applies back-pressure
at the websocket instead of fanning out upstream. One person's commands
stay serialised (user_lock.py): two quick !kot would otherwise both pass
the cooldown gate before either recorded a use.

Every served command reports its timings split into the bot's own time
and everybody else's (served.py); /_selftest carries that block and
tool/latency.py holds own_ms to 100 ms at p95, with the command's
cooldown switched off for the run and put back afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:35:34 +02:00

156 lines
5.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The latency run: what it types, what it restores, and how it judges."""
from __future__ import annotations
import json
from typing import Any
import httpx
import pytest
from signal_bot.latency import (
LatencyError,
Report,
Sample,
measure,
percentile,
)
from tests.conftest import mock_client
BASE = "http://bot"
# The first sample, the row read, and the row overridden: the second sample
# is the first one made after the cooldown is off.
SETUP_CALLS = 3
def _served(own_ms: int, **overrides: int | str) -> dict[str, Any]:
base: dict[str, Any] = {
"command": "help",
"outcome": "ok",
"queue_ms": 0,
"dispatch_ms": 1,
"handler_ms": own_ms + 40,
"send_ms": 40,
"upstream_ms": 0,
"own_ms": own_ms + 1,
}
return {**base, **overrides}
class _Bot:
"""Enough of the bot's HTTP face for the run: selftest and the switchboard."""
def __init__(self, own_ms: list[int], *, listed: bool = True) -> None:
self.own_ms = own_ms
self.listed = listed
self.requests: list[tuple[str, str, Any]] = []
self.setting: dict[str, Any] = {
"enabled": True,
"disabled_until": None,
"cooldown_seconds": 30.0,
}
def __call__(self, request: httpx.Request) -> httpx.Response:
body = json.loads(request.content) if request.content else None
self.requests.append((request.method, request.url.path, body))
if request.url.path == "/_selftest":
served = _served(self.own_ms.pop(0))
return httpx.Response(
200, json={"status": "delivered", "group": "g", "text": "!pomoc", "served": served}
)
if request.url.path == "/_commands":
# Another command first, so the lookup has to walk past it.
rows = [{"name": "cat", **self.setting}]
if self.listed:
rows.append({"name": "help", **self.setting})
return httpx.Response(200, json={"commands": rows})
assert request.url.path == "/_commands/help"
self.setting = body
return httpx.Response(200, json={"name": "help", **body})
async def test_a_run_types_the_command_with_the_cooldown_off_and_puts_it_back() -> None:
bot = _Bot([5, 7, 6])
report = await measure(mock_client(bot), BASE, "testbot", "!pomoc", times=3)
assert report.command == "help"
assert [s["own_ms"] for s in report.samples] == [6, 8, 7]
assert report.within_budget
methods = [(m, p) for m, p, _ in bot.requests]
assert methods == [
("POST", "/_selftest"),
("GET", "/_commands"),
("PUT", "/_commands/help"),
("POST", "/_selftest"),
("POST", "/_selftest"),
("PUT", "/_commands/help"),
]
# Zero for the run, then exactly the row that was there before.
assert bot.requests[2][2]["cooldown_seconds"] == 0
assert bot.setting == {"enabled": True, "disabled_until": None, "cooldown_seconds": 30.0}
async def test_the_row_is_put_back_even_when_a_later_reply_fails() -> None:
class _Breaks(_Bot):
def __call__(self, request: httpx.Request) -> httpx.Response:
if request.url.path == "/_selftest" and len(self.requests) >= SETUP_CALLS:
self.requests.append((request.method, request.url.path, None))
return httpx.Response(503, json={"status": "connecting", "reason": "down"})
return super().__call__(request)
bot = _Breaks([5, 7, 6])
with pytest.raises(LatencyError, match="down"):
await measure(mock_client(bot), BASE, "testbot", "!pomoc", times=3)
assert bot.setting["cooldown_seconds"] == 30.0
async def test_a_refused_group_is_reported_with_the_bots_reason() -> None:
client = mock_client(
lambda _: httpx.Response(
403, json={"status": "refused", "reason": "not in SELFTEST_GROUPS"}
)
)
with pytest.raises(LatencyError, match="not in SELFTEST_GROUPS"):
await measure(client, BASE, "prod", "!pomoc", times=2)
async def test_text_that_is_not_a_command_is_refused_rather_than_timed() -> None:
client = mock_client(
lambda _: httpx.Response(200, json={"status": "delivered", "group": "g", "text": "hej"})
)
with pytest.raises(LatencyError, match="not a command"):
await measure(client, BASE, "testbot", "hej", times=2)
async def test_a_command_the_bot_ran_but_does_not_list_is_an_error() -> None:
bot = _Bot([5], listed=False)
with pytest.raises(LatencyError, match="does not list"):
await measure(mock_client(bot), BASE, "testbot", "!pomoc", times=1)
def test_percentiles_are_nearest_rank() -> None:
values = list(range(1, 21))
assert percentile(values, 50) == 10
assert percentile(values, 95) == 19
assert percentile([42], 95) == 42
def test_the_report_judges_the_p95_of_own_time_and_renders_every_column() -> None:
samples = tuple(
Sample(served=_served(own_ms), round_trip_ms=own_ms + 300)
for own_ms in [3, 4, 5, 6, 7, 8, 9, 10, 11, 250]
)
report = Report(command="help", samples=samples, budget_ms=100)
assert not report.within_budget
text = report.render()
assert text.startswith("help: 10 replies")
for column in ("own_ms", "queue_ms", "dispatch_ms", "handler_ms", "send_ms", "round_trip_ms"):
assert column in text
assert "own_ms p95 OVER the 100 ms budget" in text
assert report.at("round_trip_ms", 50) == 307
assert Report(command="help", samples=samples[:-1]).within_budget
assert "within the 100 ms budget" in Report(command="help", samples=samples[:-1]).render()