Files
fuzzforge_ai/sdk/src/fuzzforge_sdk/exceptions.py
T
Tanguy Duhamel 0547b78429 Initial commit
2025-09-29 21:26:41 +02:00

553 lines
18 KiB
Python

"""
Enhanced exceptions for FuzzForge SDK with rich context and Docker integration.
Provides comprehensive error information including container logs, diagnostics,
and actionable suggestions for troubleshooting.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import json
import re
from typing import Optional, Dict, Any, List, Union
from dataclasses import dataclass, asdict
from .docker_logs import docker_integration, ContainerDiagnostics
@dataclass
class ErrorContext:
"""Rich context information for errors."""
url: Optional[str] = None
request_method: Optional[str] = None
request_data: Optional[Dict[str, Any]] = None
response_data: Optional[Dict[str, Any]] = None
container_diagnostics: Optional[ContainerDiagnostics] = None
suggested_fixes: List[str] = None
error_patterns: Dict[str, List[str]] = None
related_run_id: Optional[str] = None
workflow_name: Optional[str] = None
def __post_init__(self):
if self.suggested_fixes is None:
self.suggested_fixes = []
if self.error_patterns is None:
self.error_patterns = {}
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return asdict(self)
class FuzzForgeError(Exception):
"""Base exception for all FuzzForge SDK errors with rich context."""
def __init__(
self,
message: str,
context: Optional[ErrorContext] = None,
original_exception: Optional[Exception] = None
):
super().__init__(message)
self.message = message
self.context = context or ErrorContext()
self.original_exception = original_exception
# Auto-populate container diagnostics if we have a run ID
if self.context.related_run_id and not self.context.container_diagnostics:
self._fetch_container_diagnostics()
def _fetch_container_diagnostics(self):
"""Fetch container diagnostics for the related run."""
if not self.context.related_run_id:
return
try:
# Try to find containers by Prefect run ID label
label_filter = f"prefect.flow-run-id={self.context.related_run_id}"
container_names = docker_integration.get_container_names_by_label(label_filter)
if container_names:
# Use the most recent container
container_name = container_names[0]
diagnostics = docker_integration.get_container_diagnostics(container_name)
# Analyze error patterns in logs
if diagnostics.logs:
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
suggestions = docker_integration.suggest_fixes(error_analysis)
self.context.container_diagnostics = diagnostics
self.context.error_patterns = error_analysis
self.context.suggested_fixes.extend(suggestions)
except Exception:
# Don't fail the main error because of diagnostics issues
pass
def get_summary(self) -> str:
"""Get a summary of the error with key details."""
parts = [self.message]
if self.context.container_diagnostics:
diag = self.context.container_diagnostics
if diag.status != 'running':
parts.append(f"Container status: {diag.status}")
if diag.exit_code is not None:
parts.append(f"Exit code: {diag.exit_code}")
if self.context.error_patterns:
detected = list(self.context.error_patterns.keys())
parts.append(f"Detected issues: {', '.join(detected)}")
return " | ".join(parts)
def get_detailed_info(self) -> Dict[str, Any]:
"""Get detailed error information for rich display."""
info = {
"message": self.message,
"type": self.__class__.__name__,
}
if self.context:
info.update(self.context.to_dict())
return info
def __str__(self) -> str:
return self.get_summary()
class FuzzForgeHTTPError(FuzzForgeError):
"""HTTP-related errors with enhanced context."""
def __init__(
self,
message: str,
status_code: int,
response_text: Optional[str] = None,
context: Optional[ErrorContext] = None,
original_exception: Optional[Exception] = None
):
if context is None:
context = ErrorContext()
# Parse response data if it's JSON
if response_text:
try:
context.response_data = json.loads(response_text)
except (json.JSONDecodeError, TypeError):
context.response_data = {"raw": response_text}
super().__init__(message, context, original_exception)
self.status_code = status_code
self.response_text = response_text
def get_summary(self) -> str:
base = f"HTTP {self.status_code}: {self.message}"
if self.context.container_diagnostics:
diag = self.context.container_diagnostics
if diag.exit_code is not None and diag.exit_code != 0:
base += f" (Container exit code: {diag.exit_code})"
return base
class DeploymentError(FuzzForgeHTTPError):
"""Enhanced deployment errors with container diagnostics."""
def __init__(
self,
workflow_name: str,
message: str,
deployment_id: Optional[str] = None,
container_name: Optional[str] = None,
status_code: int = 500,
response_text: Optional[str] = None,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.workflow_name = workflow_name
# If we have a container name, get its diagnostics immediately
if container_name:
try:
diagnostics = docker_integration.get_container_diagnostics(container_name)
context.container_diagnostics = diagnostics
# Analyze logs for error patterns
if diagnostics.logs:
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
suggestions = docker_integration.suggest_fixes(error_analysis)
context.error_patterns = error_analysis
context.suggested_fixes.extend(suggestions)
except Exception:
# Don't fail on diagnostics
pass
full_message = f"Deployment failed for workflow '{workflow_name}': {message}"
super().__init__(full_message, status_code, response_text, context)
self.workflow_name = workflow_name
self.deployment_id = deployment_id
class WorkflowExecutionError(FuzzForgeHTTPError):
"""Enhanced workflow execution errors."""
def __init__(
self,
workflow_name: str,
run_id: str,
message: str,
status_code: int = 400,
response_text: Optional[str] = None,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.workflow_name = workflow_name
context.related_run_id = run_id
full_message = f"Workflow '{workflow_name}' execution failed (run: {run_id}): {message}"
super().__init__(full_message, status_code, response_text, context)
self.workflow_name = workflow_name
self.run_id = run_id
class WorkflowNotFoundError(FuzzForgeHTTPError):
"""Enhanced workflow not found error."""
def __init__(
self,
workflow_name: str,
available_workflows: List[str] = None,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.workflow_name = workflow_name
if available_workflows:
context.suggested_fixes = [
f"Available workflows: {', '.join(available_workflows)}",
"Use 'fuzzforge workflows list' to see all available workflows",
"Check workflow name spelling and case sensitivity"
]
message = f"Workflow not found: {workflow_name}"
super().__init__(message, 404, context=context)
self.workflow_name = workflow_name
self.available_workflows = available_workflows or []
class RunNotFoundError(FuzzForgeHTTPError):
"""Enhanced run not found error."""
def __init__(
self,
run_id: str,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.related_run_id = run_id
context.suggested_fixes = [
"Use 'fuzzforge runs list' to see available runs",
"Check if the run ID is correct and complete",
"Ensure the run hasn't been deleted or expired"
]
message = f"Run not found: {run_id}"
super().__init__(message, 404, context=context)
self.run_id = run_id
class ContainerError(FuzzForgeError):
"""Enhanced container-specific errors."""
def __init__(
self,
container_name: str,
message: str,
exit_code: Optional[int] = None,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
# Immediately fetch container diagnostics
try:
diagnostics = docker_integration.get_container_diagnostics(container_name)
context.container_diagnostics = diagnostics
# Analyze logs for patterns
if diagnostics.logs:
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
suggestions = docker_integration.suggest_fixes(error_analysis)
context.error_patterns = error_analysis
context.suggested_fixes.extend(suggestions)
except Exception:
# Don't fail on diagnostics
pass
full_message = f"Container error ({container_name}): {message}"
if exit_code is not None:
full_message += f" (exit code: {exit_code})"
super().__init__(full_message, context)
self.container_name = container_name
self.exit_code = exit_code
class VolumeError(FuzzForgeError):
"""Volume mount related errors."""
def __init__(
self,
volume_path: str,
message: str,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.suggested_fixes = [
"Check if the volume path exists and is accessible",
"Verify file permissions (Docker needs read access)",
"Ensure the path is not in use by another process",
"Try using an absolute path instead of relative path",
"Check if SELinux or AppArmor is blocking access"
]
full_message = f"Volume error ({volume_path}): {message}"
super().__init__(full_message, context)
self.volume_path = volume_path
class ResourceLimitError(FuzzForgeError):
"""Resource limit related errors."""
def __init__(
self,
resource_type: str,
message: str,
current_usage: Optional[Dict[str, Any]] = None,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.suggested_fixes = [
f"Increase {resource_type} limits in workflow configuration",
"Check system resource availability",
"Consider using a smaller dataset or batch size",
"Monitor resource usage during execution"
]
full_message = f"{resource_type.title()} limit error: {message}"
super().__init__(full_message, context)
self.resource_type = resource_type
self.current_usage = current_usage or {}
class ValidationError(FuzzForgeError):
"""Enhanced data validation errors."""
def __init__(
self,
field_name: str,
message: str,
provided_value: Any = None,
expected_format: Optional[str] = None,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
if expected_format:
context.suggested_fixes = [
f"Expected format: {expected_format}",
f"Provided value: {provided_value}",
"Check parameter documentation for valid values"
]
full_message = f"Validation error for '{field_name}': {message}"
super().__init__(full_message, context)
self.field_name = field_name
self.provided_value = provided_value
self.expected_format = expected_format
class ConnectionError(FuzzForgeError):
"""Enhanced connection errors."""
def __init__(
self,
endpoint: str,
message: str,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.suggested_fixes = [
"Check if FuzzForge backend is running (docker-compose up -d)",
"Verify the API endpoint URL is correct",
"Check network connectivity and firewall settings",
"Ensure all required services are healthy",
"Try restarting the FuzzForge services"
]
full_message = f"Connection error to {endpoint}: {message}"
super().__init__(full_message, context)
self.endpoint = endpoint
class TimeoutError(FuzzForgeError):
"""Enhanced timeout errors."""
def __init__(
self,
operation: str,
timeout_seconds: int,
context: Optional[ErrorContext] = None
):
if context is None:
context = ErrorContext()
context.suggested_fixes = [
f"Increase timeout value (current: {timeout_seconds}s)",
"Check if the operation is resource-intensive",
"Verify backend services are responsive",
"Consider breaking down large operations into smaller chunks"
]
full_message = f"Timeout error for {operation} after {timeout_seconds} seconds"
super().__init__(full_message, context)
self.operation = operation
self.timeout_seconds = timeout_seconds
class WebSocketError(FuzzForgeError):
"""WebSocket-related errors."""
class SSEError(FuzzForgeError):
"""Server-Sent Events related errors."""
def from_http_error(status_code: int, response_text: str, url: str) -> FuzzForgeHTTPError:
"""
Create appropriate exception from HTTP error response with enhanced context.
Args:
status_code: HTTP status code
response_text: Response body text
url: Request URL that failed
Returns:
Appropriate FuzzForgeError subclass with rich context
"""
context = ErrorContext(url=url, response_data={"raw": response_text})
# Try to parse JSON response for more context
try:
response_data = json.loads(response_text)
context.response_data = response_data
# Extract additional context from structured error responses
if isinstance(response_data, dict):
if "run_id" in response_data:
context.related_run_id = response_data["run_id"]
if "workflow" in response_data:
context.workflow_name = response_data["workflow"]
except (json.JSONDecodeError, TypeError):
# Unable to parse JSON, use raw text
pass
# Handle specific error types based on URL patterns and status codes
if status_code == 404:
if "/workflows/" in url and "/submit" not in url:
# Extract workflow name from URL
parts = url.split("/workflows/")
if len(parts) > 1:
workflow_name = parts[1].split("/")[0]
return WorkflowNotFoundError(workflow_name, context=context)
elif "/runs/" in url:
# Extract run ID from URL
parts = url.split("/runs/")
if len(parts) > 1:
run_id = parts[1].split("/")[0]
return RunNotFoundError(run_id, context)
elif status_code == 400:
# Check for specific error patterns in response
if "deployment" in response_text.lower() and "not found" in response_text.lower():
# Extract workflow name if possible
workflow_match = re.search(r"workflow['\"]?\s*[:\-]?\s*['\"]?(\w+)", response_text, re.IGNORECASE)
workflow_name = workflow_match.group(1) if workflow_match else "unknown"
return DeploymentError(
workflow_name=workflow_name,
message="Deployment not found",
status_code=status_code,
response_text=response_text,
context=context
)
elif "volume" in response_text.lower() or "mount" in response_text.lower():
return VolumeError(
volume_path="unknown",
message=response_text,
context=context
)
elif "memory" in response_text.lower() or "resource" in response_text.lower():
return ResourceLimitError(
resource_type="memory",
message=response_text,
context=context
)
elif status_code == 500:
# Server errors might be deployment or execution issues
if "deployment" in response_text.lower() or "container" in response_text.lower():
workflow_match = re.search(r"workflow['\"]?\s*[:\-]?\s*['\"]?(\w+)", response_text, re.IGNORECASE)
workflow_name = workflow_match.group(1) if workflow_match else "unknown"
return DeploymentError(
workflow_name=workflow_name,
message=response_text,
status_code=status_code,
response_text=response_text,
context=context
)
# Generic HTTP error with enhanced context
return FuzzForgeHTTPError(
message=f"HTTP request failed: {response_text}",
status_code=status_code,
response_text=response_text,
context=context
)