diff --git a/config/settings.py b/config/settings.py index 2a14913..6f72f7d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -43,6 +43,8 @@ def load_config(): "project_root": os.getenv("CLOVER_PROJECT_ROOT", "."), "summary_file": os.getenv("CLOVER_SUMMARY_FILE", "clover.md"), "structure_file": os.getenv("CLOVER_STRUCTURE_FILE", "structure.md"), + "max_retries": int(os.getenv("CLOVER_MAX_RETRIES", "3")), + "retry_base_delay": float(os.getenv("CLOVER_RETRY_BASE_DELAY", "2")), } # Debug output if enabled diff --git a/models/api_client.py b/models/api_client.py index 6e531e2..5e2d61d 100644 --- a/models/api_client.py +++ b/models/api_client.py @@ -5,6 +5,7 @@ Handles communication with the OpenAI-compatible LLM server import json import os +import time from typing import Any, Dict, Optional import requests @@ -27,7 +28,10 @@ class APIClient: self, endpoint: str, method: str = "GET", data: Optional[Dict] = None ) -> Dict[str, Any]: """ - Make a request to the LLM API server + Make a request to the LLM API server with retry and exponential backoff. + + Retries up to 3 times with exponential backoff (2s base delay) for + transient failures (5xx errors, connection errors, timeouts). Args: endpoint (str): API endpoint @@ -37,45 +41,93 @@ class APIClient: Returns: dict: Response from the API """ - try: - # Ensure base_url doesn't have trailing slash and endpoint has leading slash - base = self.base_url.rstrip("/") - endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}" - url = f"{base}{endpoint}" + max_retries = self.config.get("max_retries", 3) + base_delay = self.config.get("retry_base_delay", 2) - headers = { - "Content-Type": "application/json", - "User-Agent": "clover-cli/1.0", - "Accept": "application/json", + last_exception = None + + for attempt in range(max_retries + 1): + try: + # Ensure base_url doesn't have trailing slash and endpoint has leading slash + base = self.base_url.rstrip("/") + endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}" + url = f"{base}{endpoint}" + + headers = { + "Content-Type": "application/json", + "User-Agent": "clover-cli/1.0", + "Accept": "application/json", + } + + # Add API key if available + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + if method == "GET": + response = requests.get( + url, + headers=headers, + timeout=self.config.get("timeout", 300), + ) + elif method == "POST": + response = requests.post( + url, + headers=headers, + json=data, + timeout=self.config.get("timeout", 300), + ) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + # Retry on 5xx server errors + if response.status_code >= 500 and attempt < max_retries: + delay = base_delay * (2**attempt) + print( + f"Server error {response.status_code}, " + f"retrying in {delay}s (attempt {attempt + 1}/{max_retries})" + ) + time.sleep(delay) + continue + + response.raise_for_status() + return {"success": True, "data": response.json()} + + except requests.exceptions.Timeout as e: + last_exception = e + if attempt < max_retries: + delay = base_delay * (2**attempt) + print( + f"Timeout, retrying in {delay}s " + f"(attempt {attempt + 1}/{max_retries})" + ) + time.sleep(delay) + continue + + except requests.exceptions.ConnectionError as e: + last_exception = e + if attempt < max_retries: + delay = base_delay * (2**attempt) + print( + f"Connection error, retrying in {delay}s " + f"(attempt {attempt + 1}/{max_retries})" + ) + time.sleep(delay) + continue + + except requests.exceptions.RequestException as e: + return {"success": False, "error": f"HTTP Request failed: {str(e)}"} + except json.JSONDecodeError as e: + return {"success": False, "error": f"Invalid JSON response: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"Unexpected error: {str(e)}"} + + # All retries exhausted + if last_exception: + return { + "success": False, + "error": f"Request failed after {max_retries} retries: {str(last_exception)}", } - - # Add API key if available - if self.api_key: - headers["Authorization"] = f"Bearer {self.api_key}" - - if method == "GET": - response = requests.get( - url, headers=headers, timeout=self.config.get("timeout", 300) - ) - elif method == "POST": - response = requests.post( - url, - headers=headers, - json=data, - timeout=self.config.get("timeout", 300), - ) - else: - raise ValueError(f"Unsupported HTTP method: {method}") - - response.raise_for_status() - return {"success": True, "data": response.json()} - - except requests.exceptions.RequestException as e: - return {"success": False, "error": f"HTTP Request failed: {str(e)}"} - except json.JSONDecodeError as e: - return {"success": False, "error": f"Invalid JSON response: {str(e)}"} - except Exception as e: - return {"success": False, "error": f"Unexpected error: {str(e)}"} + return {"success": False, "error": "Request failed after maximum retries"} def list_models(self) -> Dict[str, Any]: """