fix: v0.2.8 — init cooldown to prevent repeated failed-login hammering

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 10:06:35 +02:00
parent e17a70aecf
commit 4caac3a6c7
3 changed files with 23 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.2.7" version = "0.2.8"
description = "MCP server for Synology Container Manager" description = "MCP server for Synology Container Manager"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+21
View File
@@ -27,6 +27,10 @@ logger = logging.getLogger(__name__)
# Session error codes that trigger transparent re-auth # Session error codes that trigger transparent re-auth
_SESSION_ERROR_CODES = frozenset({106, 107, 119}) _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 # Parameters to mask in debug logging
_SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"}) _SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"})
@@ -111,6 +115,8 @@ class DsmClient:
self._init_lock = asyncio.Lock() self._init_lock = asyncio.Lock()
self._initialized = False self._initialized = False
self._initializing = False # True while inside _ensure_initialized self._initializing = False # True while inside _ensure_initialized
self._init_error: Exception | None = None
self._init_error_until: float = 0.0
logger.debug( logger.debug(
"DsmClient: base_url=%s verify_ssl=%s timeout=%d", "DsmClient: base_url=%s verify_ssl=%s timeout=%d",
self._base_url, self._base_url,
@@ -141,6 +147,10 @@ class DsmClient:
async with self._init_lock: async with self._init_lock:
if self._initialized: # re-check inside lock if self._initialized: # re-check inside lock
return 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 self._initializing = True
try: try:
sys.stderr.write(f"[dsm] Connecting to {self._base_url}...\n") 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.write("[dsm] Auth OK\n")
sys.stderr.flush() sys.stderr.flush()
self._initialized = True self._initialized = True
self._init_error = None
self._init_error_until = 0.0
logger.debug("Lazy init complete") 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: finally:
self._initializing = False self._initializing = False
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]] [[package]]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.2.7" version = "0.2.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },