From 647a650629a02cffd6979247a5e00a9dd0005e3d Mon Sep 17 00:00:00 2001 From: airano Date: Thu, 19 Feb 2026 14:58:16 +0330 Subject: [PATCH] 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 --- core/health.py | 22 +- core/oauth/token_manager.py | 4 +- core/tool_generator.py | 13 +- plugins/n8n/handlers/system.py | 4 +- plugins/n8n/handlers/variables.py | 2 +- plugins/openpanel/handlers/profiles.py | 4 +- plugins/supabase/handlers/system.py | 16 +- plugins/wordpress/client.py | 256 +++++++++++++--- plugins/wordpress/handlers/menus.py | 6 +- plugins/wordpress/wp_cli.py | 8 +- plugins/wordpress_advanced/handlers/system.py | 6 +- tests/test_wordpress_plugin.py | 285 +++++++++++++++++- 12 files changed, 559 insertions(+), 67 deletions(-) diff --git a/core/health.py b/core/health.py index 343c079..2f7aa6f 100644 --- a/core/health.py +++ b/core/health.py @@ -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]: """ diff --git a/core/oauth/token_manager.py b/core/oauth/token_manager.py index 3bc1690..548cc7a 100644 --- a/core/oauth/token_manager.py +++ b/core/oauth/token_manager.py @@ -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( diff --git a/core/tool_generator.py b/core/tool_generator.py index 7101db3..2ab3492 100644 --- a/core/tool_generator.py +++ b/core/tool_generator.py @@ -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, diff --git a/plugins/n8n/handlers/system.py b/plugins/n8n/handlers/system.py index 393db12..91932fa 100644 --- a/plugins/n8n/handlers/system.py +++ b/plugins/n8n/handlers/system.py @@ -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 diff --git a/plugins/n8n/handlers/variables.py b/plugins/n8n/handlers/variables.py index 8130820..e86c42f 100644 --- a/plugins/n8n/handlers/variables.py +++ b/plugins/n8n/handlers/variables.py @@ -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) diff --git a/plugins/openpanel/handlers/profiles.py b/plugins/openpanel/handlers/profiles.py index 399dcb1..af5f20d 100644 --- a/plugins/openpanel/handlers/profiles.py +++ b/plugins/openpanel/handlers/profiles.py @@ -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( { diff --git a/plugins/supabase/handlers/system.py b/plugins/supabase/handlers/system.py index 2fc6e50..fed849b 100644 --- a/plugins/supabase/handlers/system.py +++ b/plugins/supabase/handlers/system.py @@ -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) diff --git a/plugins/wordpress/client.py b/plugins/wordpress/client.py index 2da6c1c..e8d89c9 100644 --- a/plugins/wordpress/client.py +++ b/plugins/wordpress/client.py @@ -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,37 +159,124 @@ class WordPressClient: if json_data: json_data = {k: v for k, v in json_data.items() if should_include(v)} - # Make request - async with ( - aiohttp.ClientSession() as session, - session.request( - method, url, params=params, json=json_data, data=data, headers=headers - ) as response, - ): - # Handle errors with structured error messages - if response.status >= 400: - error_text = await response.text() + # Make request with retry for transient errors + timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT) + last_exception = None - # Parse structured error response - error_info = self._parse_error_response( - response.status, error_text, use_woocommerce + for attempt in range(_MAX_RETRIES + 1): + try: + async with ( + aiohttp.ClientSession(timeout=timeout) as session, + session.request( + method, url, params=params, json=json_data, data=data, headers=headers + ) as response, + ): + # Handle errors with structured error messages + 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 + ) + + # Log the error for debugging + self.logger.error( + f"API error: {error_info['error_code']} - {error_info['message']}" + ) + + # Raise appropriate exception + if response.status in (401, 403): + raise AuthenticationError( + f"[{error_info['error_code']}] {error_info['message']}" + ) + + raise Exception(f"[{error_info['error_code']}] {error_info['message']}") + + # 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." ) - - # Log the error for debugging - self.logger.error( - f"API error: {error_info['error_code']} - {error_info['message']}" - ) - - # Raise appropriate exception - if response.status in (401, 403): - raise AuthenticationError( - f"[{error_info['error_code']}] {error_info['message']}" + 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 - raise Exception(f"[{error_info['error_code']}] {error_info['message']}") + 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 - # Return JSON response - return await response.json() + 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 @@ -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": False, - "message": f"Site returned status {response.status}", + "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." + ), } - except Exception as e: + + 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"Health check failed: {str(e)}", + "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, + "error_type": "unknown", + "message": f"Health check failed: {e}", } diff --git a/plugins/wordpress/handlers/menus.py b/plugins/wordpress/handlers/menus.py index 5da28c6..0f6698c 100644 --- a/plugins/wordpress/handlers/menus.py +++ b/plugins/wordpress/handlers/menus.py @@ -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) diff --git a/plugins/wordpress/wp_cli.py b/plugins/wordpress/wp_cli.py index 93dac33..3abef69 100644 --- a/plugins/wordpress/wp_cli.py +++ b/plugins/wordpress/wp_cli.py @@ -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 diff --git a/plugins/wordpress_advanced/handlers/system.py b/plugins/wordpress_advanced/handlers/system.py index 2e74266..da278ae 100644 --- a/plugins/wordpress_advanced/handlers/system.py +++ b/plugins/wordpress_advanced/handlers/system.py @@ -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) diff --git a/tests/test_wordpress_plugin.py b/tests/test_wordpress_plugin.py index a15622e..b65bd19 100644 --- a/tests/test_wordpress_plugin.py +++ b/tests/test_wordpress_plugin.py @@ -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 ---