import json import logging import socket import time import urllib.error import urllib.request from typing import Dict, Any, Optional logger = logging.getLogger("pr") class SyncHTTPClient: def __init__(self): self.session_headers = {} def request( self, method: str, url: str, headers: Optional[Dict[str, str]] = None, data: Optional[bytes] = None, json_data: Optional[Dict[str, Any]] = None, timeout: float = 30.0, ) -> Dict[str, Any]: """Make a sync HTTP request using urllib with retry logic.""" # Prepare headers request_headers = {**self.session_headers} if headers: request_headers.update(headers) # Prepare data request_data = data if json_data is not None: request_data = json.dumps(json_data).encode("utf-8") request_headers["Content-Type"] = "application/json" # Create request object req = urllib.request.Request(url, data=request_data, headers=request_headers, method=method) attempt = 0 start_time = time.time() while True: attempt += 1 try: # Execute request with urllib.request.urlopen(req, timeout=timeout) as response: response_data = response.read() response_text = response_data.decode("utf-8") return { "status": response.status, "headers": dict(response.headers), "text": response_text, "json": lambda: json.loads(response_text) if response_text else None, } except urllib.error.HTTPError as e: error_body = e.read() error_text = error_body.decode("utf-8") return { "status": e.code, "error": True, "text": error_text, "json": lambda: json.loads(error_text) if error_text else None, } except socket.timeout: # Handle socket timeouts specifically elapsed = time.time() - start_time elapsed_minutes = int(elapsed // 60) elapsed_seconds = elapsed % 60 duration_str = ( f"{elapsed_minutes}m {elapsed_seconds:.1f}s" if elapsed_minutes > 0 else f"{elapsed_seconds:.1f}s" ) logger.warning( f"Request timed out (attempt {attempt}, " f"duration: {duration_str}). Retrying in {attempt} second(s)..." ) # Exponential backoff starting at 1 second time.sleep(attempt) except Exception as e: error_msg = str(e) # For other exceptions, check if they might be timeout-related if "timed out" in error_msg.lower() or "timeout" in error_msg.lower(): elapsed = time.time() - start_time elapsed_minutes = int(elapsed // 60) elapsed_seconds = elapsed % 60 duration_str = ( f"{elapsed_minutes}m {elapsed_seconds:.1f}s" if elapsed_minutes > 0 else f"{elapsed_seconds:.1f}s" ) logger.warning( f"Request timed out (attempt {attempt}, " f"duration: {duration_str}). Retrying in {attempt} second(s)..." ) # Exponential backoff starting at 1 second time.sleep(attempt) else: # Non-timeout errors should not be retried return {"error": True, "exception": error_msg} def get( self, url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0 ) -> Dict[str, Any]: return self.request("GET", url, headers=headers, timeout=timeout) def post( self, url: str, headers: Optional[Dict[str, str]] = None, data: Optional[bytes] = None, json_data: Optional[Dict[str, Any]] = None, timeout: float = 30.0, ) -> Dict[str, Any]: return self.request( "POST", url, headers=headers, data=data, json_data=json_data, timeout=timeout ) def set_default_headers(self, headers: Dict[str, str]): self.session_headers.update(headers) # Global client instance http_client = SyncHTTPClient()