hermes-agent/tests/gateway/test_loop_exception_handler.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

141 lines
4.2 KiB
Python

"""Tests for the gateway loop-level transient-network-error safety net.
Issues #31066 / #31110: unhandled ``telegram.error.TimedOut`` (or peer
``NetworkError`` / ``httpx`` connection error) propagating to the
asyncio event loop killed the gateway process, taking down every
profile attached to the same runner. The safety net installed in
:func:`gateway.run.start_gateway` catches the transient crash class
and logs+swallows it; non-transient errors still surface.
These tests pin the classifier and the loop handler so the safety net
can't silently regress to swallowing every exception.
"""
from __future__ import annotations
import asyncio
import logging
import pytest
from gateway.run import (
_gateway_loop_exception_handler,
_is_transient_network_error,
)
# ----- Fake exception classes that mimic the real wire types ----------
# We avoid importing telegram / httpx here so the test runs in environments
# without those packages installed (the classifier matches on class name).
class TimedOut(Exception):
"""Stand-in for ``telegram.error.TimedOut``."""
class NetworkError(Exception):
"""Stand-in for ``telegram.error.NetworkError``."""
class ConnectError(Exception):
"""Stand-in for ``httpx.ConnectError``."""
class ReadTimeout(Exception):
"""Stand-in for ``httpx.ReadTimeout``."""
class PoolTimeout(Exception):
"""Stand-in for ``httpx.PoolTimeout``."""
class ClientConnectorError(Exception):
"""Stand-in for ``aiohttp.ClientConnectorError``."""
class SomeUnrelatedBug(Exception):
"""A non-transient error that should NOT be swallowed."""
# ---------------------------------------------------------------------
# Classifier
# ---------------------------------------------------------------------
@pytest.mark.parametrize(
"exc_cls",
[
TimedOut,
NetworkError,
ConnectError,
ReadTimeout,
PoolTimeout,
ClientConnectorError,
],
)
def test_transient_classifier_matches_known_network_errors(exc_cls):
"""Every well-known transient network exception class is classified."""
assert _is_transient_network_error(exc_cls("boom")) is True
# ---------------------------------------------------------------------
# Loop handler
# ---------------------------------------------------------------------
def test_handler_delegates_unknown_errors_to_default(monkeypatch):
"""A non-transient error is forwarded to ``loop.default_exception_handler``."""
loop = asyncio.new_event_loop()
try:
forwarded: list[dict] = []
def fake_default(ctx):
forwarded.append(ctx)
monkeypatch.setattr(loop, "default_exception_handler", fake_default)
context = {
"message": "Something else broke",
"exception": SomeUnrelatedBug("real bug"),
}
_gateway_loop_exception_handler(loop, context)
assert forwarded == [context]
finally:
loop.close()
# ---------------------------------------------------------------------
# End-to-end: task-level
# ---------------------------------------------------------------------
def test_unhandled_transient_error_in_task_does_not_propagate_to_loop():
"""Smoke test the wiring as a loop would actually use it.
Schedules a task that raises TimedOut and is never awaited. With the
handler installed, the loop completes normally and logs a warning
instead of dying. Without the handler, asyncio would emit
``Task exception was never retrieved`` and (depending on Python's
debug mode) potentially escalate.
"""
async def raiser():
raise TimedOut("upstream timeout")
async def main():
loop = asyncio.get_running_loop()
loop.set_exception_handler(_gateway_loop_exception_handler)
task = loop.create_task(raiser())
# Give the task a tick to run and raise.
await asyncio.sleep(0)
# Don't await ``task`` — let it become an unhandled-exception task.
del task
import gc
gc.collect()
await asyncio.sleep(0)
# If the safety net works, this returns cleanly. If not, the test
# would still pass (asyncio's default is a warning, not a crash) —
# the real assertion is that no unhandled exception escapes the
# ``run`` boundary.
asyncio.run(main())