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:
opencode 2026-07-05 07:04:13 +00:00
parent b25fb3c1c6
commit 4a676a604a
2 changed files with 92 additions and 38 deletions

View File

@ -43,6 +43,8 @@ def load_config():
"project_root": os.getenv("CLOVER_PROJECT_ROOT", "."), "project_root": os.getenv("CLOVER_PROJECT_ROOT", "."),
"summary_file": os.getenv("CLOVER_SUMMARY_FILE", "clover.md"), "summary_file": os.getenv("CLOVER_SUMMARY_FILE", "clover.md"),
"structure_file": os.getenv("CLOVER_STRUCTURE_FILE", "structure.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 # Debug output if enabled

View File

@ -5,6 +5,7 @@ Handles communication with the OpenAI-compatible LLM server
import json import json
import os import os
import time
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import requests import requests
@ -27,7 +28,10 @@ class APIClient:
self, endpoint: str, method: str = "GET", data: Optional[Dict] = None self, endpoint: str, method: str = "GET", data: Optional[Dict] = None
) -> Dict[str, Any]: ) -> 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: Args:
endpoint (str): API endpoint endpoint (str): API endpoint
@ -37,45 +41,93 @@ class APIClient:
Returns: Returns:
dict: Response from the API dict: Response from the API
""" """
try: max_retries = self.config.get("max_retries", 3)
# Ensure base_url doesn't have trailing slash and endpoint has leading slash base_delay = self.config.get("retry_base_delay", 2)
base = self.base_url.rstrip("/")
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
url = f"{base}{endpoint}"
headers = { last_exception = None
"Content-Type": "application/json",
"User-Agent": "clover-cli/1.0", for attempt in range(max_retries + 1):
"Accept": "application/json", 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)}",
} }
return {"success": False, "error": "Request failed after maximum retries"}
# 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)}"}
def list_models(self) -> Dict[str, Any]: def list_models(self) -> Dict[str, Any]:
""" """