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