Harden DsmClient against a slow/stalled NAS so no tool can hang
A second, independent failure mode behind the intermittent "MCP not
responding" reports: every tool shares one HTTP client with generous
timeouts (FileStation download/upload used 60s) and no retry. When DSM
briefly stalled (FileStation busy, NAS under load) a single call blocked
for up to a minute, and follow-up calls - even read-only ones like
read_compose - stalled with it, so the whole MCP appeared dead. The path
to the NAS is direct LAN (dsm.gecheckt.de -> 192.168.0.2), so this is DSM
responsiveness, not a network/NAT-loopback issue.
Route every DSM round-trip through a new DsmClient._send that adds:
- a hard wall-clock ceiling (asyncio.wait_for, HARD_CALL_TIMEOUT=25s) so a
call returns even if DSM accepts a request but never responds;
- a single retry on a fresh connection for transient transport failures -
always for connection-establishment errors (request provably never
reached DSM, safe even for POST), and for GETs also on read/protocol
errors (also absorbs the keepalive race where DSM drops an idle conn);
- a clean SynologyError ("NAS did not respond, it may be busy") instead of
a raw transport traceback or a long silent hang.
Also tighten timeouts: default read 30s->15s, FileStation 60s->20s,
build_stream per-read 60s->20s. build_stream stays out of _send (it
streams and manages its own budget).
- add 6 tests for retry/no-retry/ceiling behaviour (320 pass)
- update CLAUDE.md and CHANGELOG; bump 0.8.0 -> 0.9.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -485,6 +485,131 @@ async def test_request_reauth_thundering_herd_login_called_once() -> None:
|
||||
assert client._http.get.call_count == 4
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _send: hard ceiling + single retry on transient transport failures
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_retries_once_on_connect_error() -> None:
|
||||
"""A GET that fails to connect is retried once on a fresh connection."""
|
||||
async with DsmClient(base_url="https://nas.local:443") as client:
|
||||
mark_initialized(client)
|
||||
client._http = AsyncMock()
|
||||
client._http.get = AsyncMock(
|
||||
side_effect=[
|
||||
httpx.ConnectError("connection refused"),
|
||||
make_response({"success": True, "data": {"ok": 1}}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.request("SYNO.Docker.Project", "list")
|
||||
|
||||
assert result == {"ok": 1}
|
||||
assert client._http.get.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_retries_on_read_timeout_for_get() -> None:
|
||||
"""A read timeout on an idempotent GET is retried (server may have stalled)."""
|
||||
async with DsmClient(base_url="https://nas.local:443") as client:
|
||||
mark_initialized(client)
|
||||
client._http = AsyncMock()
|
||||
client._http.get = AsyncMock(
|
||||
side_effect=[
|
||||
httpx.ReadTimeout("slow"),
|
||||
make_response({"success": True, "data": {"ok": 2}}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.request("SYNO.Docker.Project", "list")
|
||||
|
||||
assert result == {"ok": 2}
|
||||
assert client._http.get.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_both_attempts_fail_raises_clean_error() -> None:
|
||||
"""When both attempts fail, a clean SynologyError (code 0) is raised — no
|
||||
raw transport exception leaks to the tool layer."""
|
||||
async with DsmClient(base_url="https://nas.local:443") as client:
|
||||
mark_initialized(client)
|
||||
client._http = AsyncMock()
|
||||
client._http.get = AsyncMock(side_effect=httpx.ConnectError("nas down"))
|
||||
|
||||
with pytest.raises(SynologyError) as exc_info:
|
||||
await client.request("SYNO.Docker.Project", "list")
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert "did not respond" in str(exc_info.value)
|
||||
assert client._http.get.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_request_not_retried_on_read_timeout() -> None:
|
||||
"""A read timeout on a POST is NOT retried — DSM may already have acted on
|
||||
the (non-idempotent) request."""
|
||||
async with DsmClient(base_url="https://nas.local:443") as client:
|
||||
mark_initialized(client)
|
||||
client._http = AsyncMock()
|
||||
client._http.post = AsyncMock(side_effect=httpx.ReadTimeout("slow"))
|
||||
|
||||
with pytest.raises(SynologyError) as exc_info:
|
||||
await client.post_request("SYNO.Docker.Project", "create")
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert client._http.post.call_count == 1 # no retry for non-idempotent POST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_request_retried_on_connect_error() -> None:
|
||||
"""A pre-send connection failure on a POST IS retried — the request provably
|
||||
never reached DSM, so it is safe even though POST is non-idempotent."""
|
||||
async with DsmClient(base_url="https://nas.local:443") as client:
|
||||
mark_initialized(client)
|
||||
client._http = AsyncMock()
|
||||
client._http.post = AsyncMock(
|
||||
side_effect=[
|
||||
httpx.ConnectError("connection refused"),
|
||||
make_response({"success": True, "data": {}}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.post_request("SYNO.Docker.Project", "create")
|
||||
|
||||
assert result == {}
|
||||
assert client._http.post.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_hard_ceiling_bounds_a_stalled_call(monkeypatch) -> None:
|
||||
"""If a call exceeds HARD_CALL_TIMEOUT (DSM accepts but never responds), the
|
||||
wall-clock ceiling fires, the GET is retried once, then a clean error is
|
||||
raised — the tool never hangs for the full read timeout."""
|
||||
import mcp_synology_container.dsm_client as dc
|
||||
|
||||
monkeypatch.setattr(dc, "HARD_CALL_TIMEOUT", 0.02)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def stalled_get(_url: str, **_kwargs: Any) -> Any:
|
||||
calls["n"] += 1
|
||||
await asyncio.sleep(5.0) # cancelled by the ceiling well before this
|
||||
return make_response({"success": True, "data": {}})
|
||||
|
||||
async with DsmClient(base_url="https://nas.local:443") as client:
|
||||
mark_initialized(client)
|
||||
client._http = AsyncMock()
|
||||
client._http.get = stalled_get
|
||||
|
||||
with pytest.raises(SynologyError) as exc_info:
|
||||
await client.request("SYNO.Docker.Project", "list")
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert "did not respond" in str(exc_info.value)
|
||||
assert calls["n"] == 2 # ceiling fired on both the initial GET and the retry
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# trigger_build_stream
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user