Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

536 lines
17 KiB
Python

"""Tests for tools/system/fleet_monitoring/error_signals.py."""
from __future__ import annotations
import re
import threading
import pytest
from tools.system.fleet_monitoring.error_signals import (
DEFAULT_CATEGORIES,
ErrorCategory,
ErrorSignals,
)
class _FakeClock:
"""Deterministic clock for sliding-window tests."""
def __init__(self, start: float = 1000.0) -> None:
self.now = start
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def _signals_with_clock(
clock: _FakeClock,
*,
window_seconds: float = 60.0,
categories: tuple[ErrorCategory, ...] | None = None,
) -> ErrorSignals:
return ErrorSignals(
categories=categories,
window_seconds=window_seconds,
now=clock,
)
# ---------------------------------------------------------------------------
# Initialization / contract
# ---------------------------------------------------------------------------
def test_initial_rates_are_all_zero() -> None:
signals = ErrorSignals()
rates = signals.rate_per_minute()
assert rates == {cat.name: 0.0 for cat in DEFAULT_CATEGORIES}
def test_observe_empty_chunk_is_a_noop() -> None:
signals = ErrorSignals()
signals.observe("")
assert signals.rate_per_minute() == {cat.name: 0.0 for cat in DEFAULT_CATEGORIES}
def test_invalid_window_raises() -> None:
with pytest.raises(ValueError, match="window_seconds must be > 0"):
ErrorSignals(window_seconds=0)
with pytest.raises(ValueError, match="window_seconds must be > 0"):
ErrorSignals(window_seconds=-1.0)
def test_duplicate_category_names_raise() -> None:
with pytest.raises(ValueError, match="duplicate category name"):
ErrorSignals(
categories=(
ErrorCategory(name="dup", keywords=("a",)),
ErrorCategory(name="dup", keywords=("b",)),
),
)
# ---------------------------------------------------------------------------
# False-positive guards (acceptance criterion: no false positives on neutral text)
# ---------------------------------------------------------------------------
def test_no_false_positive_on_simple_neutral_prose() -> None:
signals = ErrorSignals()
signals.observe(
"Hello, the meeting is at 5pm. Phone is 555-1234. "
"I love programming and the price is $599. Show me the file at line 502."
)
assert signals.rate_per_minute() == {cat.name: 0.0 for cat in DEFAULT_CATEGORIES}
def test_no_false_positive_on_descriptive_rate_limit_mention() -> None:
"""Plain prose mentioning rate limits descriptively must not fire."""
signals = ErrorSignals()
signals.observe("we should rate limit the API to 10 req/sec")
signals.observe("the OpenAI rate limit is 10k tokens per minute")
signals.observe("no rate limit was hit") # negation
signals.observe("rate limit configuration is in the env file")
assert signals.rate_per_minute()["rate_limit"] == 0.0
def test_no_false_positive_on_tool_error_handling_phrase() -> None:
"""Code-suggestion text containing 'tool error' or 'tool failure' must not fire."""
signals = ErrorSignals()
signals.observe("add tool error handling around the call")
signals.observe("fixed a tool error in last commit")
signals.observe("tool failure modes are covered in the docs")
signals.observe("we discussed tool failure recovery yesterday")
assert signals.rate_per_minute()["tool_failure"] == 0.0
def test_no_false_positive_on_traceback_word_in_prose() -> None:
"""Bare 'Traceback' in instructive text must not fire."""
signals = ErrorSignals()
signals.observe("show me the Traceback for that bug")
signals.observe("Traceback in Python is helpful for debugging")
signals.observe("read the Traceback carefully")
assert signals.rate_per_minute()["traceback"] == 0.0
def test_no_false_positive_on_json_log_line_with_status_field() -> None:
"""A JSON log entry with a 'status' field must not trip http_5xx."""
signals = ErrorSignals()
signals.observe('{"level": "info", "status": 200, "msg": "ok"}')
signals.observe('{"endpoint": "/v1/users", "status_code": 201, "latency_ms": 502}')
assert signals.rate_per_minute()["http_5xx"] == 0.0
def test_no_false_positive_on_markdown_explainer() -> None:
"""A markdown paragraph descriptively covering all four categories must not fire."""
signals = ErrorSignals()
signals.observe(
"## Common errors\n"
"When you hit the rate limit you should back off. "
"If a tool failure pattern shows up, retry with exponential delay. "
"A Python Traceback is your friend during debugging. "
"HTTP 5xx responses indicate the server is having a bad time."
)
assert signals.rate_per_minute() == {cat.name: 0.0 for cat in DEFAULT_CATEGORIES}
def test_no_false_positive_on_bare_three_digit_numbers() -> None:
"""Plain numbers like '500 lines of code' must not trip the 5xx counter."""
signals = ErrorSignals()
signals.observe("There are 500 lines of code, file size is 502KB, line 599 has a typo.")
assert signals.rate_per_minute()["http_5xx"] == 0.0
# ---------------------------------------------------------------------------
# Positive detection (rate_limit)
# ---------------------------------------------------------------------------
def test_detects_rate_limit_exceeded() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("Anthropic API: rate limit exceeded, retrying in 5s")
rates = signals.rate_per_minute()
assert rates["rate_limit"] == 1.0
assert rates["http_5xx"] == 0.0
assert rates["tool_failure"] == 0.0
assert rates["traceback"] == 0.0
def test_detects_rate_limit_hit_or_reached() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("rate limit hit")
signals.observe("rate-limit reached")
signals.observe("ratelimited")
assert signals.rate_per_minute()["rate_limit"] == 3.0
def test_detects_429_with_rate_limit_context() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("got 429: too many requests")
signals.observe("HTTP 429 - rate limit")
assert signals.rate_per_minute()["rate_limit"] == 2.0
def test_rate_limit_is_case_insensitive() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("RATE LIMIT EXCEEDED")
signals.observe("Rate-Limit Hit")
assert signals.rate_per_minute()["rate_limit"] == 2.0
# ---------------------------------------------------------------------------
# Positive detection (http_5xx)
# ---------------------------------------------------------------------------
def test_detects_http_5xx_with_status_context() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("HTTP/1.1 503 Service Unavailable")
signals.observe("status: 500 internal server error")
signals.observe("got 502 Bad Gateway from upstream")
assert signals.rate_per_minute()["http_5xx"] == 3.0
def test_detects_http_5xx_case_insensitive() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("http/1.1 503 service unavailable")
signals.observe("STATUS: 500")
assert signals.rate_per_minute()["http_5xx"] == 2.0
# ---------------------------------------------------------------------------
# Positive detection (tool_failure)
# ---------------------------------------------------------------------------
def test_detects_tool_failure_header_form() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("tool failure: bash exited with code 1")
signals.observe("tool_failure: timeout")
signals.observe("tool failure - subprocess died")
assert signals.rate_per_minute()["tool_failure"] == 3.0
def test_detects_tool_failed_with_action_verb() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("the tool failed during execution")
signals.observe("tool failed with code 137")
signals.observe("tool failed because of bad input")
assert signals.rate_per_minute()["tool_failure"] == 3.0
def test_detects_tool_exited_with_code() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("tool exited with code 2")
signals.observe("tool exited with 1")
assert signals.rate_per_minute()["tool_failure"] == 2.0
# ---------------------------------------------------------------------------
# Positive detection (traceback)
# ---------------------------------------------------------------------------
def test_detects_python_traceback_header() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe('Traceback (most recent call last):\n File "x.py", line 1\nValueError: bad')
assert signals.rate_per_minute()["traceback"] == 1.0
# ---------------------------------------------------------------------------
# Combinatorics / counting
# ---------------------------------------------------------------------------
def test_one_chunk_can_match_multiple_categories() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe(
"rate limit exceeded; server returned status: 503; tool failure: subprocess died"
)
rates = signals.rate_per_minute()
assert rates["rate_limit"] == 1.0
assert rates["http_5xx"] == 1.0
assert rates["tool_failure"] == 1.0
def test_multiple_occurrences_in_one_chunk_count_separately() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("rate limit exceeded ... rate limit exceeded ... rate limit exceeded")
assert signals.rate_per_minute()["rate_limit"] == 3.0
# ---------------------------------------------------------------------------
# Sliding window
# ---------------------------------------------------------------------------
def test_sliding_window_prunes_old_events() -> None:
"""Events older than window_seconds must drop out of the rate."""
clock = _FakeClock(start=0.0)
signals = _signals_with_clock(clock, window_seconds=60.0)
# t=0: three rate-limit events
signals.observe("rate limit exceeded")
signals.observe("rate limit exceeded")
signals.observe("rate limit exceeded")
assert signals.rate_per_minute()["rate_limit"] == 3.0
# t=30: still inside window
clock.advance(30.0)
assert signals.rate_per_minute()["rate_limit"] == 3.0
# t=61: all three originals just fell out
clock.advance(31.0)
assert signals.rate_per_minute()["rate_limit"] == 0.0
def test_rate_normalizes_to_per_minute_for_short_window() -> None:
"""A 30s window with 2 events reports as 4.0/min, not 2.0/min."""
clock = _FakeClock(start=0.0)
signals = _signals_with_clock(clock, window_seconds=30.0)
signals.observe("rate limit exceeded")
signals.observe("rate limit exceeded")
assert signals.rate_per_minute()["rate_limit"] == 4.0
def test_categories_are_independent_in_window() -> None:
"""Pruning one category must not affect another."""
clock = _FakeClock(start=0.0)
signals = _signals_with_clock(clock, window_seconds=60.0)
signals.observe("rate limit exceeded") # t=0
clock.advance(30.0)
signals.observe("Traceback (most recent call last):") # t=30
clock.advance(31.0) # t=61, rate_limit dropped, traceback still in
rates = signals.rate_per_minute()
assert rates["rate_limit"] == 0.0
assert rates["traceback"] == 1.0
# ---------------------------------------------------------------------------
# Memory bounds (P2-1)
# ---------------------------------------------------------------------------
def test_observe_prunes_expired_events_so_idle_dashboard_does_not_grow_unbounded() -> None:
"""If rate_per_minute() is never called, observe() must still keep memory bounded."""
clock = _FakeClock(start=0.0)
signals = _signals_with_clock(clock, window_seconds=60.0)
# Hammer 1000 events at t=0
for _ in range(1000):
signals.observe("rate limit exceeded")
# Advance past the window and observe one more (no rate_per_minute call yet)
clock.advance(120.0)
signals.observe("rate limit exceeded")
# The deque must have been pruned during observe(), not just on query.
rate = signals.rate_per_minute()["rate_limit"]
assert rate == 1.0
# ---------------------------------------------------------------------------
# Custom categories
# ---------------------------------------------------------------------------
def test_custom_categories_override_defaults() -> None:
clock = _FakeClock()
custom = (
ErrorCategory(
name="oom",
keywords=("OutOfMemoryError", "killed by oom"),
patterns=(re.compile(r"signal:\s*9", re.IGNORECASE),),
),
)
signals = _signals_with_clock(clock, categories=custom)
signals.observe("Process killed by OOM at 12:34")
signals.observe("subprocess died: signal: 9")
rates = signals.rate_per_minute()
assert rates == {"oom": 2.0}
def test_keyword_matching_is_word_bounded() -> None:
"""A keyword like 'error' must match the word 'error' but not 'errored',
'errorless', or 'noerror'. Substring matching would let an adversarial
agent inflate the counter and trigger false SLO breaches."""
clock = _FakeClock()
signals = _signals_with_clock(
clock,
categories=(ErrorCategory(name="custom", keywords=("error",)),),
)
signals.observe("the call errored at line 42")
signals.observe("an errorless run")
signals.observe("the noerror flag is set")
assert signals.rate_per_minute()["custom"] == 0.0
signals.observe("an error occurred")
signals.observe("ERROR: bad input")
assert signals.rate_per_minute()["custom"] == 2.0
def test_empty_keyword_strings_are_ignored() -> None:
"""An empty-string keyword would match every chunk; defend against it."""
clock = _FakeClock()
signals = _signals_with_clock(
clock,
categories=(ErrorCategory(name="bad", keywords=("",)),),
)
signals.observe("totally normal text without any errors")
assert signals.rate_per_minute() == {"bad": 0.0}
def test_non_ascii_chunks_do_not_crash() -> None:
clock = _FakeClock()
signals = _signals_with_clock(clock)
signals.observe("こんにちは 你好 مرحبا running smoothly")
assert all(v == 0.0 for v in signals.rate_per_minute().values())
# ---------------------------------------------------------------------------
# Thread safety (P2-2)
# ---------------------------------------------------------------------------
def test_concurrent_observe_and_query_does_not_raise() -> None:
"""Tail thread observes while renderer thread queries; no crashes, sane bounds."""
signals = ErrorSignals(window_seconds=60.0)
stop = threading.Event()
errors: list[Exception] = []
def writer() -> None:
try:
for _ in range(5000):
signals.observe("rate limit exceeded")
except Exception as e:
errors.append(e)
finally:
stop.set()
def reader() -> None:
try:
while not stop.is_set():
rates = signals.rate_per_minute()
assert rates["rate_limit"] >= 0.0
except Exception as e:
errors.append(e)
t_writer = threading.Thread(target=writer)
t_reader = threading.Thread(target=reader)
t_writer.start()
t_reader.start()
t_writer.join(timeout=10.0)
t_reader.join(timeout=10.0)
assert not errors
final = signals.rate_per_minute()
# All 5000 events should normally complete well under the 60s window, but
# tolerate a slow CI runner where a small fraction may have expired by the
# time we read the rate. The point of the test is no exceptions; the
# bound just guards against silent total loss.
assert final["rate_limit"] >= 4500.0
def test_concurrent_pruning_during_active_expiry_does_not_raise() -> None:
"""Both methods prune the same deque; with events actively expiring, the
compound 'check then popleft' must not race into an IndexError."""
# Tiny window means events expire essentially immediately, so both
# observe() and rate_per_minute() will be racing on the same prune path.
signals = ErrorSignals(window_seconds=0.001)
stop = threading.Event()
errors: list[Exception] = []
def writer() -> None:
try:
for _ in range(2000):
signals.observe("rate limit exceeded")
except Exception as e:
errors.append(e)
finally:
stop.set()
def reader() -> None:
try:
while not stop.is_set():
signals.rate_per_minute()
except Exception as e:
errors.append(e)
t_writer = threading.Thread(target=writer)
t_reader = threading.Thread(target=reader)
t_writer.start()
t_reader.start()
t_writer.join(timeout=10.0)
t_reader.join(timeout=10.0)
assert not errors