mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-16 09:31:37 +00:00
feat(gateway/signal): add support for multiple images sending
Adds a new `send_multiple_images` method to the ``BasePlatformAdapter`` that implements the default "One image per message" loop and allows for platform-specific overriding. Implements such an override for the Signal adapter, batching images and trying (best-effort) to work around rate-limits for voluminous batches using a specific scheduler. Also implements batching + rate-limit handling in the `send_message` tool. New tests added for the Signal adapter, its rate-limit scheduler and the `send_message` tool
This commit is contained in:
parent
411f586c67
commit
04ea895ffb
9 changed files with 2010 additions and 84 deletions
|
|
@ -1,4 +1,5 @@
|
|||
"""Tests for Signal messenger platform adapter."""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import pytest
|
||||
|
|
@ -9,6 +10,16 @@ from urllib.parse import quote
|
|||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_signal_scheduler():
|
||||
"""The attachment scheduler is process-wide; drop it between tests
|
||||
so a fresh token bucket greets each case."""
|
||||
from gateway.platforms.signal_rate_limit import _reset_scheduler
|
||||
_reset_scheduler()
|
||||
yield
|
||||
_reset_scheduler()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1102,3 +1113,539 @@ class TestSignalQuoteExtraction:
|
|||
event = captured["event"]
|
||||
assert event.reply_to_message_id == "123"
|
||||
assert event.reply_to_text is None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _rpc rate-limit detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeHttpResponse:
|
||||
"""Minimal stand-in for httpx.Response — only what _rpc touches."""
|
||||
|
||||
def __init__(self, json_data):
|
||||
self._json = json_data
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
def _install_fake_client(adapter, json_data):
|
||||
"""Replace adapter.client.post with an async fn returning json_data."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
async def _post(url, json=None, timeout=None):
|
||||
return _FakeHttpResponse(json_data)
|
||||
|
||||
adapter.client = SimpleNamespace(post=_post)
|
||||
|
||||
|
||||
class TestSignalRpcRateLimit:
|
||||
"""_rpc opt-in 429 detection and SignalRateLimitError propagation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_429_when_opted_in(self, monkeypatch):
|
||||
from gateway.platforms.signal import SignalRateLimitError
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
_install_fake_client(adapter, {
|
||||
"error": {"message": "Failed to send: [429] Rate Limited"},
|
||||
})
|
||||
|
||||
with pytest.raises(SignalRateLimitError):
|
||||
await adapter._rpc("send", {}, raise_on_rate_limit=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_rate_limit_exception_substring(self, monkeypatch):
|
||||
"""Some signal-cli builds emit 'RateLimitException' without a literal [429]."""
|
||||
from gateway.platforms.signal import SignalRateLimitError
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
_install_fake_client(adapter, {
|
||||
"error": {"message": "RateLimitException occurred"},
|
||||
})
|
||||
|
||||
with pytest.raises(SignalRateLimitError):
|
||||
await adapter._rpc("send", {}, raise_on_rate_limit=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_swallows_rate_limit_returns_none(self, monkeypatch):
|
||||
"""Without opt-in, 429 stays swallowed — preserves backwards compat."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
_install_fake_client(adapter, {
|
||||
"error": {"message": "[429] Rate Limited"},
|
||||
})
|
||||
|
||||
result = await adapter._rpc("send", {})
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_rate_limit_error_does_not_raise_when_opted_in(self, monkeypatch):
|
||||
"""Opt-in only escalates 429s; other errors still return None."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
_install_fake_client(adapter, {
|
||||
"error": {"message": "Recipient unknown (UntrustedIdentityException)"},
|
||||
})
|
||||
|
||||
result = await adapter._rpc("send", {}, raise_on_rate_limit=True)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_with_retry_after_from_v0_14_3_payload(self, monkeypatch):
|
||||
"""signal-cli ≥ v0.14.3 surfaces server Retry-After under
|
||||
``error.data.response.results[*].retryAfterSeconds`` — _rpc
|
||||
carries that value through SignalRateLimitError.retry_after."""
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SignalRateLimitError, SIGNAL_RPC_ERROR_RATELIMIT,
|
||||
)
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
_install_fake_client(adapter, {
|
||||
"error": {
|
||||
"code": SIGNAL_RPC_ERROR_RATELIMIT,
|
||||
"message": "Failed to send message due to rate limiting",
|
||||
"data": {
|
||||
"response": {
|
||||
"timestamp": 0,
|
||||
"results": [
|
||||
{"type": "RATE_LIMIT_FAILURE", "retryAfterSeconds": 90},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
with pytest.raises(SignalRateLimitError) as exc_info:
|
||||
await adapter._rpc("send", {}, raise_on_rate_limit=True)
|
||||
|
||||
assert exc_info.value.retry_after == 90.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_with_retry_after_none_for_old_signal_cli(self, monkeypatch):
|
||||
"""Older signal-cli builds emit only the substring; retry_after=None."""
|
||||
from gateway.platforms.signal import SignalRateLimitError
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
_install_fake_client(adapter, {
|
||||
"error": {"message": "Failed: [429] Rate Limited"},
|
||||
})
|
||||
|
||||
with pytest.raises(SignalRateLimitError) as exc_info:
|
||||
await adapter._rpc("send", {}, raise_on_rate_limit=True)
|
||||
|
||||
assert exc_info.value.retry_after is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_retry_later_inside_attachment_invalid(self, monkeypatch):
|
||||
"""Production case: 429 during attachment upload surfaces as
|
||||
AttachmentInvalidException → UnexpectedErrorException (code
|
||||
-32603), with the libsignal-net 'Retry after N seconds'
|
||||
message embedded. _rpc must still detect this as rate-limit
|
||||
AND parse the seconds out of the message."""
|
||||
from gateway.platforms.signal import SignalRateLimitError
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
_install_fake_client(adapter, {
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": (
|
||||
"Failed to send message: /home/max/sync/Memes/fengshui.jpeg: "
|
||||
"org.signal.libsignal.net.RetryLaterException: Retry after 4 seconds "
|
||||
"(AttachmentInvalidException) (UnexpectedErrorException)"
|
||||
),
|
||||
"data": None,
|
||||
},
|
||||
})
|
||||
|
||||
with pytest.raises(SignalRateLimitError) as exc_info:
|
||||
await adapter._rpc("send", {}, raise_on_rate_limit=True)
|
||||
|
||||
assert exc_info.value.retry_after == 4.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_multiple_images — chunking, pacing, rate-limit retry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_image_files(tmp_path, count, prefix="img"):
|
||||
"""Materialize `count` tiny PNG files and return file:// URIs for them."""
|
||||
uris = []
|
||||
for i in range(count):
|
||||
p = tmp_path / f"{prefix}_{i}.png"
|
||||
p.write_bytes(b"\x89PNG" + b"\x00" * 32)
|
||||
uris.append((f"file://{p}", ""))
|
||||
return uris
|
||||
|
||||
|
||||
def _stub_rpc_responses(responses):
|
||||
"""Build an _rpc replacement that pops a response per call.
|
||||
|
||||
Each entry in `responses` is either:
|
||||
* a return value (dict / None) → returned to the caller, or
|
||||
* an Exception subclass instance → raised.
|
||||
Captures (params, kwargs) per call for inspection.
|
||||
"""
|
||||
captured = []
|
||||
queue = list(responses)
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params), "kwargs": kwargs})
|
||||
await asyncio.sleep(0)
|
||||
if not queue:
|
||||
raise AssertionError("Unexpected extra _rpc call")
|
||||
item = queue.pop(0)
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
return mock_rpc, captured
|
||||
|
||||
|
||||
def _patch_scheduler_sleep(monkeypatch, capture: list):
|
||||
"""Capture sleeps inside the scheduler so tests don't actually wait.
|
||||
Zero-second sleeps (e.g. event-loop yields from mock RPCs) are
|
||||
delegated to the real asyncio.sleep so they don't pollute the
|
||||
capture list."""
|
||||
_real_sleep = asyncio.sleep
|
||||
offset = [0.0]
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
if seconds > 0:
|
||||
capture.append(seconds)
|
||||
offset[0] += seconds
|
||||
else:
|
||||
await _real_sleep(0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.asyncio.sleep", fake_sleep
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.time.monotonic", lambda: offset[0]
|
||||
)
|
||||
|
||||
|
||||
class TestSignalSendMultipleImages:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_list_is_noop(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
await adapter.send_multiple_images(chat_id="+155****4567", images=[])
|
||||
|
||||
assert captured == []
|
||||
adapter._stop_typing_indicator.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_bad_files_no_rpc(self, monkeypatch, tmp_path):
|
||||
"""If every image is missing/invalid, no RPC fires."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
await adapter.send_multiple_images(
|
||||
chat_id="+155****4567",
|
||||
images=[(f"file://{tmp_path}/missing_a.png", ""),
|
||||
(f"file://{tmp_path}/missing_b.png", "")],
|
||||
)
|
||||
|
||||
assert captured == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_batch_under_limit(self, monkeypatch, tmp_path):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([{"timestamp": 1}])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
images = _make_image_files(tmp_path, 5)
|
||||
await adapter.send_multiple_images(chat_id="+155****4567", images=images)
|
||||
|
||||
assert len(captured) == 1
|
||||
params = captured[0]["params"]
|
||||
assert params["recipient"] == ["+155****4567"]
|
||||
assert params["message"] == ""
|
||||
assert len(params["attachments"]) == 5
|
||||
# raise_on_rate_limit must be opted into so the retry loop sees 429s
|
||||
assert captured[0]["kwargs"].get("raise_on_rate_limit") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_bad_images_in_mixed_batch(self, monkeypatch, tmp_path):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([{"timestamp": 1}])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
good = _make_image_files(tmp_path, 2, prefix="ok")
|
||||
bad = [(f"file://{tmp_path}/missing.png", "")]
|
||||
await adapter.send_multiple_images(
|
||||
chat_id="+155****4567", images=good[:1] + bad + good[1:]
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert len(captured[0]["params"]["attachments"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_429_calibrates_scheduler_then_retries(self, monkeypatch, tmp_path):
|
||||
"""Server says retry_after=27 per token. After feedback, the
|
||||
scheduler's refill_rate becomes 1/27. Re-acquiring n=3 tokens
|
||||
therefore waits 3 × 27 = 81s — pulled from the server's
|
||||
authoritative rate, not a `× 32` defensive multiplier."""
|
||||
from gateway.platforms.signal import SignalRateLimitError
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([
|
||||
SignalRateLimitError("Failed: rate limit", retry_after=27.0),
|
||||
{"timestamp": 99},
|
||||
])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
sleep_calls: list = []
|
||||
_patch_scheduler_sleep(monkeypatch, sleep_calls)
|
||||
|
||||
images = _make_image_files(tmp_path, 3)
|
||||
await adapter.send_multiple_images(chat_id="+155****4567", images=images)
|
||||
|
||||
assert len(captured) == 2 # initial 429 + retry success
|
||||
assert sleep_calls == [pytest.approx(3 * 27.0, abs=1.0)]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_429_without_retry_after_uses_default_rate(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""signal-cli < v0.14.3 doesn't surface Retry-After. The
|
||||
scheduler keeps its default refill rate (1 token / 4s), so a
|
||||
retry of n=3 waits 12s."""
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER,
|
||||
SignalRateLimitError,
|
||||
)
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([
|
||||
SignalRateLimitError("[429] Rate Limited", retry_after=None),
|
||||
{"timestamp": 99},
|
||||
])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
sleep_calls: list = []
|
||||
_patch_scheduler_sleep(monkeypatch, sleep_calls)
|
||||
|
||||
await adapter.send_multiple_images(
|
||||
chat_id="+155****4567",
|
||||
images=_make_image_files(tmp_path, 3),
|
||||
)
|
||||
|
||||
assert len(captured) == 2
|
||||
assert sleep_calls == [
|
||||
pytest.approx(3 * SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER, abs=1.0)
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_exhaust_continues_to_next_batch(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""Both attempts on batch 0 fail; batch 1 still gets a chance.
|
||||
The scheduler's natural pacing on the next acquire stands in for
|
||||
the old explicit cooldown."""
|
||||
from gateway.platforms.signal import SignalRateLimitError
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
responses = [
|
||||
SignalRateLimitError("[429]", retry_after=4.0),
|
||||
SignalRateLimitError("[429]", retry_after=4.0),
|
||||
{"timestamp": 7},
|
||||
]
|
||||
mock_rpc, captured = _stub_rpc_responses(responses)
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
sleep_calls: list = []
|
||||
_patch_scheduler_sleep(monkeypatch, sleep_calls)
|
||||
|
||||
images = _make_image_files(tmp_path, 33) # forces 2 batches
|
||||
await adapter.send_multiple_images(chat_id="+155****4567", images=images)
|
||||
|
||||
# 2 attempts on batch 0 + 1 on batch 1
|
||||
assert len(captured) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_batch_emits_pacing_notice_for_followup(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""Two full batches of 32. Batch 1 needs 14 more tokens than the
|
||||
18 remaining after batch 0, so the scheduler sleeps 56s —
|
||||
crossing the 10s user-facing pacing-notice threshold."""
|
||||
from gateway.platforms.signal import SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY,
|
||||
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER
|
||||
)
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([
|
||||
{"timestamp": 1}, {"timestamp": 2},
|
||||
])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
adapter._notify_batch_pacing = AsyncMock()
|
||||
|
||||
sleep_calls: list = []
|
||||
_patch_scheduler_sleep(monkeypatch, sleep_calls)
|
||||
|
||||
images = _make_image_files(tmp_path, 64)
|
||||
await adapter.send_multiple_images(chat_id="+155****4567", images=images)
|
||||
|
||||
assert len(captured) == 2
|
||||
assert len(captured[0]["params"]["attachments"]) == SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
assert len(captured[1]["params"]["attachments"]) == SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
assert len(sleep_calls) == 1
|
||||
# Batch 1 deficit: 32 - (50 - 32) = 14 tokens × 4s = 56s
|
||||
expected_wait = (
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
- (SIGNAL_RATE_LIMIT_BUCKET_CAPACITY - SIGNAL_MAX_ATTACHMENTS_PER_MSG)
|
||||
) * SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER
|
||||
assert sleep_calls[0] == pytest.approx(expected_wait, abs=1.0)
|
||||
adapter._notify_batch_pacing.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_followup_wait_skips_pacing_notice(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""Batch 1 only needs 1 token but 18 remain after batch 0
|
||||
(50 capacity − 32 batch 0). No wait, no pacing notice."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([
|
||||
{"timestamp": 1}, {"timestamp": 2},
|
||||
])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
adapter._notify_batch_pacing = AsyncMock()
|
||||
|
||||
sleep_calls: list = []
|
||||
_patch_scheduler_sleep(monkeypatch, sleep_calls)
|
||||
|
||||
images = _make_image_files(tmp_path, 33)
|
||||
await adapter.send_multiple_images(chat_id="+155****4567", images=images)
|
||||
|
||||
assert len(captured) == 2
|
||||
assert len(sleep_calls) == 0
|
||||
adapter._notify_batch_pacing.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_batch_send_does_not_pace(self, monkeypatch, tmp_path):
|
||||
"""A single-batch send (≤32 attachments) leaves the scheduler
|
||||
with tokens to spare — no follow-up acquire, no sleep."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc_responses([{"timestamp": 1}])
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
sleep_calls: list = []
|
||||
_patch_scheduler_sleep(monkeypatch, sleep_calls)
|
||||
|
||||
images = _make_image_files(tmp_path, 10)
|
||||
await adapter.send_multiple_images(chat_id="+155****4567", images=images)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert sleep_calls == []
|
||||
|
||||
|
||||
class TestSignalRateLimitDetection:
|
||||
"""Coverage for the typed-code + substring detection helpers."""
|
||||
|
||||
def test_detect_typed_code(self):
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
_is_signal_rate_limit_error,
|
||||
SIGNAL_RPC_ERROR_RATELIMIT,
|
||||
)
|
||||
err = {"code": SIGNAL_RPC_ERROR_RATELIMIT, "message": "any text"}
|
||||
assert _is_signal_rate_limit_error(err) is True
|
||||
|
||||
def test_detect_substring_fallback(self):
|
||||
from gateway.platforms.signal import _is_signal_rate_limit_error
|
||||
err = {"code": -32603, "message": "Failed: [429] Rate Limited (RateLimitException) (UnexpectedErrorException)"}
|
||||
assert _is_signal_rate_limit_error(err) is True
|
||||
|
||||
def test_detect_non_rate_limit(self):
|
||||
from gateway.platforms.signal import _is_signal_rate_limit_error
|
||||
err = {"code": -32603, "message": "UntrustedIdentityException"}
|
||||
assert _is_signal_rate_limit_error(err) is False
|
||||
|
||||
def test_extract_retry_after_from_results(self):
|
||||
from gateway.platforms.signal import _extract_retry_after_seconds
|
||||
err = {
|
||||
"code": -5,
|
||||
"message": "Failed to send message due to rate limiting",
|
||||
"data": {
|
||||
"response": {
|
||||
"timestamp": 0,
|
||||
"results": [
|
||||
{"type": "RATE_LIMIT_FAILURE", "retryAfterSeconds": 30},
|
||||
{"type": "RATE_LIMIT_FAILURE", "retryAfterSeconds": 45},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
assert _extract_retry_after_seconds(err) == 45.0
|
||||
|
||||
def test_extract_retry_after_missing(self):
|
||||
"""Old signal-cli builds don't expose retryAfterSeconds — return None."""
|
||||
from gateway.platforms.signal import _extract_retry_after_seconds
|
||||
err = {"code": -32603, "message": "[429] Rate Limited"}
|
||||
assert _extract_retry_after_seconds(err) is None
|
||||
|
||||
def test_detect_retry_later_exception_substring(self):
|
||||
"""libsignal-net's RetryLaterException leaks through as
|
||||
AttachmentInvalidException → UnexpectedErrorException when the
|
||||
rate-limit fires inside attachment upload. Detect it by substring."""
|
||||
from gateway.platforms.signal import _is_signal_rate_limit_error
|
||||
err = {
|
||||
"code": -32603,
|
||||
"message": (
|
||||
"Failed to send message: /home/max/sync/Memes/fengshui.jpeg: "
|
||||
"org.signal.libsignal.net.RetryLaterException: Retry after 4 seconds "
|
||||
"(AttachmentInvalidException) (UnexpectedErrorException)"
|
||||
),
|
||||
}
|
||||
assert _is_signal_rate_limit_error(err) is True
|
||||
|
||||
def test_extract_retry_after_parses_message_string(self):
|
||||
"""When the structured field is missing, parse the seconds out
|
||||
of the human 'Retry after N seconds' substring."""
|
||||
from gateway.platforms.signal import _extract_retry_after_seconds
|
||||
err = {
|
||||
"code": -32603,
|
||||
"message": (
|
||||
"Failed to send message: /home/max/sync/Memes/fengshui.jpeg: "
|
||||
"org.signal.libsignal.net.RetryLaterException: Retry after 4 seconds "
|
||||
"(AttachmentInvalidException) (UnexpectedErrorException)"
|
||||
),
|
||||
}
|
||||
assert _extract_retry_after_seconds(err) == 4.0
|
||||
|
||||
|
||||
class TestSignalSendTimeout:
|
||||
"""Timeout scaling for batched attachment sends."""
|
||||
|
||||
def test_zero_attachments_uses_default(self):
|
||||
from gateway.platforms.signal import _signal_send_timeout
|
||||
assert _signal_send_timeout(0) == 30.0
|
||||
|
||||
def test_floor_at_60s(self):
|
||||
from gateway.platforms.signal import _signal_send_timeout
|
||||
# Few attachments (would be 5×N=5s) should still get 60s floor.
|
||||
assert _signal_send_timeout(1) == 60.0
|
||||
assert _signal_send_timeout(5) == 60.0
|
||||
|
||||
def test_scales_with_batch_size(self):
|
||||
from gateway.platforms.signal import _signal_send_timeout
|
||||
# 32 attachments × 5s = 160s; ought to comfortably outlast a
|
||||
# serial upload of an attachment-heavy batch.
|
||||
assert _signal_send_timeout(32) == 160.0
|
||||
|
|
|
|||
233
tests/gateway/test_signal_rate_limit.py
Normal file
233
tests/gateway/test_signal_rate_limit.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Tests for the SignalAttachmentScheduler token-bucket simulator."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG,
|
||||
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY,
|
||||
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER,
|
||||
SignalAttachmentScheduler,
|
||||
get_scheduler,
|
||||
_reset_scheduler,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_signal_scheduler():
|
||||
"""Drop the process-wide scheduler so each test gets a clean bucket."""
|
||||
_reset_scheduler()
|
||||
yield
|
||||
_reset_scheduler()
|
||||
|
||||
|
||||
def _patch_sleep_and_time(monkeypatch, capture: list):
|
||||
"""Replace asyncio.sleep inside the scheduler module so tests don't
|
||||
actually wait and advances time.monotonic to simulate time passing.
|
||||
Captures the requested duration per call."""
|
||||
offset = 0.0
|
||||
async def _fake_sleep(seconds):
|
||||
capture.append(seconds)
|
||||
nonlocal offset
|
||||
offset += seconds
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.asyncio.sleep", _fake_sleep
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.time.monotonic", lambda: offset
|
||||
)
|
||||
|
||||
|
||||
class TestSchedulerInitialState:
|
||||
def test_default_capacity_matches_signal_cap(self):
|
||||
s = SignalAttachmentScheduler()
|
||||
assert s.capacity == SIGNAL_RATE_LIMIT_BUCKET_CAPACITY
|
||||
|
||||
def test_default_refill_rate_from_default_retry_after(self):
|
||||
s = SignalAttachmentScheduler()
|
||||
assert s.refill_rate == pytest.approx(1.0 / SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER)
|
||||
|
||||
def test_starts_full(self):
|
||||
s = SignalAttachmentScheduler()
|
||||
assert s.tokens == s.capacity
|
||||
|
||||
|
||||
class TestEstimateWait:
|
||||
def test_zero_when_bucket_has_enough(self):
|
||||
s = SignalAttachmentScheduler()
|
||||
assert s.estimate_wait(10) == 0.0
|
||||
assert s.estimate_wait(int(s.capacity)) == 0.0
|
||||
|
||||
def test_proportional_to_deficit_when_empty(self, monkeypatch):
|
||||
"""Freeze monotonic so estimate_wait doesn't see fractional refill."""
|
||||
s = SignalAttachmentScheduler()
|
||||
s.tokens = 0.0
|
||||
frozen = s.last_refill
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.time.monotonic", lambda: frozen
|
||||
)
|
||||
# 32 tokens at 0.25 tokens/sec = 128s
|
||||
assert s.estimate_wait(32) == pytest.approx(32 / s.refill_rate)
|
||||
assert s.estimate_wait(1) == pytest.approx(1 / s.refill_rate)
|
||||
|
||||
|
||||
class TestAcquire:
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_zero_is_noop(self, monkeypatch):
|
||||
sleeps: list = []
|
||||
_patch_sleep_and_time(monkeypatch, sleeps)
|
||||
s = SignalAttachmentScheduler()
|
||||
original = s.tokens
|
||||
wait = await s.acquire(0)
|
||||
assert wait == 0.0
|
||||
assert sleeps == []
|
||||
assert s.tokens == original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_within_capacity_no_sleep(self, monkeypatch):
|
||||
sleeps: list = []
|
||||
_patch_sleep_and_time(monkeypatch, sleeps)
|
||||
|
||||
s = SignalAttachmentScheduler()
|
||||
wait = await s.acquire(10)
|
||||
await s.report_rpc_duration(0.001, 10) # actually deduct tokens
|
||||
|
||||
assert wait == 0.0
|
||||
assert sleeps == []
|
||||
assert s.tokens == s.capacity - 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_when_empty_sleeps_for_deficit(self, monkeypatch):
|
||||
sleeps: list = []
|
||||
_patch_sleep_and_time(monkeypatch, sleeps)
|
||||
s = SignalAttachmentScheduler()
|
||||
|
||||
s.tokens = 0.0
|
||||
wait = await s.acquire(32)
|
||||
await s.report_rpc_duration(1e-12, 32)
|
||||
|
||||
# 32 tokens at default 0.25 tokens/sec = 128s
|
||||
expected = 32 / s.refill_rate
|
||||
assert wait == pytest.approx(expected)
|
||||
assert sleeps == [pytest.approx(expected)]
|
||||
# After sleep+acquire+rpc call, the bucket is empty again.
|
||||
assert s.tokens == pytest.approx(0.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_back_to_back_acquires_drain_then_wait(self, monkeypatch):
|
||||
"""Two sequential acquires of capacity each: first immediate,
|
||||
second waits a full refill window."""
|
||||
sleeps: list = []
|
||||
_patch_sleep_and_time(monkeypatch, sleeps)
|
||||
s = SignalAttachmentScheduler()
|
||||
|
||||
await s.acquire(int(s.capacity))
|
||||
await s.report_rpc_duration(1e-12, int(s.capacity))
|
||||
|
||||
assert sleeps == [] # first batch had a full bucket
|
||||
|
||||
await s.acquire(int(s.capacity))
|
||||
await s.report_rpc_duration(1e-12, int(s.capacity))
|
||||
# Second batch: no time elapsed (mocked sleep doesn't advance
|
||||
# monotonic), tokens still 0 → wait the full capacity / rate.
|
||||
assert sleeps == [pytest.approx(s.capacity / s.refill_rate)]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_more_tokens_than_capacity(self, monkeypatch):
|
||||
s = SignalAttachmentScheduler()
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await s.acquire(int(s.capacity) + 1)
|
||||
|
||||
class TestFeedback:
|
||||
def test_calibrates_refill_rate_from_retry_after(self):
|
||||
s = SignalAttachmentScheduler()
|
||||
original = s.refill_rate
|
||||
s.feedback(retry_after=42.0, n_attempted=1)
|
||||
assert s.refill_rate == pytest.approx(1.0 / 42.0)
|
||||
assert s.refill_rate != original
|
||||
|
||||
def test_none_retry_after_leaves_rate(self):
|
||||
s = SignalAttachmentScheduler()
|
||||
original = s.refill_rate
|
||||
s.feedback(retry_after=None, n_attempted=5)
|
||||
assert s.refill_rate == original
|
||||
|
||||
def test_zeros_tokens(self):
|
||||
s = SignalAttachmentScheduler()
|
||||
assert s.tokens > 0
|
||||
s.feedback(retry_after=4.0, n_attempted=1)
|
||||
assert s.tokens == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_after_feedback_uses_calibrated_rate(self, monkeypatch):
|
||||
"""signal-cli ≥v0.14.3: server says 'retry_after=42 for one
|
||||
token' → next acquire(1) waits 42s. Drops the old defensive
|
||||
``retry_after * 32`` heuristic in favor of the server's
|
||||
authoritative per-token value."""
|
||||
sleeps: list = []
|
||||
_patch_sleep_and_time(monkeypatch, sleeps)
|
||||
s = SignalAttachmentScheduler()
|
||||
|
||||
# Initial acquire empties enough; 429 fires.
|
||||
await s.acquire(1)
|
||||
s.feedback(retry_after=42.0, n_attempted=1)
|
||||
|
||||
# Re-acquire: bucket empty, calibrated rate = 1/42.
|
||||
await s.acquire(1)
|
||||
assert sleeps == [pytest.approx(42.0)]
|
||||
|
||||
|
||||
class TestRefillClamping:
|
||||
def test_refill_does_not_exceed_capacity(self, monkeypatch):
|
||||
"""Even after a long elapsed window, refill clamps at capacity."""
|
||||
s = SignalAttachmentScheduler()
|
||||
s.tokens = 0.0
|
||||
# Pretend a year passed.
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.time.monotonic",
|
||||
lambda: s.last_refill + 365 * 24 * 3600,
|
||||
)
|
||||
s._refill()
|
||||
assert s.tokens == s.capacity
|
||||
|
||||
|
||||
class TestFifoAcquire:
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_acquires_serialize(self, monkeypatch):
|
||||
"""Two coroutines acquiring full capacity each: the second waits
|
||||
in the lock queue until the first finishes its bucket math + sleep.
|
||||
Demonstrates the FIFO fairness across sessions."""
|
||||
sleeps: list = []
|
||||
_patch_sleep_and_time(monkeypatch, sleeps)
|
||||
s = SignalAttachmentScheduler()
|
||||
|
||||
results: list = []
|
||||
|
||||
async def worker(label: str):
|
||||
wait = await s.acquire(int(s.capacity))
|
||||
await s.report_rpc_duration(1e-12, int(s.capacity))
|
||||
results.append((label, wait))
|
||||
|
||||
# Launch in order; FIFO means A finishes first, then B.
|
||||
await asyncio.gather(worker("A"), worker("B"))
|
||||
|
||||
assert [r[0] for r in results] == ["A", "B"]
|
||||
# A had a full bucket (no wait). B waited a full refill.
|
||||
assert results[0][1] == 0.0
|
||||
assert results[1][1] == pytest.approx(s.capacity / s.refill_rate)
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
def test_get_scheduler_returns_same_instance(self):
|
||||
s1 = get_scheduler()
|
||||
s2 = get_scheduler()
|
||||
assert s1 is s2
|
||||
|
||||
def test_reset_scheduler_yields_new_instance(self):
|
||||
s1 = get_scheduler()
|
||||
_reset_scheduler()
|
||||
s2 = get_scheduler()
|
||||
assert s1 is not s2
|
||||
|
|
@ -8,12 +8,25 @@ from pathlib import Path
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_signal_scheduler():
|
||||
"""Drop the process-wide attachment scheduler so each test gets a
|
||||
fresh token bucket."""
|
||||
from gateway.platforms.signal_rate_limit import _reset_scheduler
|
||||
_reset_scheduler()
|
||||
yield
|
||||
_reset_scheduler()
|
||||
|
||||
from gateway.config import Platform
|
||||
from tools.send_message_tool import (
|
||||
_derive_forum_thread_name,
|
||||
_parse_target_ref,
|
||||
_send_discord,
|
||||
_send_matrix_via_adapter,
|
||||
_send_signal,
|
||||
_send_telegram,
|
||||
_send_to_platform,
|
||||
send_message_tool,
|
||||
|
|
@ -1621,3 +1634,361 @@ class TestForumProbeCache:
|
|||
assert result2["success"] is True
|
||||
# Only one session opened (thread creation) — no probe session this time
|
||||
# (verified by not raising from our side_effect exhaustion)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send_signal — chunking + 429 retry (mirrors gateway adapter behavior)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSignalHttp:
|
||||
"""Stand-in for httpx.AsyncClient used as an async context manager.
|
||||
|
||||
Pops a response from the queue per `post` call. Each entry is either
|
||||
a dict (returned from .json()) or an exception instance (raised).
|
||||
Captures (url, payload) per call.
|
||||
"""
|
||||
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, *_a, **_kw):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_a):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None):
|
||||
self.calls.append({"url": url, "payload": json})
|
||||
if not self.responses:
|
||||
raise AssertionError("Unexpected extra POST")
|
||||
item = self.responses.pop(0)
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
resp = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda data=item: data,
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def _install_signal_http(monkeypatch, fake):
|
||||
"""Patch httpx.AsyncClient at the module level so the lazy import in
|
||||
_send_signal picks it up.
|
||||
"""
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx, "AsyncClient", fake)
|
||||
|
||||
|
||||
def _patch_sendmsg_sleep_and_time(monkeypatch, capture: list):
|
||||
"""Mock asyncio.sleep + time.monotonic in the signal_rate_limit
|
||||
module so the scheduler's acquire loop sees synthetic time advancing
|
||||
during sleep calls, and report_rpc_duration sees the same clock.
|
||||
|
||||
Zero-second sleeps (event-loop yields from fake HTTP posts) are
|
||||
delegated to the real asyncio.sleep so they don't pollute the
|
||||
capture list.
|
||||
"""
|
||||
import asyncio as _aio
|
||||
_real_sleep = _aio.sleep
|
||||
offset = [0.0]
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
if seconds > 0:
|
||||
capture.append(seconds)
|
||||
offset[0] += seconds
|
||||
else:
|
||||
await _real_sleep(0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.asyncio.sleep", fake_sleep
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.signal_rate_limit.time.monotonic", lambda: offset[0]
|
||||
)
|
||||
|
||||
|
||||
class TestSendSignalChunking:
|
||||
def test_text_only_single_rpc(self, monkeypatch):
|
||||
fake = _FakeSignalHttp([{"result": {"timestamp": 1}}])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"hello",
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"success": True, "platform": "signal", "chat_id": "+15557654321"}
|
||||
assert len(fake.calls) == 1
|
||||
params = fake.calls[0]["payload"]["params"]
|
||||
assert params["message"] == "hello"
|
||||
assert "attachments" not in params
|
||||
|
||||
def test_chunks_attachments_above_max(self, tmp_path, monkeypatch):
|
||||
"""33 attachments → 2 batches; text only on first batch. Batch 1
|
||||
only needs 1 token and 18 remain after batch 0, so no sleep."""
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG,
|
||||
)
|
||||
|
||||
paths = []
|
||||
for i in range(33):
|
||||
p = tmp_path / f"img_{i}.png"
|
||||
p.write_bytes(b"\x89PNG" + b"\x00" * 16)
|
||||
paths.append((str(p), False))
|
||||
|
||||
fake = _FakeSignalHttp([
|
||||
{"result": {"timestamp": 1}}, # batch 0
|
||||
{"result": {"timestamp": 2}}, # batch 1
|
||||
])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
sleep_calls = []
|
||||
_patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"Caption goes here",
|
||||
media_files=paths,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(fake.calls) == 2
|
||||
assert len(sleep_calls) == 0
|
||||
|
||||
first = fake.calls[0]["payload"]["params"]
|
||||
assert first["message"] == "Caption goes here"
|
||||
assert len(first["attachments"]) == SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
|
||||
second = fake.calls[1]["payload"]["params"]
|
||||
assert second["message"] == "" # caption only on batch 0
|
||||
assert len(second["attachments"]) == 33 - SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
|
||||
def test_full_followup_batch_emits_pacing_notice(self, tmp_path, monkeypatch):
|
||||
"""64 attachments → 2 full batches. Batch 1 needs 14 more tokens
|
||||
than the 18 remaining after batch 0 — 56s wait crossing the 10s
|
||||
notice threshold."""
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG,
|
||||
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY,
|
||||
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER,
|
||||
)
|
||||
|
||||
paths = []
|
||||
for i in range(64):
|
||||
p = tmp_path / f"img_{i}.png"
|
||||
p.write_bytes(b"\x89PNG" + b"\x00" * 16)
|
||||
paths.append((str(p), False))
|
||||
|
||||
fake = _FakeSignalHttp([
|
||||
{"result": {"timestamp": 1}}, # batch 0
|
||||
{"result": {"timestamp": 99}}, # pacing notice
|
||||
{"result": {"timestamp": 2}}, # batch 1
|
||||
])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
sleep_calls = []
|
||||
_patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"",
|
||||
media_files=paths,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(fake.calls) == 3
|
||||
notice = fake.calls[1]["payload"]["params"]
|
||||
assert "More images coming" in notice["message"]
|
||||
assert "attachments" not in notice
|
||||
# Batch 1 deficit: 32 - (50 - 32) = 14 tokens × 4s = 56s
|
||||
expected = (
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
- (SIGNAL_RATE_LIMIT_BUCKET_CAPACITY - SIGNAL_MAX_ATTACHMENTS_PER_MSG)
|
||||
) * SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER
|
||||
assert sleep_calls == [pytest.approx(expected, abs=1.0)]
|
||||
|
||||
def test_429_with_retry_after_drives_exact_backoff(self, tmp_path, monkeypatch):
|
||||
"""signal-cli ≥ v0.14.3 surfaces Retry-After under
|
||||
error.data.response.results[*].retryAfterSeconds. The scheduler
|
||||
calibrates its refill rate from that value; the retry of n=1
|
||||
sleeps the per-token interval."""
|
||||
from gateway.platforms.signal_rate_limit import SIGNAL_RPC_ERROR_RATELIMIT
|
||||
|
||||
p = tmp_path / "img.png"
|
||||
p.write_bytes(b"\x89PNG" + b"\x00" * 16)
|
||||
|
||||
fake = _FakeSignalHttp([
|
||||
{
|
||||
"error": {
|
||||
"code": SIGNAL_RPC_ERROR_RATELIMIT,
|
||||
"message": "Failed to send message due to rate limiting",
|
||||
"data": {
|
||||
"response": {
|
||||
"timestamp": 0,
|
||||
"results": [
|
||||
{"type": "RATE_LIMIT_FAILURE", "retryAfterSeconds": 42},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
{"result": {"timestamp": 7}},
|
||||
])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
sleep_calls = []
|
||||
_patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"",
|
||||
media_files=[(str(p), False)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(fake.calls) == 2 # initial + retry
|
||||
assert sleep_calls == [pytest.approx(42.0, abs=1.0)]
|
||||
|
||||
def test_429_without_retry_after_falls_back_to_default(self, tmp_path, monkeypatch):
|
||||
"""Older signal-cli (< v0.14.3) doesn't surface Retry-After.
|
||||
The scheduler keeps its default rate (1 token / 4s)."""
|
||||
from gateway.platforms.signal_rate_limit import SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER
|
||||
|
||||
p = tmp_path / "img.png"
|
||||
p.write_bytes(b"\x89PNG" + b"\x00" * 16)
|
||||
|
||||
fake = _FakeSignalHttp([
|
||||
{"error": {"message": "Failed: [429] Rate Limited"}},
|
||||
{"result": {"timestamp": 7}},
|
||||
])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
sleep_calls = []
|
||||
_patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"",
|
||||
media_files=[(str(p), False)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert sleep_calls == [pytest.approx(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER, abs=1.0)]
|
||||
|
||||
def test_429_retry_exhaust_continues_to_next_batch(self, tmp_path, monkeypatch):
|
||||
"""Both attempts on batch 0 fail; batch 1 still gets a chance.
|
||||
The scheduler's natural pacing (no more cooldown gate) lets the
|
||||
second batch through after its acquire wait."""
|
||||
from gateway.platforms.signal_rate_limit import SIGNAL_RPC_ERROR_RATELIMIT
|
||||
|
||||
paths = []
|
||||
for i in range(33): # forces 2 batches
|
||||
p = tmp_path / f"img_{i}.png"
|
||||
p.write_bytes(b"\x89PNG" + b"\x00" * 16)
|
||||
paths.append((str(p), False))
|
||||
|
||||
rate_limit_err = {
|
||||
"error": {
|
||||
"code": SIGNAL_RPC_ERROR_RATELIMIT,
|
||||
"message": "Failed to send message due to rate limiting",
|
||||
"data": {
|
||||
"response": {
|
||||
"timestamp": 0,
|
||||
"results": [
|
||||
{"type": "RATE_LIMIT_FAILURE", "retryAfterSeconds": 4},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fake = _FakeSignalHttp([
|
||||
rate_limit_err, # batch 0, attempt 1
|
||||
rate_limit_err, # batch 0, attempt 2 (exhaust)
|
||||
{"result": {"timestamp": 9}}, # batch 1 succeeds
|
||||
])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
sleep_calls = []
|
||||
_patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"many",
|
||||
media_files=paths,
|
||||
)
|
||||
)
|
||||
|
||||
# Partial success: batch 0 lost but batch 1 went through.
|
||||
assert result["success"] is True
|
||||
assert "warnings" in result
|
||||
assert any("rate-limited" in w for w in result["warnings"])
|
||||
# 2 attempts on batch 0 + 1 successful batch 1 = 3 calls
|
||||
assert len(fake.calls) == 3
|
||||
|
||||
def test_non_rate_limit_error_returns_immediately(self, tmp_path, monkeypatch):
|
||||
"""A non-429 RPC error should not retry — it returns an error result."""
|
||||
p = tmp_path / "img.png"
|
||||
p.write_bytes(b"\x89PNG" + b"\x00" * 16)
|
||||
|
||||
fake = _FakeSignalHttp([
|
||||
{"error": {"message": "UntrustedIdentityException"}},
|
||||
])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"",
|
||||
media_files=[(str(p), False)],
|
||||
)
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "UntrustedIdentityException" in result["error"]
|
||||
assert len(fake.calls) == 1 # no retry on non-429
|
||||
|
||||
def test_skipped_missing_files_reported_in_warnings(self, tmp_path, monkeypatch):
|
||||
good = tmp_path / "ok.png"
|
||||
good.write_bytes(b"\x89PNG" + b"\x00" * 16)
|
||||
|
||||
fake = _FakeSignalHttp([{"result": {"timestamp": 1}}])
|
||||
_install_signal_http(monkeypatch, fake)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_signal(
|
||||
{"http_url": "http://localhost:8080", "account": "+15551234567"},
|
||||
"+15557654321",
|
||||
"msg",
|
||||
media_files=[(str(good), False), (str(tmp_path / "missing.png"), False)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "warnings" in result
|
||||
# Only the existing file made it into the RPC
|
||||
params = fake.calls[0]["payload"]["params"]
|
||||
assert len(params["attachments"]) == 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue