244 lines
7.8 KiB
Python
244 lines
7.8 KiB
Python
"""
|
|
Base client class for the ComfyUI Extension Service.
|
|
This class provides the foundation for service-specific clients
|
|
with common functionality for API communication.
|
|
"""
|
|
import json
|
|
import logging
|
|
import socket
|
|
import uuid
|
|
from enum import Enum
|
|
from typing import Dict, Any
|
|
|
|
import requests
|
|
|
|
from .exceptions import APIError, RateLimitError, ServiceUnavailableError, ValidationError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class HttpMethod(str, Enum):
|
|
"""HTTP request methods."""
|
|
GET = "GET"
|
|
POST = "POST"
|
|
|
|
class Endpoint(str, Enum):
|
|
"""Common API endpoints."""
|
|
HEALTH_CHECK = "health/direct"
|
|
CLEAR_CACHE = "admin/clear-cache"
|
|
MEMORY_CLEANUP = "admin/cleanup-memory"
|
|
|
|
|
|
class AuthenticationError:
|
|
pass
|
|
|
|
|
|
class BaseClient:
|
|
"""
|
|
Base client class for the ComfyUI Extension Service.
|
|
This class provides the foundation for service-specific clients
|
|
with common functionality for API communication.
|
|
"""
|
|
DEFAULT_TIMEOUT = 60
|
|
DEFAULT_USERNAME = "anonymous"
|
|
DEFAULT_IP = "127.0.0.1"
|
|
DEFAULT_HOSTNAME = "localhost"
|
|
CONTENT_TYPE_JSON = "application/json"
|
|
|
|
# Map HTTP status codes to exception classes
|
|
ERROR_STATUS_MAP = {
|
|
401: AuthenticationError,
|
|
422: ValidationError,
|
|
429: RateLimitError,
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
username: str = None,
|
|
timeout: int = DEFAULT_TIMEOUT,
|
|
headers: Dict[str, str] = None,
|
|
):
|
|
"""
|
|
Initialize the base client.
|
|
|
|
Args:
|
|
username: The username to use for API calls
|
|
timeout: Request timeout in seconds
|
|
headers: Additional headers to include in requests
|
|
"""
|
|
self.username = username or self.DEFAULT_USERNAME
|
|
self.timeout = timeout
|
|
self.headers = headers or {}
|
|
self.headers.update({"Content-Type": self.CONTENT_TYPE_JSON})
|
|
|
|
# Get client IP address
|
|
self._setup_client_info()
|
|
|
|
def _ensure_url_prefix(self,url: str) -> str:
|
|
if not url:
|
|
return url
|
|
|
|
# Remove leading/trailing whitespace
|
|
url = url.strip()
|
|
|
|
# If URL doesn't start with http:// or https://, add http://
|
|
if not url.startswith("http://") and not url.startswith("https://"):
|
|
url = "http://" + url
|
|
|
|
# Convert https:// to http:// for consistency
|
|
if url.startswith("https://"):
|
|
url = "http://" + url[8:]
|
|
|
|
return url
|
|
|
|
def _setup_client_info(self) -> None:
|
|
"""Set up client hostname and IP address information."""
|
|
try:
|
|
self.hostname = socket.gethostname()
|
|
self.ip_address = socket.gethostbyname(self.hostname)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get client IP address: {e}")
|
|
self.ip_address = self.DEFAULT_IP
|
|
self.hostname = self.DEFAULT_HOSTNAME
|
|
|
|
@staticmethod
|
|
def _build_url(base_url: str, endpoint: str) -> str:
|
|
"""
|
|
Build a complete URL from .e base URL and endpoint.
|
|
|
|
Args:
|
|
base_url: The base URL of the API server
|
|
endpoint: The API endpoint
|
|
|
|
Returns:
|
|
The complete URL
|
|
"""
|
|
return f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
|
|
def _prepare_request_data(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Prepare request data by adding required fields.
|
|
|
|
Args:
|
|
request_data: The request data
|
|
|
|
Returns:
|
|
The prepared request data
|
|
"""
|
|
# Create a new dictionary to avoid modifying the original
|
|
prepared_data = request_data.copy()
|
|
|
|
# Add required fields if not already present
|
|
if "req_id" not in prepared_data:
|
|
prepared_data["req_id"] = str(uuid.uuid4())
|
|
if "user_name" not in prepared_data:
|
|
prepared_data["user_name"] = self.username
|
|
if "ip_address" not in prepared_data:
|
|
prepared_data["ip_address"] = self.ip_address
|
|
|
|
return prepared_data
|
|
|
|
def _handle_error_status(self, response: requests.Response) -> None:
|
|
"""
|
|
Handle error status codes from the API.
|
|
|
|
Args:
|
|
response: The response object
|
|
|
|
Raises:
|
|
APIError: If the API returns an error
|
|
ValidationError: If the response cannot be parsed
|
|
"""
|
|
try:
|
|
data = response.json()
|
|
except json.JSONDecodeError:
|
|
raise ValidationError(f"Invalid JSON response: {response.text}")
|
|
|
|
# Check if the response is a BaseResponse
|
|
if isinstance(data, dict) and "success" in data:
|
|
if not data["success"]:
|
|
raise APIError(
|
|
status_code=response.status_code,
|
|
message=data.get("msg", "Unknown error"),
|
|
response=data,
|
|
)
|
|
|
|
# Check HTTP status
|
|
if response.status_code >= 400:
|
|
error_message = data.get("detail", "Unknown error")
|
|
|
|
# Use the error status map to get the appropriate exception class
|
|
exception_class = self.ERROR_STATUS_MAP.get(
|
|
response.status_code,
|
|
ServiceUnavailableError if response.status_code >= 500 else APIError
|
|
)
|
|
|
|
if exception_class == APIError:
|
|
raise exception_class(
|
|
status_code=response.status_code,
|
|
message=error_message,
|
|
response=data,
|
|
)
|
|
else:
|
|
raise exception_class(f"{exception_class.__name__}: {error_message}")
|
|
|
|
def _request(
|
|
self,
|
|
base_url: str,
|
|
method: HttpMethod,
|
|
endpoint: str,
|
|
data: Dict[str, Any] = None,
|
|
params: Dict[str, Any] = None,
|
|
files: Dict[str, Any] = None,
|
|
headers: Dict[str, str] = None,
|
|
) -> Dict[str, Any]:
|
|
logger.debug(f"base_url:{base_url}")
|
|
url = self._ensure_url_prefix(base_url)
|
|
logger.debug(f"_ensure_url_prefix:{url}")
|
|
url = self._build_url(url, endpoint)
|
|
logger.debug(f"_build_url:{url}")
|
|
request_headers = self.headers.copy()
|
|
|
|
if headers:
|
|
request_headers.update(headers)
|
|
|
|
# Prepare request data if it's a dict and not a file upload
|
|
if data and isinstance(data, dict) and not files:
|
|
data = self._prepare_request_data(data)
|
|
|
|
try:
|
|
kwargs = {
|
|
"method": method.value,
|
|
"url": url,
|
|
"params": params,
|
|
"headers": request_headers,
|
|
"timeout": self.timeout,
|
|
}
|
|
|
|
if files:
|
|
if "Content-Type" in request_headers:
|
|
del request_headers["Content-Type"]
|
|
kwargs["data"] = data
|
|
kwargs["files"] = files
|
|
else:
|
|
kwargs["json"] = data if data else None
|
|
|
|
response = requests.request(**kwargs)
|
|
|
|
# Log request details before sending
|
|
logger.info(f"Sending {method.value} request to {url}")
|
|
logger.debug(f"Request headers: {request_headers}")
|
|
if data and not files: # Only log data for non-file requests
|
|
logger.debug(f"Request data: {data}")
|
|
if files:
|
|
logger.debug(f"Files to upload: {list(files.keys())}")
|
|
# Log response details before returning
|
|
logger.info(f"Received response with status {response.status_code}")
|
|
logger.debug(f"Response headers: {response.headers}")
|
|
logger.debug(f"Response content: {response.text[:1000]}...") # Limit long responses
|
|
# Handle error status codes
|
|
self._handle_error_status(response)
|
|
return response.json()
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
raise ConnectionError(f"Connection error: {str(e)}")
|