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
+104 -13
View File
@@ -31,6 +31,30 @@ _SESSION_ERROR_CODES = frozenset({106, 107, 119})
# (and don't escalate 407 "IP blocked" lockouts).
INIT_ERROR_COOLDOWN = 60.0
# Hard wall-clock ceiling (seconds) for a single HTTP round-trip to DSM,
# enforced with asyncio.wait_for as a backstop. DSM occasionally accepts a
# request but is slow to respond (FileStation busy, NAS under load); without a
# ceiling a single stalled call blocks its tool — including read-only ones like
# read_compose — for the full read timeout (which used to be 60 s for
# FileStation). Kept above the per-request read timeouts so httpx's own timeout
# normally fires first; this guarantees no call hangs much longer even in cases
# httpx's timeout does not catch (e.g. a connection-level stall).
HARD_CALL_TIMEOUT = 25.0
# Per-request read timeout (seconds) for FileStation download/upload. Small
# compose files transfer in well under a second; a higher value only prolongs
# hangs when DSM stalls. Must stay below HARD_CALL_TIMEOUT.
FILESTATION_TIMEOUT = 20.0
# Transport failures where the request provably never reached the server
# (connection not established), so a retry is safe even for non-idempotent
# POSTs.
_PRESEND_TRANSPORT_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
# Transport failures where DSM may already have received (and acted on) the
# request, so a retry is only safe for idempotent GETs.
_READ_TRANSPORT_ERRORS = (httpx.ReadTimeout, httpx.WriteTimeout, httpx.RemoteProtocolError)
# Parameters to mask in debug logging
_SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"})
@@ -102,7 +126,7 @@ class DsmClient:
self,
base_url: str,
verify_ssl: bool = True,
timeout: int = 30,
timeout: int = 15,
) -> None:
self._base_url = base_url.rstrip("/")
self._verify_ssl = verify_ssl
@@ -202,6 +226,74 @@ class DsmClient:
raise RuntimeError(msg)
return self._http
async def _send(
self,
method: str,
url: str,
*,
retry_safe: bool,
**kwargs: Any,
) -> httpx.Response:
"""Perform one HTTP request to DSM with a hard ceiling and one retry.
Two robustness guarantees on top of the raw httpx call:
1. **Hard wall-clock ceiling** (``asyncio.wait_for`` /
:data:`HARD_CALL_TIMEOUT`): even if DSM accepts the request but never
responds, the call returns within the ceiling — so no tool, not even
a read-only one, can hang for minutes on a busy NAS.
2. **Single retry on a fresh connection** for transient transport
failures: always for connection-establishment errors (the request
provably never reached DSM), and additionally for read/protocol
errors when the operation is idempotent (``retry_safe``). The failed
connection is closed by httpx, so the retry lands on a fresh one —
this also absorbs the keepalive race where DSM closes an idle
connection just as a request is sent.
Args:
method: ``"GET"`` or ``"POST"``.
url: Full request URL.
retry_safe: True for idempotent operations (GET). Read-level
failures are only retried when True; a POST that may already
have been processed is never retried.
**kwargs: Forwarded to the underlying httpx call (params, data,
files, timeout, ...).
Returns:
The httpx.Response. The status is not checked here — the caller
still calls ``raise_for_status()``.
Raises:
SynologyError: If both attempts fail with a transport error or the
hard ceiling is hit (code 0).
"""
http = self._get_http()
send = http.get if method == "GET" else http.post
last_exc: Exception | None = None
for attempt in range(2):
try:
return await asyncio.wait_for(send(url, **kwargs), timeout=HARD_CALL_TIMEOUT)
except _PRESEND_TRANSPORT_ERRORS as e:
last_exc = e # request never reached DSM — safe to retry
except (*_READ_TRANSPORT_ERRORS, TimeoutError) as e:
# TimeoutError covers the asyncio.wait_for ceiling. DSM may have
# acted on the request, so only retry idempotent GETs.
last_exc = e
if not retry_safe:
break
if attempt == 0:
logger.warning(
"DSM %s %s failed (%s) — retrying once on a fresh connection",
method,
_scrub_url(url),
type(last_exc).__name__,
)
raise SynologyError(
f"NAS at {self._base_url} did not respond ({type(last_exc).__name__}). "
"It may be busy — please retry shortly.",
code=0,
) from last_exc
async def query_api_info(self) -> dict[str, dict[str, Any]]:
"""Query SYNO.API.Info to discover all available APIs and cache them.
@@ -210,7 +302,6 @@ class DsmClient:
Returns:
Dict mapping API name -> {path, minVersion, maxVersion}.
"""
http = self._get_http()
url = f"{self._base_url}/webapi/query.cgi"
params = {
"api": "SYNO.API.Info",
@@ -220,7 +311,7 @@ class DsmClient:
}
logger.debug("Querying API info from %s", url)
resp = await http.get(url, params=params)
resp = await self._send("GET", url, params=params, retry_safe=True)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
@@ -281,7 +372,6 @@ class DsmClient:
# The API cache is populated before login, so the cache is ready at this point.
if not self._initializing:
await self._ensure_initialized()
http = self._get_http()
if api not in self._api_cache:
raise SynologyError(
@@ -310,7 +400,7 @@ class DsmClient:
"DSM GET%s: %s/%s v%d%s", retry_tag, api, method, resolved_version, log_params
)
resp = await http.get(url, params=req_params)
resp = await self._send("GET", url, params=req_params, retry_safe=True)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
@@ -374,7 +464,6 @@ class DsmClient:
sys.stderr.write(f"[dsm] post_request: {api}/{method}\n")
sys.stderr.flush()
await self._ensure_initialized()
http = self._get_http()
if api not in self._api_cache:
raise SynologyError(
@@ -401,7 +490,7 @@ class DsmClient:
log_form = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in form.items()}
logger.debug("DSM POST: %s/%s v%d%s", api, method, resolved_version, log_form)
resp = await http.post(url, params=query_params, data=form)
resp = await self._send("POST", url, params=query_params, data=form, retry_safe=False)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
@@ -513,7 +602,7 @@ class DsmClient:
params=params,
timeout=httpx.Timeout(
connect=10.0,
read=60.0,
read=20.0,
write=10.0,
pool=5.0,
),
@@ -617,7 +706,6 @@ class DsmClient:
"""
api = "SYNO.FileStation.Upload"
await self._ensure_initialized()
http = self._get_http()
if api not in self._api_cache:
raise SynologyError(f"API '{api}' not found. Call query_api_info() first.", code=102)
@@ -647,12 +735,14 @@ class DsmClient:
)
encoded = content.encode("utf-8")
resp = await http.post(
resp = await self._send(
"POST",
url,
params=query_params,
data=form_data,
files={"file": (filename, encoded, "text/plain")},
timeout=httpx.Timeout(60.0),
timeout=httpx.Timeout(FILESTATION_TIMEOUT),
retry_safe=False,
)
try:
resp.raise_for_status()
@@ -681,7 +771,6 @@ class DsmClient:
"""
api = "SYNO.FileStation.Download"
await self._ensure_initialized()
http = self._get_http()
if api not in self._api_cache:
raise SynologyError(f"API '{api}' not found. Call query_api_info() first.", code=102)
@@ -703,7 +792,9 @@ class DsmClient:
log_params = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in params.items()}
logger.debug("DSM GET: %s/download v%d%s", api, resolved_version, log_params)
resp = await http.get(url, params=params, timeout=httpx.Timeout(60.0))
resp = await self._send(
"GET", url, params=params, timeout=httpx.Timeout(FILESTATION_TIMEOUT), retry_safe=True
)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e: