From 4caac3a6c77201a243ca586ccd57f02c0b561f71 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 21 Apr 2026 10:06:35 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20v0.2.8=20=E2=80=94=20init=20cooldown=20t?= =?UTF-8?q?o=20prevent=20repeated=20failed-login=20hammering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache failed `_ensure_initialized` outcomes for 60 seconds so that repeated tool calls during a credential outage (wrong password, IP-blocked 407, DNS failure) don't keep hammering DSM. Each caller gets the same exception raised from the cache until the cooldown window expires, after which a fresh attempt is made. - Adds INIT_ERROR_COOLDOWN module constant (60.0 s). - Adds self._init_error / self._init_error_until state on DsmClient. - Re-raises cached error inside the init lock, emits warning log on cache entry. Addresses M4 from the 0.2.7 review. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- src/mcp_synology_container/dsm_client.py | 21 +++++++++++++++++++++ uv.lock | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35aa05f..b1082bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.2.7" +version = "0.2.8" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/dsm_client.py b/src/mcp_synology_container/dsm_client.py index 2c77f62..7855dae 100644 --- a/src/mcp_synology_container/dsm_client.py +++ b/src/mcp_synology_container/dsm_client.py @@ -27,6 +27,10 @@ logger = logging.getLogger(__name__) # Session error codes that trigger transparent re-auth _SESSION_ERROR_CODES = frozenset({106, 107, 119}) +# Cooldown after a failed init so repeated tool calls don't hammer DSM +# (and don't escalate 407 "IP blocked" lockouts). +INIT_ERROR_COOLDOWN = 60.0 + # Parameters to mask in debug logging _SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"}) @@ -111,6 +115,8 @@ class DsmClient: self._init_lock = asyncio.Lock() self._initialized = False self._initializing = False # True while inside _ensure_initialized + self._init_error: Exception | None = None + self._init_error_until: float = 0.0 logger.debug( "DsmClient: base_url=%s verify_ssl=%s timeout=%d", self._base_url, @@ -141,6 +147,10 @@ class DsmClient: async with self._init_lock: if self._initialized: # re-check inside lock return + # Negative cache: re-raise cached error during cooldown window. + now = asyncio.get_event_loop().time() + if self._init_error is not None and now < self._init_error_until: + raise self._init_error self._initializing = True try: sys.stderr.write(f"[dsm] Connecting to {self._base_url}...\n") @@ -157,7 +167,18 @@ class DsmClient: sys.stderr.write("[dsm] Auth OK\n") sys.stderr.flush() self._initialized = True + self._init_error = None + self._init_error_until = 0.0 logger.debug("Lazy init complete") + except Exception as e: + self._init_error = e + self._init_error_until = now + INIT_ERROR_COOLDOWN + logger.warning( + "Lazy init failed (%s); will retry after %.0fs cooldown", + type(e).__name__, + INIT_ERROR_COOLDOWN, + ) + raise finally: self._initializing = False diff --git a/uv.lock b/uv.lock index ba81567..0287a4d 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.2.7" +version = "0.2.8" source = { editable = "." } dependencies = [ { name = "click" },