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:
Maxence Groine 2026-04-30 12:11:07 +02:00 committed by Teknium
parent 411f586c67
commit 04ea895ffb
9 changed files with 2010 additions and 84 deletions

View file

@ -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

View 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

View file

@ -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