""" HTTP client for NexusCRM API. Handles authentication and provides methods for GET/POST/PATCH/DELETE. """ import json import time import requests from typing import Tuple, Any, Optional import config class NexusAPIClient: """HTTP client for NexusCRM API with auto token management and rate limit retry.""" MAX_RETRIES = 3 DEFAULT_RETRY_DELAY = 1 # seconds LOST_CONNECTION_RETRIES = 10 # extra retries for MySQL lost connection def __init__(self, base_url: str = None, username: str = None, password: str = None): self.base_url = base_url or config.NEXUSCRM_URL self.username = username or config.NEXUSCRM_USERNAME self.password = password or config.NEXUSCRM_PASSWORD self.token = None self.session = requests.Session() self.session.headers.update({ "Content-Type": "application/json", "Accept": "application/json" }) def _authenticate(self) -> str: """Authenticate and get access token, with retry on 429 rate limit.""" auth_data = { "grant_type": "password", "username": self.username, "password": self.password, "client_id": "test-gosf-client", "client_secret": "test-gosf-secret-123" } last_error = None for attempt in range(self.MAX_RETRIES): response = self.session.post( f"{self.base_url}{config.AUTH_ENDPOINT}", data=auth_data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=30 ) if response.status_code == 200: token = response.json().get("access_token") self.token = token self.session.headers.update({"Authorization": f"Bearer {token}"}) return token elif response.status_code == 429: last_error = f"Authentication rate-limited: {response.status_code}" if attempt < self.MAX_RETRIES - 1: retry_delay = self._get_retry_delay(response) print(f" [Auth Rate Limit] 429 on auth, retrying in {retry_delay}s (attempt {attempt + 1}/{self.MAX_RETRIES})") time.sleep(retry_delay) continue else: last_error = f"Authentication failed: {response.status_code} {response.text}" break raise Exception(last_error) def authenticate(self) -> bool: """Public wrapper for authentication. Returns True if successful.""" try: self._authenticate() return True except Exception: return False def _ensure_authenticated(self): """Ensure we have a valid token.""" if not self.token: self._authenticate() def _get_retry_delay(self, response) -> float: """Extract retry_after delay from 429 response, default to DEFAULT_RETRY_DELAY.""" try: error_data = response.json() retry_after = error_data.get("retry_after", "") if retry_after: # Handle both string ("1s") and numeric values if isinstance(retry_after, str) and retry_after.endswith("s"): return float(retry_after[:-1]) return float(retry_after) except (json.JSONDecodeError, ValueError, TypeError): pass return self.DEFAULT_RETRY_DELAY def _request_with_retry(self, method: str, url: str, **kwargs) -> requests.Response: """ Execute HTTP request with automatic retry on 429 rate limit. Also handles 401 authentication refresh. """ last_response = None last_error = None for attempt in range(self.MAX_RETRIES): try: last_response = self.session.request(method, url, **kwargs) # Handle 401 - token expired if last_response.status_code == 401: if attempt < self.MAX_RETRIES - 1: self._authenticate() # Re-fetch the auth token and retry if method.upper() == "GET": last_response = self.session.get(url, **kwargs) elif method.upper() == "POST": last_response = self.session.post(url, **kwargs) elif method.upper() == "PATCH": last_response = self.session.patch(url, **kwargs) elif method.upper() == "DELETE": last_response = self.session.delete(url, **kwargs) continue return last_response # Handle 429 - rate limit if last_response.status_code == 429: if attempt < self.MAX_RETRIES - 1: retry_delay = self._get_retry_delay(last_response) print(f" [Rate Limit] 429 received, retrying in {retry_delay}s (attempt {attempt + 1}/{self.MAX_RETRIES})") time.sleep(retry_delay) continue return last_response # Handle MySQL lost connection (retry with dedicated LOST_CONNECTION_RETRIES) if last_response.status_code >= 500 and attempt < self.MAX_RETRIES - 1: text_lower = last_response.text.lower() if "lost connection" in text_lower or "gone away" in text_lower: lost_retry = self.LOST_CONNECTION_RETRIES print(f" [DB Lost Connection] retrying {lost_retry}x every 1s (attempt {attempt + 1}/{self.MAX_RETRIES})") for lr in range(lost_retry): time.sleep(1) try: retry_resp = self.session.request(method, url, **kwargs) if retry_resp.status_code < 500: print(f" [DB Recovered] status {retry_resp.status_code} after {lr+1}s wait") return retry_resp except: pass # All DB retries exhausted, continue with normal retry flow continue # Success or other error - return as-is return last_response except requests.exceptions.Timeout: last_error = f"Request timeout after {kwargs.get('timeout', 30)}s" if attempt < self.MAX_RETRIES - 1: wait_time = self.DEFAULT_RETRY_DELAY * (attempt + 1) print(f" [Timeout] Backend slow, checking health before retry...") # Health check with exponential backoff before retry for h in range(3): try: resp = self.session.get(f"{self.base_url}/health", timeout=5) if resp.status_code == 200: print(f" [Health] Backend healthy, retrying (attempt {attempt + 1}/{self.MAX_RETRIES})") break except: pass if h < 2: time.sleep(wait_time / 3) else: print(f" [Timeout] Retrying in {wait_time}s (attempt {attempt + 1}/{self.MAX_RETRIES})") time.sleep(wait_time) continue # Create a mock response for timeout class TimeoutResponse: status_code = 408 text = last_error def json(self): return {"error": last_error} return TimeoutResponse() except requests.exceptions.ConnectionError as e: last_error = f"Connection error: {str(e)}" if attempt < self.MAX_RETRIES - 1: wait_time = self.DEFAULT_RETRY_DELAY * (attempt + 1) print(f" [Connection Error] Checking backend health before retry...") # Health check before retry for h in range(3): try: resp = self.session.get(f"{self.base_url}/health", timeout=5) if resp.status_code == 200: print(f" [Health OK] Retrying (attempt {attempt + 1}/{self.MAX_RETRIES})") break except: pass if h < 2: time.sleep(wait_time / 3) else: print(f" [Connection Error] Retrying in {wait_time}s (attempt {attempt + 1}/{self.MAX_RETRIES})") time.sleep(wait_time) continue # Create a mock response for connection error class ConnectionErrorResponse: status_code = 503 text = last_error def json(self): return {"error": last_error} return ConnectionErrorResponse() return last_response def get(self, path: str, timeout_seconds: int = 30) -> Tuple[int, Any]: """ Perform GET request. Returns (status_code, response_json). Automatically retries on 429 rate limit and re-authenticates on 401. """ self._ensure_authenticated() url = f"{self.base_url}{path}" response = self._request_with_retry("GET", url, timeout=timeout_seconds) try: return response.status_code, response.json() except json.JSONDecodeError: return response.status_code, {"raw": response.text} def post(self, path: str, body: dict = None, timeout_seconds: int = 30) -> Tuple[int, Any]: """ Perform POST request. Returns (status_code, response_json). Automatically retries on 429 rate limit and re-authenticates on 401. """ self._ensure_authenticated() url = f"{self.base_url}{path}" response = self._request_with_retry("POST", url, json=body, timeout=timeout_seconds) try: return response.status_code, response.json() except json.JSONDecodeError: return response.status_code, {"raw": response.text} def patch(self, path: str, body: dict = None, timeout_seconds: int = 30) -> Tuple[int, Any]: """ Perform PATCH request. Returns (status_code, response_json). Automatically retries on 429 rate limit and re-authenticates on 401. """ self._ensure_authenticated() url = f"{self.base_url}{path}" response = self._request_with_retry("PATCH", url, json=body, timeout=timeout_seconds) try: return response.status_code, response.json() except json.JSONDecodeError: return response.status_code, {"raw": response.text} def delete(self, path: str, timeout_seconds: int = 30) -> Tuple[int, Any]: """ Perform DELETE request. Returns (status_code, response_json). Automatically retries on 429 rate limit and re-authenticates on 401. """ self._ensure_authenticated() url = f"{self.base_url}{path}" response = self._request_with_retry("DELETE", url, timeout=timeout_seconds) try: return response.status_code, response.json() except json.JSONDecodeError: return response.status_code, {"raw": response.text} # Global client instance _client = None def get_client() -> NexusAPIClient: """Get or create the global API client instance.""" global _client if _client is None: _client = NexusAPIClient() return _client def reset_client(): """Reset the global client (for testing).""" global _client _client = None