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:
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user