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

@@ -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,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}",
}

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)