| import asyncio |
| import httpx |
| import logging |
| from typing import Optional, Dict, Any |
| from .config import CrawlerConfig |
| from .anti_blocking import AntiBlockingManager |
|
|
| logger = logging.getLogger("crawler.client") |
|
|
| class RobustHTTPClient: |
| def __init__(self, config: CrawlerConfig, anti_blocking: AntiBlockingManager): |
| self.config = config |
| self.anti_blocking = anti_blocking |
| |
| |
| limits = httpx.Limits( |
| max_keepalive_connections=self.config.concurrency_limit, |
| max_connections=self.config.concurrency_limit |
| ) |
| |
| |
| self.proxy_index = 0 |
| proxy = self._get_next_proxy() |
| |
| self.client = httpx.AsyncClient( |
| proxy=proxy, |
| limits=limits, |
| timeout=httpx.Timeout(15.0, connect=5.0), |
| follow_redirects=True, |
| http2=True |
| ) |
|
|
| def _get_next_proxy(self) -> Optional[str]: |
| if not self.config.proxies: |
| return None |
| proxy = self.config.proxies[self.proxy_index] |
| self.proxy_index = (self.proxy_index + 1) % len(self.config.proxies) |
| return proxy |
|
|
| async def get(self, url: str, referer: Optional[str] = None) -> httpx.Response: |
| """Performs a GET request with automatic headers, retries, and exponential backoff on HTTP 429.""" |
| headers = self.anti_blocking.generate_headers(self.config.domain, referer) |
| |
| retries = 3 |
| backoff_factor = 2.0 |
| |
| for attempt in range(retries): |
| try: |
| |
| delay = self.anti_blocking.calculate_jitter_delay(self.config.delay_range) |
| await asyncio.sleep(delay) |
| |
| logger.info(f"Requesting: {url} (Attempt {attempt + 1}/{retries})") |
| |
| |
| response = await self.client.get(url, headers=headers) |
| |
| |
| if response.status_code == 429: |
| retry_after = response.headers.get("Retry-After") |
| sleep_time = float(retry_after) if retry_after and retry_after.isdigit() else (backoff_factor ** attempt) * 5.0 |
| logger.warning(f"Rate limited (429) on {url}. Sleeping for {sleep_time}s before retry.") |
| await asyncio.sleep(sleep_time) |
| continue |
| |
| |
| response.raise_for_status() |
| return response |
| |
| except httpx.HTTPStatusError as e: |
| |
| status_code = e.response.status_code |
| if status_code in [500, 502, 503, 504]: |
| |
| sleep_time = (backoff_factor ** attempt) * 2.0 |
| logger.warning(f"Server error {status_code} on {url}. Retrying in {sleep_time}s...") |
| await asyncio.sleep(sleep_time) |
| continue |
| else: |
| |
| logger.error(f"Client error {status_code} on {url}: {str(e)}") |
| raise e |
| |
| except (httpx.RequestError, asyncio.TimeoutError) as e: |
| |
| sleep_time = (backoff_factor ** attempt) * 2.0 |
| logger.warning(f"Network error on {url}: {str(e)}. Retrying in {sleep_time}s...") |
| await asyncio.sleep(sleep_time) |
| continue |
| |
| raise httpx.RequestError(f"Failed to fetch {url} after {retries} attempts.") |
|
|
| async def close(self): |
| await self.client.aclose() |
|
|