Files
hermes-agent/tests/gateway/test_send_retry_hooks.py
T
teknium1 81d77c280a refactor(platforms): photon rides the base send-retry loop; slack/discord parse Retry-After with the shared parser
Photon forked BasePlatformAdapter._send_with_retry before three fixes landed there: the server's
retry_after is honoured over exponential backoff, a long server penalty (> 60s) returns a typed
failure instead of sleeping inline (#91969), and an exhausted rate-limited send no longer posts the
failure notice inside the flood penalty. The fork got none of them. Photon's two genuine differences
are now hooks on the base — `_send_retry_is_final(result)` (structured auth/target refusals are
returned as-is, no retry, no plain-text resend) and `_send_plain_fallback(...)` (no Markdown banner,
richlink() bypassed) — and the 42-line fork is deleted.

Slack's `_retry_after_from_exc` and Discord's `_extract_discord_retry_after` parsed the header by
hand and only understood the numeric form; both now call agent.retry_utils.parse_retry_after_seconds
(numeric or HTTP-date, either header casing). Discord keeps its `retry_after` attribute path, the
`X-RateLimit-Reset-After` fallback and the 1s floor.

Behavior change: Photon retries now add up to 1s of jitter to the backoff and honour a server
retry_after; Slack/Discord recognise an HTTP-date Retry-After they previously ignored.
2026-09-13 05:21:39 -07:00

101 lines
4.3 KiB
Python

"""Invariants for the shared send-retry path.
Photon used to fork ``BasePlatformAdapter._send_with_retry`` and so missed three landed fixes
(server ``retry_after`` honoured, the inline wait cap, no failure notice inside a flood penalty).
It now overrides two hooks. Slack and Discord parsed ``Retry-After`` by hand and only understood
the numeric form; both now go through ``agent.retry_utils.parse_retry_after_seconds``.
"""
from email.utils import format_datetime
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any, Dict
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, SendResult
class _Adapter(BasePlatformAdapter):
"""Sends fail with a scripted list of results; records every call."""
def __init__(self, results):
super().__init__(PlatformConfig(enabled=True), Platform.TELEGRAM)
self._results = list(results)
self.sent: list = []
async def connect(self, *, is_reconnect: bool = False) -> bool:
return True
async def disconnect(self) -> None:
pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {}
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
self.sent.append(content)
return self._results.pop(0) if self._results else SendResult(success=True, message_id="ok")
class _FinalOnRefusal(_Adapter):
def _send_retry_is_final(self, result: SendResult) -> bool:
return isinstance(result.raw_response, dict) and result.raw_response.get("error_class") == "target_not_allowed"
@pytest.mark.asyncio
async def test_final_failure_skips_retry_and_plain_text_fallback(monkeypatch):
"""A structured refusal must come back untouched: no backoff sleep, no second send."""
slept = []
async def _sleep(d):
slept.append(d)
monkeypatch.setattr("gateway.platforms.base.asyncio.sleep", _sleep)
refusal = SendResult(success=False, error="Target not allowed", retryable=True,
raw_response={"error_class": "target_not_allowed", "retryable": False})
adapter = _FinalOnRefusal([refusal])
result = await adapter._send_with_retry("c", "hello")
assert result is refusal
assert adapter.sent == ["hello"] and slept == []
@pytest.mark.asyncio
async def test_server_retry_after_is_honoured_by_every_adapter(monkeypatch):
"""Photon's fork slept base_delay*2**n regardless of the server hint; via the base every
adapter waits what the server asked (plus jitter) — the fix that landed once now reaches all."""
slept = []
async def _sleep(d):
slept.append(d)
monkeypatch.setattr("gateway.platforms.base.asyncio.sleep", _sleep)
monkeypatch.setattr("gateway.platforms.base.random.uniform", lambda a, b: 0.0)
adapter = _FinalOnRefusal([SendResult(success=False, error="429 rate limited", retryable=True, retry_after=7.0)])
result = await adapter._send_with_retry("c", "hello", base_delay=2.0)
assert result.success and adapter.sent == ["hello", "hello"]
assert slept == [7.0]
def _http_date_in(seconds: int) -> str:
return format_datetime(datetime.now(timezone.utc) + timedelta(seconds=seconds), usegmt=True)
def test_slack_retry_after_understands_http_date():
from plugins.platforms.slack.adapter import SlackAdapter
exc = SimpleNamespace(response=SimpleNamespace(headers={"Retry-After": _http_date_in(90)}))
seconds = SlackAdapter._retry_after_from_exc(exc)
assert seconds is not None and 80 <= seconds <= 90
assert SlackAdapter._retry_after_from_exc(SimpleNamespace(response=None)) is None
def test_discord_retry_after_understands_http_date_and_reset_after():
from plugins.platforms.discord.adapter import DiscordAdapter
extract = DiscordAdapter._extract_discord_retry_after
dated = SimpleNamespace(response=SimpleNamespace(headers={"retry-after": _http_date_in(90)}))
seconds = extract(dated)
assert seconds is not None and 80 <= seconds <= 90
reset = SimpleNamespace(response=SimpleNamespace(headers={"X-RateLimit-Reset-After": "0.25"}))
assert extract(reset) == 1.0 # floored: a sub-second hint must not hot-loop
assert extract(SimpleNamespace(retry_after=12)) == 12.0
assert extract(SimpleNamespace()) is None