Skip to content

Commit 8169a2c

Browse files
authored
fix(downloader): cap qB httpx keepalive to prevent stale-socket storms (#1028)
Closes #984 Apply httpx.Limits(keepalive_expiry=30, max_connections=10, max_keepalive_connections=5) to the qBittorrent AsyncClient — same recipe as #1018 for the RSS side. Prevents the "Server disconnected" cascade when a proxy / NAS silently reaps idle sockets between renamer cycles.
1 parent 1fbc0e3 commit 8169a2c

2 files changed

Lines changed: 118 additions & 26 deletions

File tree

backend/src/module/downloader/client/qb_downloader.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,19 @@ async def auth(self, retry=3):
2828
times = 0
2929
use_https = self.host.startswith("https://")
3030
timeout = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0)
31+
# Keepalive_expiry keeps idle TCP sockets short-lived so they can't
32+
# outlive a proxy / NAS idle-reap, which would otherwise surface as
33+
# "Server disconnected without sending a response" when the next
34+
# renamer cycle reuses the pool (#984). max_connections caps parallel
35+
# load on the downloader and anything fronting it.
36+
limits = httpx.Limits(
37+
max_keepalive_connections=5,
38+
max_connections=10,
39+
keepalive_expiry=30.0,
40+
)
3141
# Never verify certificates - self-signed certs are the norm for
3242
# home-server / NAS / Docker qBittorrent setups.
33-
self._client = httpx.AsyncClient(timeout=timeout, verify=False)
43+
self._client = httpx.AsyncClient(timeout=timeout, limits=limits, verify=False)
3444
while times < retry:
3545
try:
3646
resp = await self._client.post(
@@ -242,7 +252,8 @@ async def torrents_rename_file(
242252
break
243253
# Final attempt failed
244254
logger.debug(
245-
"[Downloader] Rename API returned 200 but file unchanged: %s", old_path
255+
"[Downloader] Rename API returned 200 but file unchanged: %s",
256+
old_path,
246257
)
247258
return False
248259
# new_path found or old_path not found

backend/src/test/test_qb_downloader.py

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ class TestQbDownloaderConstructor:
2424

2525
def test_ssl_true_no_scheme_uses_https(self):
2626
"""ssl=True with bare host prepends https://."""
27-
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=True)
27+
qb = QbDownloader(
28+
host="192.168.1.10:8080", username="admin", password="pass", ssl=True
29+
)
2830
assert qb.host == "https://192.168.1.10:8080"
2931

3032
def test_ssl_false_no_scheme_uses_http(self):
3133
"""ssl=False with bare host prepends http://."""
32-
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=False)
34+
qb = QbDownloader(
35+
host="192.168.1.10:8080", username="admin", password="pass", ssl=False
36+
)
3337
assert qb.host == "http://192.168.1.10:8080"
3438

3539
def test_explicit_http_scheme_preserved_when_ssl_true(self):
@@ -42,18 +46,25 @@ def test_explicit_http_scheme_preserved_when_ssl_true(self):
4246
def test_explicit_https_scheme_preserved_when_ssl_false(self):
4347
"""Explicit https:// scheme is kept even if ssl=False."""
4448
qb = QbDownloader(
45-
host="https://192.168.1.10:8080", username="admin", password="pass", ssl=False
49+
host="https://192.168.1.10:8080",
50+
username="admin",
51+
password="pass",
52+
ssl=False,
4653
)
4754
assert qb.host == "https://192.168.1.10:8080"
4855

4956
def test_explicit_http_scheme_preserved_ssl_false(self):
5057
"""Explicit http:// URL with ssl=False stays as http://."""
51-
qb = QbDownloader(host="http://nas.local:8080", username="u", password="p", ssl=False)
58+
qb = QbDownloader(
59+
host="http://nas.local:8080", username="u", password="p", ssl=False
60+
)
5261
assert qb.host == "http://nas.local:8080"
5362

5463
def test_explicit_https_scheme_preserved_ssl_true(self):
5564
"""Explicit https:// URL with ssl=True stays as https://."""
56-
qb = QbDownloader(host="https://nas.local:8080", username="u", password="p", ssl=True)
65+
qb = QbDownloader(
66+
host="https://nas.local:8080", username="u", password="p", ssl=True
67+
)
5768
assert qb.host == "https://nas.local:8080"
5869

5970
def test_credentials_stored(self):
@@ -67,7 +78,9 @@ def test_credentials_stored(self):
6778

6879
def test_client_initially_none(self):
6980
"""_client starts as None before any auth call."""
70-
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
81+
qb = QbDownloader(
82+
host="localhost:8080", username="admin", password="pass", ssl=False
83+
)
7184
assert qb._client is None
7285

7386

@@ -110,7 +123,9 @@ class TestAuthClientCreation:
110123

111124
async def test_auth_creates_client_with_verify_false_when_ssl_true(self):
112125
"""verify=False is used even when ssl=True (self-signed certs are common)."""
113-
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=True)
126+
qb = QbDownloader(
127+
host="192.168.1.10:8080", username="admin", password="pass", ssl=True
128+
)
114129

115130
captured: list[dict] = []
116131

@@ -127,7 +142,9 @@ async def post(self, url, data=None):
127142
async def aclose(self):
128143
pass
129144

130-
with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient):
145+
with patch(
146+
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
147+
):
131148
result = await qb.auth()
132149

133150
assert result is True
@@ -136,7 +153,9 @@ async def aclose(self):
136153

137154
async def test_auth_creates_client_with_verify_false_when_ssl_false(self):
138155
"""verify=False is used even when ssl=False."""
139-
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=False)
156+
qb = QbDownloader(
157+
host="192.168.1.10:8080", username="admin", password="pass", ssl=False
158+
)
140159

141160
captured: list[dict] = []
142161

@@ -153,7 +172,9 @@ async def post(self, url, data=None):
153172
async def aclose(self):
154173
pass
155174

156-
with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient):
175+
with patch(
176+
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
177+
):
157178
result = await qb.auth()
158179

159180
assert result is True
@@ -178,12 +199,46 @@ async def post(self, url, data=None):
178199
async def aclose(self):
179200
pass
180201

181-
with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient):
202+
with patch(
203+
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
204+
):
182205
await qb.auth()
183206

184207
assert len(captured_timeouts) == 1
185208
assert captured_timeouts[0].connect == pytest.approx(5.0)
186209

210+
async def test_auth_sets_connection_limits_for_keepalive(self):
211+
"""Regression for #984: qB client must cap keepalive so idle TCP
212+
sockets don't linger past proxy / NAS idle-reap timeouts, otherwise
213+
parallel renamer calls cascade into 'Server disconnected' errors."""
214+
qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False)
215+
216+
captured: list[dict] = []
217+
218+
class _FakeClient:
219+
def __init__(self, **kwargs):
220+
captured.append(kwargs)
221+
222+
async def post(self, url, data=None):
223+
resp = MagicMock()
224+
resp.status_code = 200
225+
resp.text = "Ok."
226+
return resp
227+
228+
async def aclose(self):
229+
pass
230+
231+
with patch(
232+
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
233+
):
234+
await qb.auth()
235+
236+
limits = captured[0].get("limits")
237+
assert limits is not None
238+
assert limits.keepalive_expiry is not None
239+
assert limits.keepalive_expiry > 0
240+
assert limits.max_connections is not None
241+
187242

188243
# ---------------------------------------------------------------------------
189244
# auth: success / failure paths
@@ -195,7 +250,9 @@ class TestAuthSuccessFailure:
195250

196251
async def test_auth_returns_true_on_ok_response(self):
197252
"""Returns True when server responds 200 + 'Ok.'."""
198-
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
253+
qb = QbDownloader(
254+
host="localhost:8080", username="admin", password="pass", ssl=False
255+
)
199256

200257
mock_client = AsyncMock()
201258
mock_resp = MagicMock()
@@ -213,7 +270,9 @@ async def test_auth_returns_true_on_ok_response(self):
213270

214271
async def test_auth_returns_false_on_403(self):
215272
"""Returns False and stops retrying immediately on 403 Forbidden."""
216-
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
273+
qb = QbDownloader(
274+
host="localhost:8080", username="admin", password="pass", ssl=False
275+
)
217276

218277
mock_client = AsyncMock()
219278
mock_resp = MagicMock()
@@ -233,7 +292,9 @@ async def test_auth_returns_false_on_403(self):
233292

234293
async def test_auth_retries_up_to_limit_on_server_error(self):
235294
"""Retries up to the retry limit on non-200/non-403 responses."""
236-
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
295+
qb = QbDownloader(
296+
host="localhost:8080", username="admin", password="pass", ssl=False
297+
)
237298

238299
mock_client = AsyncMock()
239300
mock_resp = MagicMock()
@@ -271,7 +332,9 @@ async def test_https_url_logs_https_specific_guidance(self, caplog):
271332
)
272333

273334
mock_client = AsyncMock()
274-
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
335+
mock_client.post = AsyncMock(
336+
side_effect=httpx.ConnectError("Connection refused")
337+
)
275338

276339
with patch(
277340
"module.downloader.client.qb_downloader.httpx.AsyncClient",
@@ -287,7 +350,9 @@ async def test_https_url_logs_https_specific_guidance(self, caplog):
287350
result = await qb.auth(retry=1)
288351

289352
assert result is False
290-
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
353+
error_messages = [
354+
r.message for r in caplog.records if r.levelno == logging.ERROR
355+
]
291356
assert any("HTTPS" in msg for msg in error_messages)
292357
assert any(
293358
"disable SSL" in msg or "plain HTTP" in msg for msg in error_messages
@@ -296,7 +361,9 @@ async def test_https_url_logs_https_specific_guidance(self, caplog):
296361
async def test_https_url_derived_from_ssl_flag_logs_https_guidance(self, caplog):
297362
"""HTTPS guidance also fires when scheme comes from ssl=True (bare host)."""
298363
# Bare host + ssl=True -> self.host becomes https://... -> use_https=True in auth()
299-
qb = QbDownloader(host="192.168.1.10:8080", username="u", password="p", ssl=True)
364+
qb = QbDownloader(
365+
host="192.168.1.10:8080", username="u", password="p", ssl=True
366+
)
300367
assert qb.host.startswith("https://")
301368

302369
mock_client = AsyncMock()
@@ -315,7 +382,9 @@ async def test_https_url_derived_from_ssl_flag_logs_https_guidance(self, caplog)
315382
):
316383
await qb.auth(retry=1)
317384

318-
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
385+
error_messages = [
386+
r.message for r in caplog.records if r.levelno == logging.ERROR
387+
]
319388
assert any("HTTPS" in msg for msg in error_messages)
320389

321390
async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog):
@@ -325,7 +394,9 @@ async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog):
325394
)
326395

327396
mock_client = AsyncMock()
328-
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
397+
mock_client.post = AsyncMock(
398+
side_effect=httpx.ConnectError("Connection refused")
399+
)
329400

330401
with patch(
331402
"module.downloader.client.qb_downloader.httpx.AsyncClient",
@@ -341,14 +412,20 @@ async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog):
341412
result = await qb.auth(retry=1)
342413

343414
assert result is False
344-
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
345-
assert any("Cannot connect to qBittorrent Server" in msg for msg in error_messages)
415+
error_messages = [
416+
r.message for r in caplog.records if r.levelno == logging.ERROR
417+
]
418+
assert any(
419+
"Cannot connect to qBittorrent Server" in msg for msg in error_messages
420+
)
346421
# SSL-disable hint must NOT appear for plain HTTP connections
347422
assert not any("disable SSL" in msg for msg in error_messages)
348423

349424
async def test_http_url_derived_from_ssl_flag_false_no_ssl_hint(self, caplog):
350425
"""SSL-disable hint is absent when scheme comes from ssl=False (bare host)."""
351-
qb = QbDownloader(host="192.168.1.10:8080", username="u", password="p", ssl=False)
426+
qb = QbDownloader(
427+
host="192.168.1.10:8080", username="u", password="p", ssl=False
428+
)
352429
assert qb.host.startswith("http://")
353430

354431
mock_client = AsyncMock()
@@ -420,7 +497,9 @@ async def test_explicit_http_with_ssl_true_still_uses_generic_message(self, capl
420497
):
421498
await qb.auth(retry=1)
422499

423-
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
500+
error_messages = [
501+
r.message for r in caplog.records if r.levelno == logging.ERROR
502+
]
424503
assert not any("disable SSL" in msg for msg in error_messages)
425504
assert not any("HTTPS" in msg for msg in error_messages)
426505

@@ -445,5 +524,7 @@ def test_url_format_with_https(self):
445524

446525
def test_url_with_explicit_http_scheme_overriding_ssl_true(self):
447526
"""_url works correctly when explicit http:// scheme overrides ssl=True."""
448-
qb = QbDownloader(host="http://nas.local:8080", username="u", password="p", ssl=True)
527+
qb = QbDownloader(
528+
host="http://nas.local:8080", username="u", password="p", ssl=True
529+
)
449530
assert qb._url("torrents/info") == "http://nas.local:8080/api/v2/torrents/info"

0 commit comments

Comments
 (0)