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:
2026-06-16 07:58:34 +02:00
parent 4a49407883
commit 46b36f6b08
6 changed files with 270 additions and 15 deletions
+125
View File
@@ -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
# ──────────────────────────────────────────────────────────────────────