fix: add retry with exponential backoff for API calls (issue #9)
- Retry up to 3 times with exponential backoff (2s base delay) - Retries on 5xx errors, timeouts, and connection errors - Configurable via CLOVER_MAX_RETRIES and CLOVER_RETRY_BASE_DELAY - Logs retry attempts with delay and attempt number
This commit is contained in:
parent
b25fb3c1c6
commit
4a676a604a
@ -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
|
||||
|
||||
@ -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]:
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user