mirror of https://github.com/Nezreka/SoulSync.git
- new soulseek.search_min_delay_seconds knob forces a gap between consecutive searches; smooths the burst pattern that trips ISP anti-abuse (Reddit report: Bell Canada cuts the WAN after rapid peer-connection spikes) even when the existing 35/220 sliding-window cap isn't hit - throttle math lifted to a pure compute_search_wait_seconds helper so the gate logic is testable independent of asyncio.sleep + the singleton client - new field on settings → connections → soulseek; default 0 = disabled so existing users see no change 15 helper-boundary tests pin defaults / no-throttle, sliding-window cap (legacy), min-delay (the new burst-smoother), max-of-both gates, and defensive paths.pull/567/head
parent
e0bf595a35
commit
b9feed1a67
@ -0,0 +1,254 @@
|
||||
"""Pin `compute_search_wait_seconds` — the pure scheduler behind the
|
||||
slskd search throttle.
|
||||
|
||||
Reddit report (YeloMelo95, Bell Canada): ISP anti-abuse cuts the user's
|
||||
WAN connection after a burst of slskd searches. The pre-fix throttle
|
||||
was hardcoded to 35 searches per 220s sliding window, which allowed all
|
||||
35 in rapid succession and only blocked once the cap was hit. That's
|
||||
fine for soulseek-side bans but doesn't smooth bursts at the ISP layer.
|
||||
|
||||
Fix lifts the cap + window to config and adds a new `min_delay_seconds`
|
||||
knob. The pure helper takes the throttle inputs and returns how long to
|
||||
sleep — easy to test independently of asyncio.sleep / the singleton
|
||||
client / wall-clock time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.soulseek_client import compute_search_wait_seconds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults / no-throttle path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoThrottleNeeded:
|
||||
def test_empty_state_returns_zero(self):
|
||||
"""First search ever → no timestamps, no last-search → no wait."""
|
||||
assert compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=0.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
) == 0.0
|
||||
|
||||
def test_below_window_cap_returns_zero(self):
|
||||
"""When timestamps haven't filled the window cap and min-delay
|
||||
is disabled, no wait. Preserves prior behavior for existing
|
||||
users who don't tune the new knob."""
|
||||
assert compute_search_wait_seconds(
|
||||
timestamps=[10.0, 20.0, 30.0],
|
||||
last_search_at=30.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
) == 0.0
|
||||
|
||||
def test_min_delay_zero_is_disabled(self):
|
||||
"""Explicit zero (the default) means no min-delay enforcement
|
||||
even when the last search was a millisecond ago. Confirms
|
||||
backwards compat — existing users see no new wait."""
|
||||
assert compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=99.99,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sliding-window cap (legacy behavior preserved)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlidingWindowCap:
|
||||
def test_window_full_waits_for_oldest_to_age_out(self):
|
||||
"""35 timestamps in window → wait until oldest ages out.
|
||||
Same semantics as the pre-fix hardcoded behavior."""
|
||||
timestamps = [10.0 + i for i in range(35)] # 10..44
|
||||
# now = 50, window = 220, oldest = 10 → ages out at 230 → wait 180
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=timestamps,
|
||||
last_search_at=44.0,
|
||||
now=50.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert wait == pytest.approx(180.0, abs=1e-9)
|
||||
|
||||
def test_window_full_but_oldest_already_aged_out_returns_zero(self):
|
||||
"""If now is past oldest+window, the negative is clamped to 0
|
||||
(the caller is expected to prune timestamps before passing —
|
||||
this is just defense-in-depth)."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[10.0] * 35,
|
||||
last_search_at=10.0,
|
||||
now=400.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
def test_custom_max_per_window_honored(self):
|
||||
"""User dials max down to 10 (paranoia mode for ISP anti-abuse).
|
||||
Cap kicks in at 10, not 35."""
|
||||
timestamps = [10.0 + i for i in range(10)]
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=timestamps,
|
||||
last_search_at=19.0,
|
||||
now=20.0,
|
||||
max_per_window=10,
|
||||
window_seconds=60,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
# oldest = 10, ages out at 70, now = 20 → wait 50
|
||||
assert wait == pytest.approx(50.0, abs=1e-9)
|
||||
|
||||
def test_max_per_window_zero_disables_window_cap(self):
|
||||
"""Defensive: max=0 means no cap (don't divide by zero, don't
|
||||
block forever). Min-delay still applies if set."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[10.0] * 100,
|
||||
last_search_at=50.0,
|
||||
now=51.0,
|
||||
max_per_window=0,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Min-delay between searches (the new knob — Bell Canada fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMinDelayBetweenSearches:
|
||||
def test_recent_last_search_blocks_for_remaining_delay(self):
|
||||
"""User sets min_delay=5s. Last search 2s ago → wait 3s.
|
||||
Smooths the burst pattern that trips Bell's anti-abuse even
|
||||
when the sliding window isn't full."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0, 102.0],
|
||||
last_search_at=102.0,
|
||||
now=104.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=5,
|
||||
)
|
||||
assert wait == pytest.approx(3.0, abs=1e-9)
|
||||
|
||||
def test_min_delay_already_elapsed_returns_zero(self):
|
||||
"""Last search 10s ago, min-delay 5s → already cleared, no wait."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0],
|
||||
last_search_at=100.0,
|
||||
now=110.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=5,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
def test_min_delay_skipped_on_very_first_search(self):
|
||||
"""`last_search_at == 0` means there's never been a search.
|
||||
Don't gate the very first one — that would force an arbitrary
|
||||
startup delay for no reason."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=0.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=10,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Both gates active — max wins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaxOfBothGates:
|
||||
def test_returns_window_wait_when_window_wait_is_larger(self):
|
||||
"""Window says wait 100s, min-delay says wait 5s → return 100s."""
|
||||
timestamps = [0.0 + i for i in range(35)] # 0..34
|
||||
# now = 5, window = 220, oldest = 0 → ages out at 220 → wait 215
|
||||
# min_delay = 5, last = 4, now = 5 → wait 4
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=timestamps,
|
||||
last_search_at=4.0,
|
||||
now=5.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=5,
|
||||
)
|
||||
assert wait == pytest.approx(215.0, abs=1e-9)
|
||||
|
||||
def test_returns_min_delay_wait_when_min_delay_is_larger(self):
|
||||
"""Window not full → window wait = 0. Min-delay 30s, last 5s
|
||||
ago → wait 25s. Min-delay drives it."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0, 105.0],
|
||||
last_search_at=105.0,
|
||||
now=110.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=30,
|
||||
)
|
||||
assert wait == pytest.approx(25.0, abs=1e-9)
|
||||
|
||||
def test_both_zero_returns_zero(self):
|
||||
"""Window not full + min-delay clear → zero. Sanity."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0],
|
||||
last_search_at=50.0,
|
||||
now=200.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=10,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defensive — input shape variations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefensive:
|
||||
def test_negative_min_delay_treated_as_disabled(self):
|
||||
"""Defensive: a negative min-delay (somehow) shouldn't return
|
||||
a negative wait or trigger weird behavior. Treat as disabled."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=99.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=-5,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
def test_returns_float(self):
|
||||
"""Caller passes to asyncio.sleep which wants a float. Pin shape."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=0.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert isinstance(wait, float)
|
||||
Loading…
Reference in new issue