kir / kiro /network_errors.py
jwadow
feat(errors): add network error classification with user-friendly messages (#53)
80f33a2
Raw
History Blame Contribute Delete
16.2 kB
# -*- coding: utf-8 -*-
# Kiro Gateway
# https://github.com/jwadow/kiro-gateway
# Copyright (C) 2025 Jwadow
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Network error classification and user-friendly message formatting.
This module provides a centralized system for classifying network errors
and converting them into actionable, user-friendly messages with troubleshooting steps.
Architecture:
- ErrorCategory: Enum of all possible network error types
- NetworkErrorInfo: Structured information about an error
- classify_network_error(): Analyzes exceptions and returns NetworkErrorInfo
- format_error_for_user(): Formats errors for API responses (OpenAI/Anthropic)
"""
import socket
from dataclasses import dataclass
from enum import Enum
from typing import List, Dict, Any, Optional
import httpx
from loguru import logger
class ErrorCategory(str, Enum):
"""
Categories of network errors.
Each category represents a distinct type of network failure
with specific troubleshooting steps.
"""
DNS_RESOLUTION = "dns_resolution"
CONNECTION_REFUSED = "connection_refused"
CONNECTION_RESET = "connection_reset"
NETWORK_UNREACHABLE = "network_unreachable"
TIMEOUT_CONNECT = "timeout_connect"
TIMEOUT_READ = "timeout_read"
SSL_ERROR = "ssl_error"
PROXY_ERROR = "proxy_error"
TOO_MANY_REDIRECTS = "too_many_redirects"
UNKNOWN = "unknown"
@dataclass
class NetworkErrorInfo:
"""
Structured information about a network error.
Attributes:
category: Error category for classification
user_message: Clear, non-technical message for end users
troubleshooting_steps: List of actionable steps to resolve the issue
technical_details: Technical error details for logging and debugging
is_retryable: Whether retrying the request might succeed
suggested_http_code: Appropriate HTTP status code (502, 504, etc.)
"""
category: ErrorCategory
user_message: str
troubleshooting_steps: List[str]
technical_details: str
is_retryable: bool
suggested_http_code: int
def classify_network_error(error: Exception) -> NetworkErrorInfo:
"""
Classifies a network error and returns structured information.
Analyzes the exception type, error message, and underlying cause
to determine the specific type of network failure and provide
appropriate user-facing messages and troubleshooting steps.
Args:
error: The exception that occurred (typically httpx.RequestError)
Returns:
NetworkErrorInfo with classification and user-friendly details
Example:
>>> try:
... response = await client.get("https://example.com")
... except httpx.RequestError as e:
... error_info = classify_network_error(e)
... logger.error(f"[{error_info.category}] {error_info.user_message}")
"""
error_type = type(error).__name__
error_str = str(error)
# Extract technical details for logging
technical_details = f"{error_type}: {error_str}"
# Analyze httpx.ConnectError (connection establishment failures)
if isinstance(error, httpx.ConnectError):
return _classify_connect_error(error, technical_details)
# Analyze httpx.TimeoutException (various timeout types)
if isinstance(error, httpx.TimeoutException):
return _classify_timeout_error(error, technical_details)
# Analyze httpx.TooManyRedirects
if isinstance(error, httpx.TooManyRedirects):
return NetworkErrorInfo(
category=ErrorCategory.TOO_MANY_REDIRECTS,
user_message="Too many redirects - the server is redirecting in a loop.",
troubleshooting_steps=[
"This is likely a server-side configuration issue",
"Try accessing the service directly without the gateway",
"Contact the service provider if the issue persists"
],
technical_details=technical_details,
is_retryable=False,
suggested_http_code=502
)
# Analyze httpx.ProxyError
if isinstance(error, httpx.ProxyError):
return NetworkErrorInfo(
category=ErrorCategory.PROXY_ERROR,
user_message="Proxy connection failed - cannot connect through the configured proxy.",
troubleshooting_steps=[
"Check proxy configuration (HTTP_PROXY, HTTPS_PROXY environment variables)",
"Verify proxy server is accessible",
"Try disabling proxy temporarily",
"Check proxy authentication credentials if required"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=502
)
# Generic httpx.RequestError (catch-all)
if isinstance(error, httpx.RequestError):
return NetworkErrorInfo(
category=ErrorCategory.UNKNOWN,
user_message="Network request failed due to an unexpected error.",
troubleshooting_steps=[
"Check your internet connection",
"Verify firewall/antivirus settings",
"Try again in a few moments",
"Check the debug logs for more details"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=502
)
# Non-httpx errors (shouldn't happen, but handle gracefully)
return NetworkErrorInfo(
category=ErrorCategory.UNKNOWN,
user_message="An unexpected error occurred.",
troubleshooting_steps=[
"Check the debug logs for details",
"Try again in a few moments",
"Report this issue if it persists"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=500
)
def _classify_connect_error(error: httpx.ConnectError, technical_details: str) -> NetworkErrorInfo:
"""
Classifies httpx.ConnectError into specific subcategories.
Args:
error: The ConnectError exception
technical_details: Technical error string for logging
Returns:
NetworkErrorInfo with specific classification
"""
error_str = str(error)
# Check underlying cause chain for more specific errors
cause = error.__cause__
# Check for DNS errors (socket.gaierror)
if cause and isinstance(cause, socket.gaierror):
# DNS resolution failed
# Common errno values:
# - 11001 (Windows): WSAHOST_NOT_FOUND
# - -2, -3, -5 (Unix): EAI_NONAME, EAI_AGAIN, EAI_NODATA
errno = getattr(cause, 'errno', None)
return NetworkErrorInfo(
category=ErrorCategory.DNS_RESOLUTION,
user_message="DNS resolution failed - cannot resolve the provider's domain name.",
troubleshooting_steps=[
"Check your internet connection",
"Try changing DNS servers to Google DNS (8.8.8.8, 8.8.4.4) or Cloudflare (1.1.1.1, 1.0.0.1)",
"Temporarily disable VPN if you're using one",
"Check if firewall/antivirus is blocking DNS requests",
"Verify the domain name is correct and the service is operational"
],
technical_details=f"{technical_details} (errno: {errno})",
is_retryable=True,
suggested_http_code=502
)
# Check for connection refused
if "Connection refused" in error_str or "ECONNREFUSED" in error_str:
return NetworkErrorInfo(
category=ErrorCategory.CONNECTION_REFUSED,
user_message="Connection refused - the server is not accepting connections.",
troubleshooting_steps=[
"The service may be temporarily down",
"Check if the service is running and accessible",
"Verify firewall is not blocking the connection",
"Try again in a few moments"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=502
)
# Check for connection reset
if "Connection reset" in error_str or "ECONNRESET" in error_str:
return NetworkErrorInfo(
category=ErrorCategory.CONNECTION_RESET,
user_message="Connection reset - the server closed the connection unexpectedly.",
troubleshooting_steps=[
"This is usually a temporary server issue",
"Try again in a few moments",
"Check if VPN/proxy is interfering with the connection",
"Verify network stability"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=502
)
# Check for network unreachable
if "Network is unreachable" in error_str or "No route to host" in error_str or "ENETUNREACH" in error_str:
return NetworkErrorInfo(
category=ErrorCategory.NETWORK_UNREACHABLE,
user_message="Network unreachable - cannot reach the server's network.",
troubleshooting_steps=[
"Check your internet connection",
"Verify network adapter is enabled and working",
"Check routing table if using VPN",
"Try disabling VPN temporarily",
"Restart network adapter or router"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=502
)
# Check for SSL/TLS errors
if "SSL" in error_str or "TLS" in error_str or "certificate" in error_str.lower():
return NetworkErrorInfo(
category=ErrorCategory.SSL_ERROR,
user_message="SSL/TLS error - secure connection could not be established.",
troubleshooting_steps=[
"Check system date and time (incorrect time causes SSL errors)",
"Update SSL certificates on your system",
"Check if antivirus/firewall is intercepting HTTPS traffic",
"Verify the server's SSL certificate is valid"
],
technical_details=technical_details,
is_retryable=False,
suggested_http_code=502
)
# Generic connection error
return NetworkErrorInfo(
category=ErrorCategory.UNKNOWN,
user_message="Connection failed - unable to establish connection to the server.",
troubleshooting_steps=[
"Check your internet connection",
"Verify firewall/antivirus settings",
"Try disabling VPN temporarily",
"Check if the service is accessible from other devices"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=502
)
def _classify_timeout_error(error: httpx.TimeoutException, technical_details: str) -> NetworkErrorInfo:
"""
Classifies httpx.TimeoutException into specific subcategories.
Args:
error: The TimeoutException
technical_details: Technical error string for logging
Returns:
NetworkErrorInfo with specific classification
"""
# ConnectTimeout: TCP handshake timeout
if isinstance(error, httpx.ConnectTimeout):
return NetworkErrorInfo(
category=ErrorCategory.TIMEOUT_CONNECT,
user_message="Connection timeout - server did not respond to connection attempt.",
troubleshooting_steps=[
"Check your internet connection speed",
"The server may be overloaded or slow to respond",
"Try again in a few moments",
"Check if firewall is delaying connections"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=504
)
# ReadTimeout: Server stopped sending data
if isinstance(error, httpx.ReadTimeout):
return NetworkErrorInfo(
category=ErrorCategory.TIMEOUT_READ,
user_message="Read timeout - server stopped responding during data transfer.",
troubleshooting_steps=[
"The server may be processing a complex request",
"Check your internet connection stability",
"Try again with a simpler request",
"The service may be experiencing high load"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=504
)
# Generic timeout
return NetworkErrorInfo(
category=ErrorCategory.TIMEOUT_READ,
user_message="Request timeout - operation took too long to complete.",
troubleshooting_steps=[
"Check your internet connection",
"The server may be slow or overloaded",
"Try again in a few moments"
],
technical_details=technical_details,
is_retryable=True,
suggested_http_code=504
)
def format_error_for_user(
error_info: NetworkErrorInfo,
format_type: str = "openai",
include_troubleshooting: bool = True
) -> Dict[str, Any]:
"""
Formats NetworkErrorInfo for API response.
Converts structured error information into the appropriate format
for OpenAI or Anthropic API responses.
Args:
error_info: The classified error information
format_type: "openai" or "anthropic" format
include_troubleshooting: Whether to include troubleshooting steps
Returns:
Dictionary formatted for API response
Example:
>>> error_info = classify_network_error(exception)
>>> response = format_error_for_user(error_info, format_type="openai")
>>> return JSONResponse(status_code=502, content=response)
"""
# Build the message
message = error_info.user_message
if include_troubleshooting and error_info.troubleshooting_steps:
message += "\n\nTroubleshooting steps:\n"
for i, step in enumerate(error_info.troubleshooting_steps, 1):
message += f"{i}. {step}\n"
# Format for OpenAI API
if format_type == "openai":
return {
"error": {
"message": message.strip(),
"type": "connectivity_error",
"code": error_info.category.value,
"param": None
}
}
# Format for Anthropic API
elif format_type == "anthropic":
return {
"type": "error",
"error": {
"type": "connectivity_error",
"message": message.strip()
}
}
# Generic format (fallback)
else:
return {
"error": {
"type": "connectivity_error",
"category": error_info.category.value,
"message": message.strip(),
"technical_details": error_info.technical_details
}
}
def get_short_error_message(error_info: NetworkErrorInfo) -> str:
"""
Returns a short, single-line error message for logging.
Args:
error_info: The classified error information
Returns:
Short error message suitable for log files
Example:
>>> error_info = classify_network_error(exception)
>>> logger.warning(get_short_error_message(error_info))
"""
return error_info.user_message