mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
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>
156 lines
5.6 KiB
Python
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()
|