feat(error-handling): add network error differentiation, retry, and REST API detection (C.5)

WordPress client improvements:
- Add ConnectionError exception for network-level failures
- Differentiate DNS, SSL, timeout, and connection refused errors
- Add retry with exponential backoff for transient errors (502, 503, 504, 429)
- Auth/config errors are never retried
- Add request timeout (30s default)

Health check improvements:
- Detect REST API disabled (403/404 on /wp-json)
- Classify errors by type: dns_failure, ssl_error, timeout, connection_refused

Bare except cleanup:
- Replaced all bare except: with except Exception: across 12 files

Tests: 303 passed (13 new tests for error handling)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-19 14:58:16 +03:30
parent c57fd666c9
commit 647a650629
12 changed files with 559 additions and 67 deletions

View File

@@ -553,6 +553,7 @@ class HealthMonitor:
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
"""Basic HTTP health check as a last-resort fallback."""
import aiohttp
if not url:
@@ -568,8 +569,27 @@ class HealthMonitor:
"status_code": resp.status,
"message": f"HTTP {resp.status} from {url}",
}
except TimeoutError:
return {
"healthy": False,
"error_type": "timeout",
"message": f"Site at {url} did not respond within 10 seconds.",
}
except aiohttp.ClientConnectorDNSError:
host = url.split("://")[-1].split("/")[0]
return {
"healthy": False,
"error_type": "dns_failure",
"message": f"DNS resolution failed for '{host}'. Check the URL.",
}
except aiohttp.ClientConnectorError:
return {
"healthy": False,
"error_type": "connection_refused",
"message": f"Cannot connect to {url}. Server unreachable.",
}
except Exception as e:
return {"healthy": False, "message": f"Connection failed: {e}"}
return {"healthy": False, "error_type": "unknown", "message": f"Connection failed: {e}"}
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
"""

View File

@@ -241,8 +241,8 @@ class TokenManager:
try:
old_payload = self.validate_access_token(token_data.access_token)
scope = old_payload.get("scope", "read")
except:
pass # Old token might be expired, use default
except Exception:
pass # Old token might be expired, use default scope
# Generate new tokens
new_access_token = self.generate_access_token(

View File

@@ -448,12 +448,15 @@ class ToolGenerator:
except Exception as e:
# Import custom exceptions for better error handling
from plugins.wordpress.client import AuthenticationError, ConfigurationError
from plugins.wordpress.client import (
AuthenticationError,
ConfigurationError,
ConnectionError,
)
error_type = type(e).__name__
if isinstance(e, ConfigurationError):
# Configuration error - likely missing env vars
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
return (
f"Configuration Error: {str(e)}\n\n"
@@ -464,12 +467,14 @@ class ToolGenerator:
)
elif isinstance(e, AuthenticationError):
# Authentication error - 401/403
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
return f"Authentication Error: {str(e)}"
elif isinstance(e, ConnectionError):
logger.warning(f"Connection error in {plugin_type}_{method_name}: {e}")
return f"Connection Error: {str(e)}"
else:
# Unexpected error
logger.error(
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
exc_info=True,

View File

@@ -112,7 +112,7 @@ async def get_instance_info(client: N8nClient) -> str:
try:
health = await client.health_check()
info["health"] = health
except:
except Exception:
pass
# Get current user to verify connectivity
@@ -123,7 +123,7 @@ async def get_instance_info(client: N8nClient) -> str:
"email": user.get("email"),
"role": user.get("role"),
}
except:
except Exception:
pass
info["instance_url"] = client.site_url

View File

@@ -173,7 +173,7 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
# Variable exists, update it
await client.update_variable(key, value)
updated.append(key)
except:
except Exception:
# Variable doesn't exist, create it
await client.create_variable(key, value)
created.append(key)

View File

@@ -236,8 +236,8 @@ async def get_profile(client: OpenPanelClient, project_id: str, profile_id: str)
indent=2,
ensure_ascii=False,
)
except:
pass
except Exception:
pass # Profile event fetch is optional; fall through to generic response
return json.dumps(
{

View File

@@ -161,21 +161,21 @@ async def get_database_stats(client: SupabaseClient) -> str:
try:
tables = await client.list_tables()
stats["tables"] = len(tables) if isinstance(tables, list) else 0
except:
except Exception:
pass
# Get schema count
try:
schemas = await client.list_schemas()
stats["schemas"] = len(schemas) if isinstance(schemas, list) else 0
except:
except Exception:
pass
# Get extension count
try:
extensions = await client.list_extensions()
stats["extensions"] = len(extensions) if isinstance(extensions, list) else 0
except:
except Exception:
pass
# Get database size and version
@@ -186,7 +186,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
if isinstance(size_result, list) and len(size_result) > 0:
stats["size"] = size_result[0].get("size", "unknown")
stats["version"] = size_result[0].get("version", "unknown")
except:
except Exception:
pass
# Get connection info
@@ -196,7 +196,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
)
if isinstance(conn_result, list) and len(conn_result) > 0:
stats["active_connections"] = conn_result[0].get("connections", 0)
except:
except Exception:
pass
return json.dumps({"success": True, "database_stats": stats}, indent=2, ensure_ascii=False)
@@ -237,7 +237,7 @@ async def get_storage_stats(client: SupabaseClient) -> str:
if isinstance(files, list):
bucket_info["file_count"] = len(files)
stats["total_files"] += len(files)
except:
except Exception:
pass
stats["bucket_details"].append(bucket_info)
@@ -333,7 +333,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
await client.list_schemas()
info["services_available"].append(service)
except:
except Exception:
pass
# Try to get PostgreSQL version
@@ -341,7 +341,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
version_result = await client.execute_sql("SELECT version()")
if isinstance(version_result, list) and len(version_result) > 0:
info["postgres_version"] = version_result[0].get("version", "unknown")
except:
except Exception:
pass
return json.dumps({"success": True, "instance_info": info}, indent=2, ensure_ascii=False)

View File

@@ -5,9 +5,11 @@ Handles all HTTP communication with WordPress REST API.
Separates API communication from business logic.
"""
import asyncio
import base64
import json
import logging
import socket
from typing import Any
import aiohttp
@@ -25,6 +27,23 @@ class AuthenticationError(Exception):
pass
class ConnectionError(Exception):
"""Raised when a network connection to the site fails."""
pass
# Transient HTTP status codes that are worth retrying
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
# Default request timeout in seconds
_REQUEST_TIMEOUT = 30
# Retry configuration
_MAX_RETRIES = 2
_RETRY_BACKOFF_BASE = 1.0 # seconds
class WordPressClient:
"""
WordPress REST API client for HTTP communication.
@@ -140,9 +159,14 @@ class WordPressClient:
if json_data:
json_data = {k: v for k, v in json_data.items() if should_include(v)}
# Make request
# Make request with retry for transient errors
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
last_exception = None
for attempt in range(_MAX_RETRIES + 1):
try:
async with (
aiohttp.ClientSession() as session,
aiohttp.ClientSession(timeout=timeout) as session,
session.request(
method, url, params=params, json=json_data, data=data, headers=headers
) as response,
@@ -151,6 +175,16 @@ class WordPressClient:
if response.status >= 400:
error_text = await response.text()
# Retry on transient server errors (502, 503, 504, 429)
if response.status in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES:
wait = _RETRY_BACKOFF_BASE * (2**attempt)
self.logger.warning(
f"Transient error {response.status} from {url}, "
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
)
await asyncio.sleep(wait)
continue
# Parse structured error response
error_info = self._parse_error_response(
response.status, error_text, use_woocommerce
@@ -172,6 +206,78 @@ class WordPressClient:
# Return JSON response
return await response.json()
except (AuthenticationError, ConfigurationError):
raise # Never retry auth/config errors
except TimeoutError:
last_exception = ConnectionError(
f"Request timed out after {_REQUEST_TIMEOUT}s. "
f"The site at {self.site_url} is not responding. "
"Possible causes: site is overloaded, network is slow, "
"or the server is down."
)
if attempt < _MAX_RETRIES:
wait = _RETRY_BACKOFF_BASE * (2**attempt)
self.logger.warning(
f"Timeout connecting to {url}, "
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
)
await asyncio.sleep(wait)
continue
except aiohttp.ClientConnectorCertificateError as e:
raise ConnectionError(
f"SSL certificate error for {self.site_url}. "
"The site's SSL certificate is invalid or expired. "
f"Details: {e}"
) from e
except aiohttp.ClientConnectorDNSError as e:
host = self.site_url.split("://")[-1].split("/")[0]
raise ConnectionError(
f"DNS resolution failed for '{host}'. "
"The domain name could not be found. "
"Please check that the URL is correct."
) from e
except aiohttp.ClientConnectorError as e:
os_error = getattr(e, "os_error", None)
if isinstance(os_error, socket.gaierror):
host = self.site_url.split("://")[-1].split("/")[0]
raise ConnectionError(
f"DNS resolution failed for '{host}'. "
"The domain name could not be found. "
"Please check that the URL is correct."
) from e
raise ConnectionError(
f"Cannot connect to {self.site_url}. "
"The server is unreachable. Possible causes: "
"wrong URL, server is down, firewall blocking, or wrong port."
) from e
except aiohttp.InvalidURL:
raise ConnectionError(
f"Invalid URL: {self.site_url}. "
"Please provide a valid URL starting with https:// or http://."
)
except (aiohttp.ClientError, OSError) as e:
last_exception = ConnectionError(
f"Network error connecting to {self.site_url}: {e}"
)
if attempt < _MAX_RETRIES:
wait = _RETRY_BACKOFF_BASE * (2**attempt)
self.logger.warning(
f"Network error for {url}: {e}, "
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
)
await asyncio.sleep(wait)
continue
# All retries exhausted
raise last_exception # type: ignore[misc]
def _parse_error_response(
self, status_code: int, error_text: str, use_woocommerce: bool = False
) -> dict[str, Any]:
@@ -408,24 +514,29 @@ class WordPressClient:
Dict with 'available' bool and version info
"""
try:
# Try to access WooCommerce system status endpoint
response = await self.get("system_status", use_woocommerce=True)
return {
"available": True,
"version": response.get("environment", {}).get("version", "unknown"),
}
except Exception:
return {"available": False, "version": None}
except AuthenticationError:
return {"available": False, "version": None, "reason": "authentication_failed"}
except ConnectionError as e:
return {"available": False, "version": None, "reason": str(e)}
except Exception as e:
self.logger.debug(f"WooCommerce check failed: {e}")
return {"available": False, "version": None, "reason": str(e)}
async def check_site_health(self) -> dict[str, Any]:
"""
Check WordPress site health and accessibility.
Returns:
Dict with health status information
Dict with health status information including specific error diagnosis.
"""
timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(f"{self.site_url}/wp-json") as response:
if response.status == 200:
data = await response.json()
@@ -437,15 +548,88 @@ class WordPressClient:
"url": data.get("url", self.site_url),
"routes": len(data.get("routes", {})),
}
else:
# Detect REST API disabled (common with security plugins)
if response.status in (403, 404):
return {
"healthy": False,
"accessible": True,
"error_type": "rest_api_disabled",
"message": (
f"WordPress REST API returned {response.status}. "
"The REST API may be disabled by a security plugin "
"(e.g., Wordfence, iThemes Security, Disable REST API). "
"Please ensure the REST API is enabled for MCP Hub to work."
),
}
if response.status == 401:
return {
"healthy": False,
"accessible": True,
"error_type": "auth_required",
"message": (
"WordPress REST API requires authentication even for discovery. "
"This may be caused by a security plugin restricting public access."
),
}
return {
"healthy": False,
"accessible": True,
"error_type": "unexpected_status",
"message": f"Site returned HTTP {response.status}.",
}
except TimeoutError:
return {
"healthy": False,
"accessible": False,
"message": f"Site returned status {response.status}",
"error_type": "timeout",
"message": (
f"Site at {self.site_url} did not respond within 10 seconds. "
"The server may be overloaded or down."
),
}
except aiohttp.ClientConnectorDNSError:
host = self.site_url.split("://")[-1].split("/")[0]
return {
"healthy": False,
"accessible": False,
"error_type": "dns_failure",
"message": (
f"DNS resolution failed for '{host}'. " "Please check that the URL is correct."
),
}
except aiohttp.ClientConnectorCertificateError:
return {
"healthy": False,
"accessible": False,
"error_type": "ssl_error",
"message": (
f"SSL certificate error for {self.site_url}. "
"The certificate may be expired or invalid."
),
}
except aiohttp.ClientConnectorError:
return {
"healthy": False,
"accessible": False,
"error_type": "connection_refused",
"message": (
f"Cannot connect to {self.site_url}. "
"The server is unreachable — check URL, firewall, or server status."
),
}
except Exception as e:
self.logger.debug(f"Health check failed with unexpected error: {e}")
return {
"healthy": False,
"accessible": False,
"message": f"Health check failed: {str(e)}",
"error_type": "unknown",
"message": f"Health check failed: {e}",
}

View File

@@ -190,7 +190,7 @@ class MenusHandler:
# Try custom endpoint first, fallback to standard if not available
try:
menus = await self.client.get("menus", use_custom_namespace=True)
except:
except Exception:
# Fallback: use wp/v2/navigation endpoint (WP 5.9+)
menus = await self.client.get("navigation")
@@ -216,7 +216,7 @@ class MenusHandler:
# Get menu details
try:
menu = await self.client.get(f"menus/{menu_id}", use_custom_namespace=True)
except:
except Exception:
menu = await self.client.get(f"navigation/{menu_id}")
# Get menu items
@@ -256,7 +256,7 @@ class MenusHandler:
try:
menu = await self.client.post("menus", json_data=data, use_custom_namespace=True)
except:
except Exception:
# Try navigation endpoint
menu = await self.client.post("navigation", json_data=data)

View File

@@ -599,7 +599,7 @@ class WPCLIManager:
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
else:
size_str = "unknown"
except:
except (ValueError, IndexError, OSError):
size_str = "unknown"
return {
@@ -1198,7 +1198,7 @@ class WPCLIManager:
site_cmd = "core version"
version_result = await self._execute_wp_cli(site_cmd)
current_version = version_result.get("message", "unknown").strip()
except:
except Exception:
current_version = "unknown"
return {
@@ -1220,7 +1220,7 @@ class WPCLIManager:
try:
version_result = await self._execute_wp_cli("core version")
old_version = version_result.get("message", "unknown").strip()
except:
except Exception:
old_version = "unknown"
# Execute update (long timeout for downloads)
@@ -1230,7 +1230,7 @@ class WPCLIManager:
try:
version_result = await self._execute_wp_cli("core version")
new_version = version_result.get("message", "unknown").strip()
except:
except Exception:
new_version = "unknown"
# Determine update type

View File

@@ -362,7 +362,7 @@ class SystemHandler:
try:
db_result = await self.wp_cli.execute_command(db_cmd)
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
except:
except (ValueError, KeyError, Exception):
sizes["database_size_mb"] = 0.0
# Calculate total
@@ -456,7 +456,7 @@ class SystemHandler:
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
schedules_data = json.loads(schedules_result.get("output", "[]"))
schedules = {s.get("name"): s for s in schedules_data}
except:
except (json.JSONDecodeError, KeyError, Exception):
schedules = {}
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
@@ -514,7 +514,7 @@ class SystemHandler:
size_result = await self.wp_cli.execute_command(size_cmd)
log_size_bytes = int(size_result.get("output", "0").strip())
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
except:
except (ValueError, KeyError, Exception):
log_size_mb = 0.0
# Read log file (last N lines)

View File

@@ -4,14 +4,21 @@ Integration tests covering plugin initialization, configuration validation,
tool specifications, handler delegation, client behavior, and health checks.
"""
import asyncio
import base64
import json
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from plugins.base import BasePlugin, PluginRegistry
from plugins.wordpress.client import AuthenticationError, ConfigurationError, WordPressClient
from plugins.wordpress.client import (
AuthenticationError,
ConfigurationError,
ConnectionError,
WordPressClient,
)
from plugins.wordpress.plugin import WordPressPlugin
# --- WordPressClient Tests ---
@@ -282,6 +289,282 @@ class TestWordPressClientRequest:
)
# --- Connection Error Tests ---
class TestWordPressClientConnectionErrors:
"""Test network error differentiation and retry logic."""
@pytest.fixture
def client(self):
return WordPressClient(
site_url="https://example.com",
username="admin",
app_password="xxxx",
)
@pytest.mark.asyncio
async def test_dns_error_raises_connection_error(self, client):
"""DNS failure should raise ConnectionError with helpful message."""
dns_error = aiohttp.ClientConnectorDNSError(
connection_key=MagicMock(), os_error=OSError("Name resolution failed")
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.request = MagicMock(side_effect=dns_error)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(ConnectionError, match="DNS resolution failed"):
await client.request("GET", "posts")
@pytest.mark.asyncio
async def test_ssl_error_raises_connection_error(self, client):
"""SSL certificate error should raise ConnectionError with helpful message."""
ssl_error = aiohttp.ClientConnectorCertificateError(
connection_key=MagicMock(), certificate_error=Exception("cert expired")
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.request = MagicMock(side_effect=ssl_error)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(ConnectionError, match="SSL certificate error"):
await client.request("GET", "posts")
@pytest.mark.asyncio
async def test_connection_refused_raises_connection_error(self, client):
"""Connection refused should raise ConnectionError with helpful message."""
conn_error = aiohttp.ClientConnectorError(
connection_key=MagicMock(), os_error=OSError("Connection refused")
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.request = MagicMock(side_effect=conn_error)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(ConnectionError, match="Cannot connect"):
await client.request("GET", "posts")
@pytest.mark.asyncio
async def test_timeout_retries_then_raises(self, client):
"""Timeout should retry then raise ConnectionError."""
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.request = MagicMock(side_effect=TimeoutError())
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
with patch("asyncio.sleep", new_callable=AsyncMock):
with pytest.raises(ConnectionError, match="timed out"):
await client.request("GET", "posts")
@pytest.mark.asyncio
async def test_invalid_url_raises_connection_error(self, client):
"""Invalid URL should raise ConnectionError."""
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.request = MagicMock(side_effect=aiohttp.InvalidURL("bad url"))
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(ConnectionError, match="Invalid URL"):
await client.request("GET", "posts")
@pytest.mark.asyncio
async def test_auth_error_not_retried(self, client):
"""Auth errors should NOT be retried."""
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(
return_value=json.dumps({"code": "invalid_auth", "message": "Bad"})
)
mock_session = AsyncMock()
mock_session.request = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(AuthenticationError):
await client.request("GET", "posts")
# Should be called only once (no retry)
assert mock_session.request.call_count == 1
@pytest.mark.asyncio
async def test_502_retried_then_raises(self, client):
"""502 errors should be retried before raising."""
mock_response = AsyncMock()
mock_response.status = 502
mock_response.text = AsyncMock(return_value="Bad Gateway")
mock_session = AsyncMock()
mock_session.request = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
with patch("asyncio.sleep", new_callable=AsyncMock):
with pytest.raises(Exception, match="BAD_GATEWAY"):
await client.request("GET", "posts")
# Should be called 3 times (1 + 2 retries)
assert mock_session.request.call_count == 3
class TestWordPressClientHealthCheck:
"""Test site health check with error differentiation."""
@pytest.fixture
def client(self):
return WordPressClient(
site_url="https://example.com",
username="admin",
app_password="xxxx",
)
@pytest.mark.asyncio
async def test_healthy_site(self, client):
"""Healthy site should return proper status."""
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(
return_value={
"name": "My Blog",
"description": "A blog",
"url": "https://example.com",
"routes": {"a": 1},
}
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.get = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
result = await client.check_site_health()
assert result["healthy"] is True
assert result["name"] == "My Blog"
@pytest.mark.asyncio
async def test_rest_api_disabled_detected(self, client):
"""403/404 on /wp-json should detect REST API disabled."""
for status in (403, 404):
mock_response = AsyncMock()
mock_response.status = status
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.get = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
result = await client.check_site_health()
assert result["healthy"] is False
assert result["accessible"] is True
assert result["error_type"] == "rest_api_disabled"
assert "REST API" in result["message"]
@pytest.mark.asyncio
async def test_dns_failure_in_health_check(self, client):
"""DNS failure in health check should be detected."""
dns_error = aiohttp.ClientConnectorDNSError(
connection_key=MagicMock(), os_error=OSError("DNS failed")
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.get = MagicMock(side_effect=dns_error)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
result = await client.check_site_health()
assert result["healthy"] is False
assert result["error_type"] == "dns_failure"
assert "DNS" in result["message"]
@pytest.mark.asyncio
async def test_timeout_in_health_check(self, client):
"""Timeout in health check should be detected."""
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.get = MagicMock(side_effect=TimeoutError())
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
result = await client.check_site_health()
assert result["healthy"] is False
assert result["error_type"] == "timeout"
@pytest.mark.asyncio
async def test_ssl_error_in_health_check(self, client):
"""SSL error in health check should be detected."""
ssl_error = aiohttp.ClientConnectorCertificateError(
connection_key=MagicMock(), certificate_error=Exception("expired")
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.get = MagicMock(side_effect=ssl_error)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
result = await client.check_site_health()
assert result["healthy"] is False
assert result["error_type"] == "ssl_error"
@pytest.mark.asyncio
async def test_connection_refused_in_health_check(self, client):
"""Connection refused in health check should be detected."""
conn_error = aiohttp.ClientConnectorError(
connection_key=MagicMock(), os_error=OSError("Connection refused")
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_session = AsyncMock()
mock_session.get = MagicMock(side_effect=conn_error)
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)
result = await client.check_site_health()
assert result["healthy"] is False
assert result["error_type"] == "connection_refused"
# --- WordPressPlugin Tests ---