flru-parser 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flru/transport.py ADDED
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import email.utils
5
+ import random
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ import httpx
11
+
12
+ from .config import ClientConfig
13
+ from .exceptions import (
14
+ AuthenticationRequired,
15
+ BlockedError,
16
+ HTTPStatusError,
17
+ RateLimitedError,
18
+ TransportError,
19
+ )
20
+ from .observability import EventHandler, Metrics, RequestEvent, Timer, emit
21
+ from .proxy import ProxyPool, ProxyState
22
+ from .resilience import AsyncRateLimiter, CircuitBreakerRegistry, RetryBudget
23
+ from .security import validate_url
24
+
25
+ _BLOCK_MARKERS = (
26
+ "captcha",
27
+ "подтвердите, что вы не робот",
28
+ "доступ временно ограничен",
29
+ "cloudflare",
30
+ )
31
+ _AUTH_MARKERS = (
32
+ "необходимо авторизоваться",
33
+ "войдите в аккаунт",
34
+ )
35
+ _REDIRECT_CODES = {301, 302, 303, 307, 308}
36
+
37
+
38
+ class ResilientTransport:
39
+ def __init__(
40
+ self,
41
+ config: ClientConfig,
42
+ *,
43
+ event_handler: EventHandler | None = None,
44
+ client_transport: httpx.AsyncBaseTransport | None = None,
45
+ ) -> None:
46
+ self.config = config
47
+ self.metrics = Metrics()
48
+ self._event_handler = event_handler
49
+ self._rate_limiter = AsyncRateLimiter(config.rate_limit)
50
+ self._circuits = CircuitBreakerRegistry(config.circuit_breaker)
51
+ self._proxy_pool = ProxyPool(config.proxies)
52
+ self._clients: dict[str | None, httpx.AsyncClient] = {}
53
+ self._client_transport = client_transport
54
+ self._cookies = dict(config.cookies)
55
+ self._closed = False
56
+
57
+ @property
58
+ def proxy_pool(self) -> ProxyPool:
59
+ return self._proxy_pool
60
+
61
+ def _make_client(self, proxy: str | None) -> httpx.AsyncClient:
62
+ timeout = httpx.Timeout(
63
+ connect=self.config.timeout.connect,
64
+ read=self.config.timeout.read,
65
+ write=self.config.timeout.write,
66
+ pool=self.config.timeout.pool,
67
+ )
68
+ limits = httpx.Limits(
69
+ max_connections=self.config.max_connections,
70
+ max_keepalive_connections=self.config.max_keepalive_connections,
71
+ keepalive_expiry=self.config.keepalive_expiry,
72
+ )
73
+ user_agent = (
74
+ random.choice(self.config.user_agents)
75
+ if self.config.rotate_user_agents
76
+ else self.config.user_agents[0]
77
+ )
78
+ headers = {
79
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
80
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.7",
81
+ "Cache-Control": "no-cache",
82
+ "User-Agent": user_agent,
83
+ **self.config.headers,
84
+ }
85
+ return httpx.AsyncClient(
86
+ timeout=timeout,
87
+ limits=limits,
88
+ headers=headers,
89
+ cookies=self._cookies,
90
+ proxy=proxy,
91
+ verify=self.config.verify_ssl,
92
+ follow_redirects=False,
93
+ http2=self.config.http2,
94
+ transport=self._client_transport,
95
+ )
96
+
97
+ def _client(self, proxy: str | None) -> httpx.AsyncClient:
98
+ if proxy not in self._clients:
99
+ self._clients[proxy] = self._make_client(proxy)
100
+ return self._clients[proxy]
101
+
102
+ def _endpoint(self, url: str) -> str:
103
+ path = urlsplit(url).path.strip("/")
104
+ return path.split("/", 1)[0] or "root"
105
+
106
+ def _circuit_key(self, endpoint: str, proxy: str | None) -> str:
107
+ scope = self.config.circuit_breaker.scope
108
+ if scope == "global":
109
+ return "global"
110
+ if scope == "endpoint":
111
+ return endpoint
112
+ return f"{endpoint}|{proxy or 'direct'}"
113
+
114
+ async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
115
+ if self._closed:
116
+ raise RuntimeError("Transport is closed")
117
+ validate_url(url, self.config.allowed_hosts, self.config.allow_subdomains)
118
+
119
+ endpoint = self._endpoint(url)
120
+ last_error: Exception | None = None
121
+ request_headers = dict(kwargs.pop("headers", {}) or {})
122
+ budget = RetryBudget(
123
+ total_timeout=self.config.retry.total_timeout,
124
+ max_total_delay=self.config.retry.max_total_delay,
125
+ )
126
+
127
+ for attempt in range(1, self.config.retry.max_attempts + 1):
128
+ response: httpx.Response | None = None
129
+ proxy_state = await self._proxy_pool.acquire()
130
+ circuit = await self._circuits.get(self._circuit_key(endpoint, proxy_state.url))
131
+ await circuit.before_call()
132
+ timer = Timer()
133
+ await self.metrics.mutate(endpoint=endpoint, requests_total=1)
134
+ await self._emit(RequestEvent("start", method, url, attempt, endpoint, proxy_state.safe_url))
135
+
136
+ try:
137
+ async with self._rate_limiter:
138
+ response = await self._request_following_safe_redirects(
139
+ self._client(proxy_state.url), method, url, headers=request_headers, **kwargs
140
+ )
141
+ elapsed = timer.elapsed
142
+ await self.metrics.mutate(
143
+ endpoint=endpoint,
144
+ responses_total=1,
145
+ bytes_received=len(response.content),
146
+ latency_seconds_total=elapsed,
147
+ )
148
+ self._detect_block(response)
149
+
150
+ if response.status_code in self.config.retry.retry_statuses:
151
+ if response.status_code == 429:
152
+ await self.metrics.mutate(rate_limited_total=1)
153
+ retry_after = self._retry_after(response)
154
+ if retry_after is not None:
155
+ await self._rate_limiter.pause_for(retry_after)
156
+ raise RateLimitedError(429, str(response.url))
157
+ raise HTTPStatusError(response.status_code, str(response.url))
158
+
159
+ if response.status_code in {401, 403}:
160
+ raise AuthenticationRequired(
161
+ f"FL.ru returned HTTP {response.status_code} for {response.url}"
162
+ )
163
+ if response.is_error:
164
+ raise HTTPStatusError(response.status_code, str(response.url))
165
+
166
+ await self._proxy_pool.success(proxy_state)
167
+ await circuit.record_success()
168
+ await self._emit(
169
+ RequestEvent(
170
+ "success",
171
+ method,
172
+ str(response.url),
173
+ attempt,
174
+ endpoint,
175
+ proxy_state.safe_url,
176
+ response.status_code,
177
+ elapsed,
178
+ )
179
+ )
180
+ return response
181
+
182
+ except AuthenticationRequired as exc:
183
+ await self.metrics.mutate(endpoint=endpoint, failures_total=1)
184
+ await self._emit_failure(method, url, attempt, endpoint, proxy_state, timer, exc)
185
+ raise
186
+ except HTTPStatusError as exc:
187
+ last_error = exc
188
+ await self.metrics.mutate(endpoint=endpoint, failures_total=1)
189
+ if exc.status_code not in self.config.retry.retry_statuses:
190
+ await self._emit_failure(method, url, attempt, endpoint, proxy_state, timer, exc)
191
+ raise
192
+ await self._proxy_pool.failure(proxy_state, exc)
193
+ except BlockedError as exc:
194
+ last_error = exc
195
+ await self.metrics.mutate(endpoint=endpoint, blocked_total=1, failures_total=1)
196
+ await self._proxy_pool.failure(proxy_state, exc)
197
+ await self._emit(
198
+ RequestEvent(
199
+ "blocked",
200
+ method,
201
+ url,
202
+ attempt,
203
+ endpoint,
204
+ proxy_state.safe_url,
205
+ elapsed=timer.elapsed,
206
+ error=type(exc).__name__,
207
+ )
208
+ )
209
+ except httpx.TransportError as exc:
210
+ last_error = exc
211
+ await self.metrics.mutate(endpoint=endpoint, failures_total=1)
212
+ await self._proxy_pool.failure(proxy_state, exc)
213
+
214
+ if attempt >= self.config.retry.max_attempts:
215
+ await circuit.record_failure()
216
+ break
217
+
218
+ delay = self._retry_delay(attempt, response)
219
+ if not budget.allows(delay):
220
+ await circuit.record_failure()
221
+ break
222
+ budget.consume_delay(delay)
223
+ await self.metrics.mutate(endpoint=endpoint, retries_total=1)
224
+ await self._emit(
225
+ RequestEvent(
226
+ "retry",
227
+ method,
228
+ url,
229
+ attempt,
230
+ endpoint,
231
+ proxy_state.safe_url,
232
+ elapsed=timer.elapsed,
233
+ error=type(last_error).__name__ if last_error else None,
234
+ )
235
+ )
236
+ await asyncio.sleep(delay)
237
+
238
+ await self._emit(
239
+ RequestEvent(
240
+ "failure",
241
+ method,
242
+ url,
243
+ min(self.config.retry.max_attempts, attempt),
244
+ endpoint,
245
+ error=type(last_error).__name__ if last_error else None,
246
+ )
247
+ )
248
+ if isinstance(last_error, TransportError):
249
+ raise last_error
250
+ raise TransportError(f"Request failed: {method} {url}: {last_error}") from last_error
251
+
252
+ async def _request_following_safe_redirects(
253
+ self,
254
+ client: httpx.AsyncClient,
255
+ method: str,
256
+ url: str,
257
+ *,
258
+ headers: dict[str, str],
259
+ **kwargs: Any,
260
+ ) -> httpx.Response:
261
+ current_method = method
262
+ current_url = url
263
+ body_kwargs = dict(kwargs)
264
+ for _ in range(10):
265
+ response = await client.request(current_method, current_url, headers=headers, **body_kwargs)
266
+ if not self.config.follow_redirects or response.status_code not in _REDIRECT_CODES:
267
+ return response
268
+ location = response.headers.get("Location")
269
+ if not location:
270
+ return response
271
+ current_url = urljoin(str(response.url), location)
272
+ validate_url(current_url, self.config.allowed_hosts, self.config.allow_subdomains)
273
+ if response.status_code == 303 or (
274
+ response.status_code in {301, 302} and current_method.upper() == "POST"
275
+ ):
276
+ current_method = "GET"
277
+ body_kwargs.pop("content", None)
278
+ body_kwargs.pop("data", None)
279
+ body_kwargs.pop("json", None)
280
+ raise TransportError(f"Too many redirects for {url}")
281
+
282
+ async def emit_event(self, event: RequestEvent) -> None:
283
+ """Emit a sanitized event without allowing handler failures to break requests."""
284
+ if not await emit(self._event_handler, event):
285
+ await self.metrics.mutate(event_handler_failures_total=1)
286
+
287
+ async def _emit(self, event: RequestEvent) -> None:
288
+ await self.emit_event(event)
289
+
290
+ async def _emit_failure(
291
+ self,
292
+ method: str,
293
+ url: str,
294
+ attempt: int,
295
+ endpoint: str,
296
+ proxy_state: ProxyState,
297
+ timer: Timer,
298
+ error: Exception,
299
+ ) -> None:
300
+ await self._emit(
301
+ RequestEvent(
302
+ "failure",
303
+ method,
304
+ url,
305
+ attempt,
306
+ endpoint,
307
+ proxy_state.safe_url,
308
+ getattr(error, "status_code", None),
309
+ timer.elapsed,
310
+ type(error).__name__,
311
+ )
312
+ )
313
+
314
+ def _retry_delay(self, attempt: int, response: httpx.Response | None) -> float:
315
+ if response is not None and self.config.retry.respect_retry_after:
316
+ parsed = self._retry_after(response)
317
+ if parsed is not None:
318
+ return min(parsed, self.config.retry.max_delay)
319
+ exponential = min(
320
+ self.config.retry.max_delay,
321
+ self.config.retry.base_delay * (2 ** (attempt - 1)),
322
+ )
323
+ return exponential + random.uniform(0, self.config.retry.jitter)
324
+
325
+ def _retry_after(self, response: httpx.Response) -> float | None:
326
+ return self._parse_retry_after(response.headers.get("Retry-After"))
327
+
328
+ @staticmethod
329
+ def _parse_retry_after(value: str | None) -> float | None:
330
+ if not value:
331
+ return None
332
+ try:
333
+ return max(0.0, float(value))
334
+ except ValueError:
335
+ try:
336
+ parsed = email.utils.parsedate_to_datetime(value)
337
+ if parsed.tzinfo is None:
338
+ parsed = parsed.replace(tzinfo=timezone.utc)
339
+ return max(0.0, (parsed - datetime.now(timezone.utc)).total_seconds())
340
+ except (TypeError, ValueError):
341
+ return None
342
+
343
+ def _detect_block(self, response: httpx.Response) -> None:
344
+ if not self.config.detect_blocks:
345
+ return
346
+ sample = response.text[:200_000].casefold()
347
+ if any(marker in sample for marker in _BLOCK_MARKERS):
348
+ raise BlockedError(f"Anti-bot or CAPTCHA page detected at {response.url}")
349
+ if any(marker in sample for marker in _AUTH_MARKERS):
350
+ raise AuthenticationRequired(f"Authentication is required for {response.url}")
351
+
352
+ def update_cookies(self, cookies: dict[str, str]) -> None:
353
+ self._cookies.update(cookies)
354
+ for client in self._clients.values():
355
+ client.cookies.update(cookies)
356
+
357
+ async def close(self) -> None:
358
+ if self._closed:
359
+ return
360
+ self._closed = True
361
+ await asyncio.gather(*(client.aclose() for client in self._clients.values()))
362
+ self._clients.clear()