dexscreen 0.0.2__py3-none-any.whl → 0.0.4__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.
- dexscreen/__init__.py +87 -0
- dexscreen/api/client.py +275 -42
- dexscreen/core/exceptions.py +1067 -0
- dexscreen/core/http.py +861 -117
- dexscreen/core/validators.py +542 -0
- dexscreen/stream/polling.py +288 -78
- dexscreen/utils/__init__.py +54 -1
- dexscreen/utils/filters.py +182 -12
- dexscreen/utils/logging_config.py +421 -0
- dexscreen/utils/middleware.py +363 -0
- dexscreen/utils/ratelimit.py +212 -8
- dexscreen/utils/retry.py +357 -0
- {dexscreen-0.0.2.dist-info → dexscreen-0.0.4.dist-info}/METADATA +52 -1
- dexscreen-0.0.4.dist-info/RECORD +22 -0
- dexscreen-0.0.2.dist-info/RECORD +0 -17
- {dexscreen-0.0.2.dist-info → dexscreen-0.0.4.dist-info}/WHEEL +0 -0
- {dexscreen-0.0.2.dist-info → dexscreen-0.0.4.dist-info}/licenses/LICENSE +0 -0
dexscreen/core/http.py
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
"""
|
2
|
-
Enhanced with
|
2
|
+
Enhanced HTTP client with structured logging and error context preservation
|
3
3
|
"""
|
4
4
|
|
5
5
|
import asyncio
|
6
6
|
import contextlib
|
7
|
+
import time
|
7
8
|
from datetime import datetime, timedelta
|
8
9
|
from enum import Enum
|
9
10
|
from threading import Lock
|
@@ -13,7 +14,16 @@ import orjson
|
|
13
14
|
from curl_cffi.requests import AsyncSession, Session
|
14
15
|
|
15
16
|
from ..utils.browser_selector import get_random_browser
|
17
|
+
from ..utils.logging_config import generate_correlation_id, get_contextual_logger, with_correlation_id
|
16
18
|
from ..utils.ratelimit import RateLimiter
|
19
|
+
from ..utils.retry import RetryConfig, RetryManager, RetryPresets
|
20
|
+
from .exceptions import (
|
21
|
+
HttpConnectionError,
|
22
|
+
HttpRequestError,
|
23
|
+
HttpResponseParsingError,
|
24
|
+
HttpSessionError,
|
25
|
+
HttpTimeoutError,
|
26
|
+
)
|
17
27
|
|
18
28
|
# Type alias for HTTP methods
|
19
29
|
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "TRACE"]
|
@@ -36,6 +46,8 @@ class HttpClientCffi:
|
|
36
46
|
- Zero-downtime configuration updates
|
37
47
|
- Graceful session switching
|
38
48
|
- Automatic connection warm-up
|
49
|
+
- Enhanced structured logging with correlation IDs
|
50
|
+
- Request/response tracking and error context preservation
|
39
51
|
"""
|
40
52
|
|
41
53
|
def __init__(
|
@@ -45,6 +57,7 @@ class HttpClientCffi:
|
|
45
57
|
base_url: str = "https://api.dexscreener.com/",
|
46
58
|
client_kwargs: Optional[dict[str, Any]] = None,
|
47
59
|
warmup_url: str = "/latest/dex/tokens/solana?limit=1",
|
60
|
+
retry_config: Optional[RetryConfig] = None,
|
48
61
|
):
|
49
62
|
"""
|
50
63
|
Initialize HTTP client with rate limiting and browser impersonation.
|
@@ -57,17 +70,27 @@ class HttpClientCffi:
|
|
57
70
|
Common options include:
|
58
71
|
- impersonate: Browser to impersonate (default: "realworld")
|
59
72
|
- proxies: Proxy configuration
|
60
|
-
- timeout: Request timeout
|
73
|
+
- timeout: Request timeout (default: 10 seconds)
|
61
74
|
- headers: Additional headers
|
62
75
|
- verify: SSL verification
|
63
76
|
warmup_url: URL path for warming up new sessions
|
77
|
+
retry_config: Retry configuration for network operations.
|
78
|
+
If None, uses default API-optimized retry settings.
|
64
79
|
"""
|
65
80
|
self._limiter = RateLimiter(calls, period)
|
66
81
|
self.base_url = base_url
|
67
82
|
self.warmup_url = warmup_url
|
68
83
|
|
84
|
+
# Setup retry configuration
|
85
|
+
self.retry_config = retry_config or RetryPresets.api_calls()
|
86
|
+
|
69
87
|
# Setup client kwargs with defaults
|
70
88
|
self.client_kwargs = client_kwargs or {}
|
89
|
+
|
90
|
+
# Set default timeout if not specified
|
91
|
+
if "timeout" not in self.client_kwargs:
|
92
|
+
self.client_kwargs["timeout"] = 10
|
93
|
+
|
71
94
|
# Use our custom realworld browser selection if not specified
|
72
95
|
if "impersonate" not in self.client_kwargs:
|
73
96
|
self.client_kwargs["impersonate"] = get_random_browser()
|
@@ -93,43 +116,163 @@ class HttpClientCffi:
|
|
93
116
|
# Async lock for session switching
|
94
117
|
self._switch_lock = asyncio.Lock()
|
95
118
|
|
96
|
-
#
|
119
|
+
# Enhanced statistics with timing data
|
97
120
|
self._stats = {
|
98
121
|
"switches": 0,
|
99
122
|
"failed_requests": 0,
|
100
123
|
"successful_requests": 0,
|
101
124
|
"last_switch": None,
|
125
|
+
"retry_attempts": 0,
|
126
|
+
"retry_successes": 0,
|
127
|
+
"retry_failures": 0,
|
128
|
+
"total_requests": 0,
|
129
|
+
"average_response_time": 0.0,
|
130
|
+
"min_response_time": float("inf"),
|
131
|
+
"max_response_time": 0.0,
|
102
132
|
}
|
103
133
|
|
134
|
+
# Enhanced logging
|
135
|
+
self.logger = get_contextual_logger(__name__)
|
136
|
+
|
104
137
|
def _create_absolute_url(self, relative: str) -> str:
|
105
138
|
base = self.base_url.rstrip("/")
|
106
139
|
relative = relative.lstrip("/")
|
107
140
|
return f"{base}/{relative}"
|
108
141
|
|
142
|
+
def _update_response_time_stats(self, duration: float):
|
143
|
+
"""Update response time statistics"""
|
144
|
+
with self._lock:
|
145
|
+
self._stats["total_requests"] += 1
|
146
|
+
# Update running average response time
|
147
|
+
total_requests = self._stats["total_requests"]
|
148
|
+
current_avg = self._stats["average_response_time"]
|
149
|
+
self._stats["average_response_time"] = (current_avg * (total_requests - 1) + duration) / total_requests
|
150
|
+
# Update min/max
|
151
|
+
self._stats["min_response_time"] = min(self._stats["min_response_time"], duration)
|
152
|
+
self._stats["max_response_time"] = max(self._stats["max_response_time"], duration)
|
153
|
+
|
154
|
+
def _parse_json_response(
|
155
|
+
self,
|
156
|
+
response: Any,
|
157
|
+
method: str,
|
158
|
+
url: str,
|
159
|
+
context: dict[str, Any]
|
160
|
+
) -> Union[list, dict, None]:
|
161
|
+
"""Parse JSON response with proper error handling and logging"""
|
162
|
+
content_type = response.headers.get("content-type", "")
|
163
|
+
|
164
|
+
if "application/json" not in content_type:
|
165
|
+
# Non-JSON response
|
166
|
+
content_preview = (
|
167
|
+
response.content[:200].decode("utf-8", errors="replace")
|
168
|
+
if response.content else ""
|
169
|
+
)
|
170
|
+
|
171
|
+
parse_context = context.copy()
|
172
|
+
parse_context.update({
|
173
|
+
"expected_json": True,
|
174
|
+
"received_content_type": content_type,
|
175
|
+
"content_preview": content_preview,
|
176
|
+
})
|
177
|
+
|
178
|
+
self.logger.warning("Received non-JSON response when JSON expected", context=parse_context)
|
179
|
+
|
180
|
+
raise HttpResponseParsingError(
|
181
|
+
method,
|
182
|
+
url,
|
183
|
+
content_type,
|
184
|
+
content_preview,
|
185
|
+
original_error=Exception(f"Expected JSON response but got {content_type}")
|
186
|
+
)
|
187
|
+
|
188
|
+
try:
|
189
|
+
return orjson.loads(response.content)
|
190
|
+
except Exception as e:
|
191
|
+
content_preview = (
|
192
|
+
response.content[:200].decode("utf-8", errors="replace")
|
193
|
+
if response.content else ""
|
194
|
+
)
|
195
|
+
|
196
|
+
parse_context = context.copy()
|
197
|
+
parse_context.update({
|
198
|
+
"parse_error": str(e),
|
199
|
+
"content_preview": content_preview,
|
200
|
+
})
|
201
|
+
|
202
|
+
self.logger.error(
|
203
|
+
"Failed to parse JSON response: %s",
|
204
|
+
str(e),
|
205
|
+
context=parse_context,
|
206
|
+
exc_info=True
|
207
|
+
)
|
208
|
+
|
209
|
+
raise HttpResponseParsingError(
|
210
|
+
method, url, content_type, content_preview, original_error=e
|
211
|
+
) from e
|
212
|
+
|
109
213
|
async def _ensure_active_session(self) -> AsyncSession:
|
110
214
|
"""Ensure there's an active session"""
|
111
215
|
async with self._switch_lock:
|
112
|
-
#
|
113
|
-
if self.
|
114
|
-
|
115
|
-
|
116
|
-
|
216
|
+
# Create primary session if it doesn't exist
|
217
|
+
if self._primary_session is None:
|
218
|
+
session_context = {
|
219
|
+
"operation": "create_session",
|
220
|
+
"session_type": "primary_async",
|
221
|
+
"browser": self.client_kwargs.get("impersonate", "unknown"),
|
222
|
+
}
|
223
|
+
|
224
|
+
self.logger.debug("Creating new async session", context=session_context)
|
225
|
+
|
117
226
|
try:
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
227
|
+
self._primary_session = AsyncSession(**self.client_kwargs)
|
228
|
+
|
229
|
+
# Warm up connection
|
230
|
+
warmup_start = time.time()
|
231
|
+
warmup_success = False
|
232
|
+
try:
|
233
|
+
warmup_url = self._create_absolute_url(self.warmup_url)
|
234
|
+
response = await self._primary_session.get(warmup_url)
|
235
|
+
if response.status_code == 200:
|
236
|
+
warmup_success = True
|
237
|
+
warmup_duration = time.time() - warmup_start
|
238
|
+
|
239
|
+
session_context.update(
|
240
|
+
{
|
241
|
+
"warmup_success": True,
|
242
|
+
"warmup_time_ms": round(warmup_duration * 1000, 2),
|
243
|
+
"warmup_status": response.status_code,
|
244
|
+
}
|
245
|
+
)
|
246
|
+
|
247
|
+
self.logger.debug("Session warmup successful", context=session_context)
|
248
|
+
except Exception as e:
|
249
|
+
warmup_duration = time.time() - warmup_start
|
250
|
+
session_context.update(
|
251
|
+
{
|
252
|
+
"warmup_success": False,
|
253
|
+
"warmup_time_ms": round(warmup_duration * 1000, 2),
|
254
|
+
"warmup_error": str(e),
|
255
|
+
}
|
256
|
+
)
|
257
|
+
|
258
|
+
self.logger.warning("Session warmup failed", context=session_context)
|
259
|
+
|
260
|
+
# Always activate the session (warmup is optional)
|
131
261
|
self._primary_state = SessionState.ACTIVE
|
132
262
|
|
263
|
+
except Exception as e:
|
264
|
+
session_context.update(
|
265
|
+
{
|
266
|
+
"creation_error": str(e),
|
267
|
+
"error_type": type(e).__name__,
|
268
|
+
}
|
269
|
+
)
|
270
|
+
|
271
|
+
self.logger.error(
|
272
|
+
"Failed to create async session: %s", str(e), context=session_context, exc_info=True
|
273
|
+
)
|
274
|
+
raise
|
275
|
+
|
133
276
|
if self._primary_session is None:
|
134
277
|
raise RuntimeError("Failed to create primary session")
|
135
278
|
return self._primary_session
|
@@ -138,22 +281,76 @@ class HttpClientCffi:
|
|
138
281
|
"""Ensure there's a sync session"""
|
139
282
|
with self._lock:
|
140
283
|
if self._sync_primary is None:
|
141
|
-
|
142
|
-
|
284
|
+
session_context = {
|
285
|
+
"operation": "create_session",
|
286
|
+
"session_type": "primary_sync",
|
287
|
+
"browser": self.client_kwargs.get("impersonate", "unknown"),
|
288
|
+
}
|
289
|
+
|
290
|
+
self.logger.debug("Creating new sync session", context=session_context)
|
291
|
+
|
143
292
|
try:
|
144
|
-
|
145
|
-
|
146
|
-
#
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
293
|
+
self._sync_primary = Session(**self.client_kwargs)
|
294
|
+
|
295
|
+
# Warm up
|
296
|
+
warmup_start = time.time()
|
297
|
+
try:
|
298
|
+
warmup_url = self._create_absolute_url(self.warmup_url)
|
299
|
+
response = self._sync_primary.get(warmup_url)
|
300
|
+
warmup_duration = time.time() - warmup_start
|
301
|
+
|
302
|
+
if response.status_code == 200:
|
303
|
+
session_context.update(
|
304
|
+
{
|
305
|
+
"warmup_success": True,
|
306
|
+
"warmup_time_ms": round(warmup_duration * 1000, 2),
|
307
|
+
"warmup_status": response.status_code,
|
308
|
+
}
|
309
|
+
)
|
310
|
+
|
311
|
+
self.logger.debug("Sync session warmup successful", context=session_context)
|
312
|
+
else:
|
313
|
+
session_context.update(
|
314
|
+
{
|
315
|
+
"warmup_success": False,
|
316
|
+
"warmup_time_ms": round(warmup_duration * 1000, 2),
|
317
|
+
"warmup_status": response.status_code,
|
318
|
+
}
|
319
|
+
)
|
320
|
+
|
321
|
+
self.logger.warning("Sync session warmup returned non-200", context=session_context)
|
322
|
+
|
323
|
+
except Exception as e:
|
324
|
+
warmup_duration = time.time() - warmup_start
|
325
|
+
session_context.update(
|
326
|
+
{
|
327
|
+
"warmup_success": False,
|
328
|
+
"warmup_time_ms": round(warmup_duration * 1000, 2),
|
329
|
+
"warmup_error": str(e),
|
330
|
+
}
|
331
|
+
)
|
332
|
+
|
333
|
+
self.logger.warning("Sync session warmup failed", context=session_context)
|
334
|
+
|
335
|
+
except Exception as e:
|
336
|
+
session_context.update(
|
337
|
+
{
|
338
|
+
"creation_error": str(e),
|
339
|
+
"error_type": type(e).__name__,
|
340
|
+
}
|
341
|
+
)
|
342
|
+
|
343
|
+
self.logger.error(
|
344
|
+
"Failed to create sync session: %s", str(e), context=session_context, exc_info=True
|
345
|
+
)
|
346
|
+
raise
|
151
347
|
|
152
348
|
return self._sync_primary
|
153
349
|
|
350
|
+
@with_correlation_id()
|
154
351
|
def request(self, method: HttpMethod, url: str, **kwargs) -> Union[list, dict, None]:
|
155
352
|
"""
|
156
|
-
Synchronous request with rate limiting and browser impersonation.
|
353
|
+
Synchronous request with rate limiting, retry logic, and browser impersonation.
|
157
354
|
|
158
355
|
Args:
|
159
356
|
method: HTTP method (GET, POST, etc.)
|
@@ -162,30 +359,180 @@ class HttpClientCffi:
|
|
162
359
|
|
163
360
|
Returns:
|
164
361
|
Parsed JSON response
|
362
|
+
|
363
|
+
Raises:
|
364
|
+
HttpConnectionError: When unable to establish connection (after retries)
|
365
|
+
HttpTimeoutError: When request times out (after retries)
|
366
|
+
HttpRequestError: When request fails with HTTP error status (after retries)
|
367
|
+
HttpResponseParsingError: When response parsing fails
|
368
|
+
HttpSessionError: When session creation fails
|
165
369
|
"""
|
166
370
|
url = self._create_absolute_url(url)
|
371
|
+
retry_manager = RetryManager(self.retry_config)
|
372
|
+
request_start = time.time()
|
373
|
+
|
374
|
+
request_context = {
|
375
|
+
"method": method,
|
376
|
+
"url": url,
|
377
|
+
"has_kwargs": bool(kwargs),
|
378
|
+
"request_id": generate_correlation_id()[:8],
|
379
|
+
"session_type": "sync",
|
380
|
+
}
|
381
|
+
|
382
|
+
self.logger.debug("Starting sync HTTP request", context=request_context)
|
167
383
|
|
168
384
|
with self._limiter:
|
385
|
+
# Try session creation first
|
169
386
|
try:
|
170
|
-
# Use persistent session
|
171
387
|
session = self._ensure_sync_session()
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
388
|
+
except Exception as e:
|
389
|
+
error_context = request_context.copy()
|
390
|
+
error_context.update(
|
391
|
+
{
|
392
|
+
"error_type": type(e).__name__,
|
393
|
+
"error_message": str(e),
|
394
|
+
}
|
395
|
+
)
|
396
|
+
|
397
|
+
self.logger.error("Failed to create sync session: %s", str(e), context=error_context, exc_info=True)
|
398
|
+
raise HttpSessionError("Failed to create or access sync session", original_error=e) from e
|
399
|
+
|
400
|
+
while True:
|
401
|
+
try:
|
402
|
+
response = session.request(method, url, **kwargs) # type: ignore
|
403
|
+
response.raise_for_status()
|
404
|
+
|
405
|
+
request_duration = time.time() - request_start
|
406
|
+
self._update_response_time_stats(request_duration)
|
407
|
+
|
408
|
+
# Track success
|
409
|
+
with self._lock:
|
410
|
+
self._stats["successful_requests"] += 1
|
411
|
+
if retry_manager.attempt > 0:
|
412
|
+
self._stats["retry_successes"] += 1
|
413
|
+
|
414
|
+
# Log successful response
|
415
|
+
response_context = request_context.copy()
|
416
|
+
response_context.update(
|
417
|
+
{
|
418
|
+
"status_code": response.status_code,
|
419
|
+
"response_time_ms": round(request_duration * 1000, 2),
|
420
|
+
"content_type": response.headers.get("content-type", "unknown"),
|
421
|
+
"content_length": len(response.content) if response.content else 0,
|
422
|
+
"retry_attempt": retry_manager.attempt,
|
423
|
+
"success": True,
|
424
|
+
}
|
425
|
+
)
|
426
|
+
|
427
|
+
self.logger.debug("Sync HTTP request completed successfully", context=response_context)
|
428
|
+
|
429
|
+
# Parse JSON response
|
430
|
+
return self._parse_json_response(response, method, url, response_context)
|
431
|
+
|
432
|
+
except HttpResponseParsingError:
|
433
|
+
# Re-raise parsing errors immediately (not retryable)
|
434
|
+
raise
|
435
|
+
except Exception as e:
|
436
|
+
request_duration = time.time() - request_start
|
437
|
+
|
438
|
+
with self._lock:
|
439
|
+
self._stats["failed_requests"] += 1
|
440
|
+
if retry_manager.attempt > 0:
|
441
|
+
self._stats["retry_attempts"] += 1
|
442
|
+
|
443
|
+
# Create error context
|
444
|
+
error_context = request_context.copy()
|
445
|
+
error_context.update(
|
446
|
+
{
|
447
|
+
"error_type": type(e).__name__,
|
448
|
+
"error_message": str(e),
|
449
|
+
"response_time_ms": round(request_duration * 1000, 2),
|
450
|
+
"retry_attempt": retry_manager.attempt,
|
451
|
+
}
|
452
|
+
)
|
453
|
+
|
454
|
+
# Add response details if available
|
455
|
+
if hasattr(e, "response"):
|
456
|
+
response = e.response # type: ignore
|
457
|
+
if response is not None:
|
458
|
+
error_context.update(
|
459
|
+
{
|
460
|
+
"status_code": response.status_code,
|
461
|
+
"response_headers": dict(response.headers),
|
462
|
+
}
|
463
|
+
)
|
464
|
+
|
465
|
+
retry_manager.record_failure(e)
|
466
|
+
|
467
|
+
if retry_manager.should_retry(e):
|
468
|
+
retry_context = error_context.copy()
|
469
|
+
retry_context.update(
|
470
|
+
{
|
471
|
+
"will_retry": True,
|
472
|
+
"max_retries": self.retry_config.max_retries,
|
473
|
+
"retry_delay_ms": round(retry_manager.calculate_delay() * 1000, 2),
|
474
|
+
}
|
475
|
+
)
|
476
|
+
|
477
|
+
self.logger.warning(
|
478
|
+
"Retrying sync request %s %s (attempt %d/%d): %s",
|
479
|
+
method,
|
480
|
+
url,
|
481
|
+
retry_manager.attempt,
|
482
|
+
self.retry_config.max_retries + 1,
|
483
|
+
str(e),
|
484
|
+
context=retry_context,
|
485
|
+
)
|
486
|
+
retry_manager.wait_sync()
|
487
|
+
continue
|
488
|
+
else:
|
489
|
+
# Not retryable or max retries exceeded - classify and raise final error
|
490
|
+
final_error_context = error_context.copy()
|
491
|
+
final_error_context.update(
|
492
|
+
{
|
493
|
+
"final_failure": True,
|
494
|
+
"total_retry_attempts": retry_manager.attempt,
|
495
|
+
"is_retryable": retry_manager.should_retry(e)
|
496
|
+
if retry_manager.attempt < self.retry_config.max_retries
|
497
|
+
else False,
|
498
|
+
}
|
499
|
+
)
|
500
|
+
|
501
|
+
with self._lock:
|
502
|
+
if retry_manager.attempt > 0:
|
503
|
+
self._stats["retry_failures"] += 1
|
504
|
+
|
505
|
+
self.logger.error(
|
506
|
+
"Sync HTTP request failed permanently: %s",
|
507
|
+
str(e),
|
508
|
+
context=final_error_context,
|
509
|
+
exc_info=True,
|
510
|
+
)
|
511
|
+
|
512
|
+
# Classify the error type for final exception
|
513
|
+
error_msg = str(e).lower()
|
514
|
+
if "timeout" in error_msg or "timed out" in error_msg:
|
515
|
+
# Extract timeout value if available from kwargs
|
516
|
+
timeout = kwargs.get("timeout", "unknown")
|
517
|
+
raise HttpTimeoutError(method, url, timeout, original_error=e) from e
|
518
|
+
elif "connection" in error_msg or "resolve" in error_msg or "network" in error_msg:
|
519
|
+
raise HttpConnectionError(method, url, original_error=e) from e
|
520
|
+
else:
|
521
|
+
# Get status code if available
|
522
|
+
status_code = None
|
523
|
+
response_text = None
|
524
|
+
if hasattr(e, "response"):
|
525
|
+
response = e.response # type: ignore
|
526
|
+
if response and hasattr(response, "status_code"):
|
527
|
+
status_code = response.status_code
|
528
|
+
if response and hasattr(response, "content"):
|
529
|
+
response_text = response.content[:200].decode("utf-8", errors="replace")
|
530
|
+
raise HttpRequestError(method, url, status_code, response_text, original_error=e) from e
|
531
|
+
|
532
|
+
@with_correlation_id()
|
186
533
|
async def request_async(self, method: HttpMethod, url: str, **kwargs) -> Union[list, dict, None]:
|
187
534
|
"""
|
188
|
-
Asynchronous request with rate limiting and browser impersonation.
|
535
|
+
Asynchronous request with rate limiting, retry logic, and browser impersonation.
|
189
536
|
|
190
537
|
Args:
|
191
538
|
method: HTTP method (GET, POST, etc.)
|
@@ -194,51 +541,245 @@ class HttpClientCffi:
|
|
194
541
|
|
195
542
|
Returns:
|
196
543
|
Parsed JSON response
|
544
|
+
|
545
|
+
Raises:
|
546
|
+
HttpConnectionError: When unable to establish connection (after retries)
|
547
|
+
HttpTimeoutError: When request times out (after retries)
|
548
|
+
HttpRequestError: When request fails with HTTP error status (after retries)
|
549
|
+
HttpResponseParsingError: When response parsing fails
|
550
|
+
HttpSessionError: When session creation fails
|
197
551
|
"""
|
198
552
|
url = self._create_absolute_url(url)
|
553
|
+
retry_manager = RetryManager(self.retry_config)
|
554
|
+
request_start = time.time()
|
555
|
+
|
556
|
+
request_context = {
|
557
|
+
"method": method,
|
558
|
+
"url": url,
|
559
|
+
"has_kwargs": bool(kwargs),
|
560
|
+
"request_id": generate_correlation_id()[:8],
|
561
|
+
"session_type": "async",
|
562
|
+
}
|
199
563
|
|
200
|
-
async
|
201
|
-
# Get active session
|
202
|
-
session = await self._ensure_active_session()
|
203
|
-
|
204
|
-
# Track active requests
|
205
|
-
with self._lock:
|
206
|
-
self._primary_requests += 1
|
207
|
-
|
208
|
-
try:
|
209
|
-
response = await session.request(method, url, **kwargs) # type: ignore
|
210
|
-
response.raise_for_status()
|
564
|
+
self.logger.debug("Starting async HTTP request", context=request_context)
|
211
565
|
|
212
|
-
|
566
|
+
async with self._limiter:
|
567
|
+
while True:
|
568
|
+
# Get active session for each attempt
|
569
|
+
try:
|
570
|
+
session = await self._ensure_active_session()
|
571
|
+
except Exception as e:
|
572
|
+
error_context = request_context.copy()
|
573
|
+
error_context.update(
|
574
|
+
{
|
575
|
+
"error_type": type(e).__name__,
|
576
|
+
"error_message": str(e),
|
577
|
+
}
|
578
|
+
)
|
579
|
+
|
580
|
+
self.logger.error(
|
581
|
+
"Failed to create async session: %s", str(e), context=error_context, exc_info=True
|
582
|
+
)
|
583
|
+
raise HttpSessionError("Failed to create or access async session", original_error=e) from e
|
584
|
+
|
585
|
+
# Track active requests
|
213
586
|
with self._lock:
|
214
|
-
self.
|
215
|
-
|
216
|
-
# Parse response
|
217
|
-
content_type = response.headers.get("content-type", "")
|
218
|
-
if "application/json" in content_type:
|
219
|
-
# Use orjson for better performance
|
220
|
-
return orjson.loads(response.content)
|
221
|
-
else:
|
222
|
-
return None
|
587
|
+
self._primary_requests += 1
|
223
588
|
|
224
|
-
|
225
|
-
|
226
|
-
|
589
|
+
try:
|
590
|
+
response = await session.request(method, url, **kwargs) # type: ignore
|
591
|
+
response.raise_for_status()
|
592
|
+
|
593
|
+
request_duration = time.time() - request_start
|
594
|
+
self._update_response_time_stats(request_duration)
|
595
|
+
|
596
|
+
# Track success
|
597
|
+
with self._lock:
|
598
|
+
self._stats["successful_requests"] += 1
|
599
|
+
if retry_manager.attempt > 0:
|
600
|
+
self._stats["retry_successes"] += 1
|
601
|
+
|
602
|
+
# Log successful response
|
603
|
+
response_context = request_context.copy()
|
604
|
+
response_context.update(
|
605
|
+
{
|
606
|
+
"status_code": response.status_code,
|
607
|
+
"response_time_ms": round(request_duration * 1000, 2),
|
608
|
+
"content_type": response.headers.get("content-type", "unknown"),
|
609
|
+
"content_length": len(response.content) if response.content else 0,
|
610
|
+
"retry_attempt": retry_manager.attempt,
|
611
|
+
"session_state": self._primary_state.value,
|
612
|
+
"success": True,
|
613
|
+
}
|
614
|
+
)
|
615
|
+
|
616
|
+
self.logger.debug("Async HTTP request completed successfully", context=response_context)
|
617
|
+
|
618
|
+
# Parse JSON response
|
619
|
+
return self._parse_json_response(response, method, url, response_context)
|
620
|
+
|
621
|
+
except HttpResponseParsingError:
|
622
|
+
# Re-raise parsing errors immediately (not retryable)
|
623
|
+
with self._lock:
|
624
|
+
self._stats["failed_requests"] += 1
|
625
|
+
raise
|
626
|
+
except Exception as e:
|
627
|
+
request_duration = time.time() - request_start
|
628
|
+
|
629
|
+
with self._lock:
|
630
|
+
self._stats["failed_requests"] += 1
|
631
|
+
if retry_manager.attempt > 0:
|
632
|
+
self._stats["retry_attempts"] += 1
|
633
|
+
|
634
|
+
# Create error context
|
635
|
+
error_context = request_context.copy()
|
636
|
+
error_context.update(
|
637
|
+
{
|
638
|
+
"error_type": type(e).__name__,
|
639
|
+
"error_message": str(e),
|
640
|
+
"response_time_ms": round(request_duration * 1000, 2),
|
641
|
+
"retry_attempt": retry_manager.attempt,
|
642
|
+
"session_state": self._primary_state.value,
|
643
|
+
}
|
644
|
+
)
|
645
|
+
|
646
|
+
# Add response details if available
|
647
|
+
if hasattr(e, "response"):
|
648
|
+
response = e.response # type: ignore
|
649
|
+
if response is not None:
|
650
|
+
error_context.update(
|
651
|
+
{
|
652
|
+
"status_code": response.status_code,
|
653
|
+
"response_headers": dict(response.headers),
|
654
|
+
}
|
655
|
+
)
|
656
|
+
|
657
|
+
self.logger.error("Async HTTP request failed: %s", str(e), context=error_context, exc_info=True)
|
658
|
+
|
659
|
+
retry_manager.record_failure(e)
|
660
|
+
|
661
|
+
if retry_manager.should_retry(e):
|
662
|
+
retry_context = error_context.copy()
|
663
|
+
retry_context.update(
|
664
|
+
{
|
665
|
+
"will_retry": True,
|
666
|
+
"max_retries": self.retry_config.max_retries,
|
667
|
+
"retry_delay_ms": round(retry_manager.calculate_delay() * 1000, 2),
|
668
|
+
}
|
669
|
+
)
|
670
|
+
|
671
|
+
self.logger.warning(
|
672
|
+
"Retrying async request %s %s (attempt %d/%d): %s",
|
673
|
+
method,
|
674
|
+
url,
|
675
|
+
retry_manager.attempt,
|
676
|
+
self.retry_config.max_retries + 1,
|
677
|
+
str(e),
|
678
|
+
context=retry_context,
|
679
|
+
)
|
680
|
+
|
681
|
+
# Decrease request count before waiting
|
682
|
+
with self._lock:
|
683
|
+
self._primary_requests -= 1
|
684
|
+
|
685
|
+
await retry_manager.wait_async()
|
686
|
+
continue
|
687
|
+
else:
|
688
|
+
# Not retryable or max retries exceeded
|
689
|
+
final_error_context = error_context.copy()
|
690
|
+
final_error_context.update(
|
691
|
+
{
|
692
|
+
"final_failure": True,
|
693
|
+
"total_retry_attempts": retry_manager.attempt,
|
694
|
+
"is_retryable": retry_manager.should_retry(e)
|
695
|
+
if retry_manager.attempt < self.retry_config.max_retries
|
696
|
+
else False,
|
697
|
+
}
|
698
|
+
)
|
699
|
+
|
700
|
+
with self._lock:
|
701
|
+
if retry_manager.attempt > 0:
|
702
|
+
self._stats["retry_failures"] += 1
|
703
|
+
|
704
|
+
self.logger.error(
|
705
|
+
"Async HTTP request failed permanently: %s",
|
706
|
+
str(e),
|
707
|
+
context=final_error_context,
|
708
|
+
exc_info=True,
|
709
|
+
)
|
710
|
+
|
711
|
+
# Try failover to secondary session if available (only on final failure)
|
712
|
+
if self._secondary_state == SessionState.ACTIVE:
|
713
|
+
self.logger.info("Attempting failover to secondary session", context=final_error_context)
|
714
|
+
try:
|
715
|
+
# Decrease primary request count before failover
|
716
|
+
with self._lock:
|
717
|
+
self._primary_requests -= 1
|
718
|
+
|
719
|
+
return await self._failover_request(method, url, **kwargs)
|
720
|
+
except Exception as failover_error:
|
721
|
+
failover_context = final_error_context.copy()
|
722
|
+
failover_context.update(
|
723
|
+
{
|
724
|
+
"failover_error_type": type(failover_error).__name__,
|
725
|
+
"failover_error_message": str(failover_error),
|
726
|
+
}
|
727
|
+
)
|
728
|
+
|
729
|
+
self.logger.error(
|
730
|
+
"Failover attempt also failed: %s",
|
731
|
+
str(failover_error),
|
732
|
+
context=failover_context,
|
733
|
+
exc_info=True,
|
734
|
+
)
|
735
|
+
|
736
|
+
# If failover also fails, raise the original error with failover context
|
737
|
+
raise self._classify_async_error(method, url, e, kwargs) from e
|
738
|
+
|
739
|
+
# No failover available or didn't work, classify the error
|
740
|
+
raise self._classify_async_error(method, url, e, kwargs) from e
|
741
|
+
|
742
|
+
finally:
|
743
|
+
# Decrease request count (only if we're not retrying)
|
744
|
+
if not (retry_manager.last_exception and retry_manager.should_retry(retry_manager.last_exception)):
|
745
|
+
with self._lock:
|
746
|
+
self._primary_requests -= 1
|
747
|
+
|
748
|
+
def _classify_async_error(self, method: str, url: str, error: Exception, kwargs: dict) -> Exception:
|
749
|
+
"""Classify an async error into appropriate HTTP exception type"""
|
750
|
+
error_msg = str(error).lower()
|
751
|
+
if "timeout" in error_msg or "timed out" in error_msg:
|
752
|
+
# Extract timeout value if available from kwargs
|
753
|
+
timeout = kwargs.get("timeout", "unknown")
|
754
|
+
return HttpTimeoutError(method, url, timeout, original_error=error)
|
755
|
+
elif "connection" in error_msg or "resolve" in error_msg or "network" in error_msg:
|
756
|
+
return HttpConnectionError(method, url, original_error=error)
|
757
|
+
else:
|
758
|
+
# Get status code if available
|
759
|
+
status_code = None
|
760
|
+
response_text = None
|
761
|
+
if hasattr(error, "response"):
|
762
|
+
response = error.response # type: ignore
|
763
|
+
if response and hasattr(response, "status_code"):
|
764
|
+
status_code = response.status_code
|
765
|
+
if response and hasattr(response, "content"):
|
766
|
+
response_text = response.content[:200].decode("utf-8", errors="replace")
|
767
|
+
return HttpRequestError(method, url, status_code, response_text, original_error=error)
|
227
768
|
|
228
|
-
|
229
|
-
|
230
|
-
|
769
|
+
async def _failover_request(self, method: HttpMethod, url: str, **kwargs) -> Union[list, dict, None]:
|
770
|
+
"""Failover to secondary session - raises exceptions instead of returning None"""
|
771
|
+
if self._secondary_session and self._secondary_state == SessionState.ACTIVE:
|
772
|
+
failover_start = time.time()
|
231
773
|
|
232
|
-
|
774
|
+
failover_context = {
|
775
|
+
"method": method,
|
776
|
+
"url": url,
|
777
|
+
"failover_attempt": True,
|
778
|
+
"request_id": generate_correlation_id()[:8],
|
779
|
+
}
|
233
780
|
|
234
|
-
|
235
|
-
# Decrease request count
|
236
|
-
with self._lock:
|
237
|
-
self._primary_requests -= 1
|
781
|
+
self.logger.info("Executing async failover request", context=failover_context)
|
238
782
|
|
239
|
-
async def _failover_request(self, method: HttpMethod, url: str, **kwargs) -> Union[list, dict, None]:
|
240
|
-
"""Failover to secondary session"""
|
241
|
-
if self._secondary_session and self._secondary_state == SessionState.ACTIVE:
|
242
783
|
try:
|
243
784
|
with self._lock:
|
244
785
|
self._secondary_requests += 1
|
@@ -246,21 +787,61 @@ class HttpClientCffi:
|
|
246
787
|
response = await self._secondary_session.request(method, url, **kwargs) # type: ignore
|
247
788
|
response.raise_for_status()
|
248
789
|
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
790
|
+
failover_duration = time.time() - failover_start
|
791
|
+
|
792
|
+
failover_context.update(
|
793
|
+
{
|
794
|
+
"status_code": response.status_code,
|
795
|
+
"response_time_ms": round(failover_duration * 1000, 2),
|
796
|
+
"content_type": response.headers.get("content-type", "unknown"),
|
797
|
+
"success": True,
|
798
|
+
}
|
799
|
+
)
|
800
|
+
|
801
|
+
self.logger.info("Async failover request succeeded", context=failover_context)
|
802
|
+
|
803
|
+
# Parse JSON response
|
804
|
+
return self._parse_json_response(response, method, url, failover_context)
|
805
|
+
|
806
|
+
except HttpResponseParsingError:
|
807
|
+
# Re-raise our custom parsing errors as-is
|
808
|
+
raise
|
809
|
+
except Exception as e:
|
810
|
+
failover_duration = time.time() - failover_start
|
811
|
+
|
812
|
+
error_context = failover_context.copy()
|
813
|
+
error_context.update(
|
814
|
+
{
|
815
|
+
"error_type": type(e).__name__,
|
816
|
+
"error_message": str(e),
|
817
|
+
"response_time_ms": round(failover_duration * 1000, 2),
|
818
|
+
"success": False,
|
819
|
+
}
|
820
|
+
)
|
821
|
+
|
822
|
+
self.logger.error("Async failover request failed: %s", str(e), context=error_context, exc_info=True)
|
823
|
+
|
824
|
+
# Classify and raise the failover error
|
825
|
+
raise self._classify_async_error(method, url, e, kwargs) from e
|
256
826
|
finally:
|
257
827
|
with self._lock:
|
258
828
|
self._secondary_requests -= 1
|
259
829
|
|
260
|
-
|
830
|
+
# No secondary session available
|
831
|
+
raise HttpSessionError("No secondary session available for failover")
|
261
832
|
|
262
833
|
async def _perform_switch(self):
|
263
834
|
"""Perform hot switch between sessions"""
|
835
|
+
switch_context = {
|
836
|
+
"operation": "session_switch",
|
837
|
+
"primary_state": self._primary_state.value,
|
838
|
+
"secondary_state": self._secondary_state.value,
|
839
|
+
"primary_requests": self._primary_requests,
|
840
|
+
"secondary_requests": self._secondary_requests,
|
841
|
+
}
|
842
|
+
|
843
|
+
self.logger.info("Starting session switch", context=switch_context)
|
844
|
+
|
264
845
|
# 1. Promote secondary to active
|
265
846
|
self._secondary_state = SessionState.ACTIVE
|
266
847
|
|
@@ -280,25 +861,60 @@ class HttpClientCffi:
|
|
280
861
|
self._secondary_requests = old_primary_requests
|
281
862
|
self._secondary_state = SessionState.DRAINING
|
282
863
|
|
864
|
+
switch_context.update(
|
865
|
+
{
|
866
|
+
"switch_completed": True,
|
867
|
+
"new_primary_state": self._primary_state.value,
|
868
|
+
"new_secondary_state": self._secondary_state.value,
|
869
|
+
}
|
870
|
+
)
|
871
|
+
|
872
|
+
self.logger.info("Session switch completed", context=switch_context)
|
873
|
+
|
283
874
|
# 4. Async cleanup of old session
|
284
875
|
if old_primary:
|
285
876
|
asyncio.create_task(self._graceful_close_session(old_primary, lambda: self._secondary_requests))
|
286
877
|
|
287
878
|
async def _graceful_close_session(self, session: AsyncSession, get_request_count):
|
288
879
|
"""Gracefully close session after requests complete"""
|
880
|
+
close_context = {
|
881
|
+
"operation": "graceful_close",
|
882
|
+
"initial_request_count": get_request_count(),
|
883
|
+
}
|
884
|
+
|
885
|
+
self.logger.debug("Starting graceful session close", context=close_context)
|
886
|
+
|
289
887
|
# Wait for ongoing requests to complete (max 30 seconds)
|
290
888
|
start_time = datetime.now()
|
291
889
|
timeout = timedelta(seconds=30)
|
292
890
|
|
293
891
|
while get_request_count() > 0:
|
294
892
|
if datetime.now() - start_time > timeout:
|
893
|
+
close_context.update(
|
894
|
+
{
|
895
|
+
"timeout_reached": True,
|
896
|
+
"remaining_requests": get_request_count(),
|
897
|
+
}
|
898
|
+
)
|
899
|
+
|
900
|
+
self.logger.warning("Session close timeout reached", context=close_context)
|
295
901
|
break
|
296
902
|
|
297
903
|
await asyncio.sleep(0.1)
|
298
904
|
|
299
905
|
# Close session
|
300
|
-
|
906
|
+
try:
|
301
907
|
await session.close()
|
908
|
+
close_context.update({"close_successful": True})
|
909
|
+
self.logger.debug("Session closed successfully", context=close_context)
|
910
|
+
except Exception as e:
|
911
|
+
close_context.update(
|
912
|
+
{
|
913
|
+
"close_successful": False,
|
914
|
+
"close_error": str(e),
|
915
|
+
}
|
916
|
+
)
|
917
|
+
self.logger.warning("Error during session close", context=close_context)
|
302
918
|
|
303
919
|
def set_impersonate(self, browser: str):
|
304
920
|
"""
|
@@ -315,6 +931,14 @@ class HttpClientCffi:
|
|
315
931
|
- "firefox133", "firefox135", etc.: Specific Firefox versions
|
316
932
|
Note: "realworld" is replaced by our custom browser selector
|
317
933
|
"""
|
934
|
+
config_context = {
|
935
|
+
"operation": "set_impersonate",
|
936
|
+
"old_browser": self.client_kwargs.get("impersonate", "unknown"),
|
937
|
+
"new_browser": browser,
|
938
|
+
}
|
939
|
+
|
940
|
+
self.logger.info("Updating browser impersonation", context=config_context)
|
941
|
+
|
318
942
|
# Update client kwargs for future sessions
|
319
943
|
with self._lock:
|
320
944
|
self.client_kwargs["impersonate"] = browser
|
@@ -328,6 +952,14 @@ class HttpClientCffi:
|
|
328
952
|
new_kwargs: New configuration options
|
329
953
|
replace: If True, replace entire config. If False (default), merge with existing.
|
330
954
|
"""
|
955
|
+
config_context = {
|
956
|
+
"operation": "config_update",
|
957
|
+
"replace_mode": replace,
|
958
|
+
"new_config_keys": list(new_kwargs.keys()),
|
959
|
+
}
|
960
|
+
|
961
|
+
self.logger.info("Starting configuration update", context=config_context)
|
962
|
+
|
331
963
|
# Don't lock here - we want requests to continue
|
332
964
|
# Prepare new config
|
333
965
|
if replace:
|
@@ -338,27 +970,75 @@ class HttpClientCffi:
|
|
338
970
|
config = self.client_kwargs.copy()
|
339
971
|
config.update(new_kwargs)
|
340
972
|
|
341
|
-
#
|
342
|
-
if "
|
343
|
-
config.pop("proxy", None)
|
973
|
+
# Remove proxy if explicitly set to None
|
974
|
+
if "proxies" in new_kwargs and new_kwargs["proxies"] is None:
|
344
975
|
config.pop("proxies", None)
|
345
976
|
|
346
977
|
if "impersonate" not in config:
|
347
978
|
config["impersonate"] = get_random_browser()
|
348
979
|
|
349
980
|
# Create new session (secondary) without blocking
|
350
|
-
|
981
|
+
try:
|
982
|
+
new_session = AsyncSession(**config)
|
983
|
+
|
984
|
+
self.logger.debug("Created new session for config update", context=config_context)
|
985
|
+
except Exception as e:
|
986
|
+
error_context = config_context.copy()
|
987
|
+
error_context.update(
|
988
|
+
{
|
989
|
+
"error_type": type(e).__name__,
|
990
|
+
"error_message": str(e),
|
991
|
+
}
|
992
|
+
)
|
993
|
+
|
994
|
+
self.logger.error(
|
995
|
+
"Failed to create new session during config update: %s", str(e), context=error_context, exc_info=True
|
996
|
+
)
|
997
|
+
return
|
351
998
|
|
352
999
|
# Warm up new connection in background
|
1000
|
+
warmup_start = time.time()
|
353
1001
|
warmup_success = False
|
354
1002
|
try:
|
355
1003
|
warmup_url = self._create_absolute_url(self.warmup_url)
|
356
1004
|
response = await new_session.get(warmup_url)
|
1005
|
+
warmup_duration = time.time() - warmup_start
|
1006
|
+
|
357
1007
|
# Only consider warmup successful if we get 200 OK
|
358
1008
|
if response.status_code == 200:
|
359
1009
|
warmup_success = True
|
360
|
-
|
361
|
-
|
1010
|
+
|
1011
|
+
warmup_context = config_context.copy()
|
1012
|
+
warmup_context.update(
|
1013
|
+
{
|
1014
|
+
"warmup_success": True,
|
1015
|
+
"warmup_time_ms": round(warmup_duration * 1000, 2),
|
1016
|
+
"warmup_status": response.status_code,
|
1017
|
+
}
|
1018
|
+
)
|
1019
|
+
|
1020
|
+
self.logger.debug("Session warmup successful during config update", context=warmup_context)
|
1021
|
+
else:
|
1022
|
+
self.logger.warning(
|
1023
|
+
"Session warmup returned non-200 status during config update: %d",
|
1024
|
+
response.status_code,
|
1025
|
+
context=config_context,
|
1026
|
+
)
|
1027
|
+
|
1028
|
+
except Exception as e:
|
1029
|
+
warmup_duration = time.time() - warmup_start
|
1030
|
+
|
1031
|
+
warmup_context = config_context.copy()
|
1032
|
+
warmup_context.update(
|
1033
|
+
{
|
1034
|
+
"warmup_success": False,
|
1035
|
+
"warmup_time_ms": round(warmup_duration * 1000, 2),
|
1036
|
+
"error_type": type(e).__name__,
|
1037
|
+
"error_message": str(e),
|
1038
|
+
}
|
1039
|
+
)
|
1040
|
+
|
1041
|
+
self.logger.warning("Session warmup failed during config update: %s", str(e), context=warmup_context)
|
362
1042
|
|
363
1043
|
# Only proceed with switch if warmup was successful
|
364
1044
|
if warmup_success:
|
@@ -378,11 +1058,23 @@ class HttpClientCffi:
|
|
378
1058
|
with self._lock:
|
379
1059
|
self._stats["switches"] += 1
|
380
1060
|
self._stats["last_switch"] = datetime.now()
|
1061
|
+
|
1062
|
+
switch_context = config_context.copy()
|
1063
|
+
switch_context.update(
|
1064
|
+
{
|
1065
|
+
"switch_successful": True,
|
1066
|
+
"total_switches": self._stats["switches"],
|
1067
|
+
}
|
1068
|
+
)
|
1069
|
+
|
1070
|
+
self.logger.info("Configuration update and session switch completed", context=switch_context)
|
381
1071
|
else:
|
382
1072
|
# Clean up failed session
|
383
1073
|
with contextlib.suppress(Exception):
|
384
1074
|
await new_session.close()
|
385
1075
|
|
1076
|
+
self.logger.error("Configuration update failed due to warmup failure", context=config_context)
|
1077
|
+
|
386
1078
|
def update_client_kwargs(self, new_kwargs: dict[str, Any], merge: bool = True):
|
387
1079
|
"""
|
388
1080
|
Update client configuration at runtime.
|
@@ -390,24 +1082,15 @@ class HttpClientCffi:
|
|
390
1082
|
Args:
|
391
1083
|
new_kwargs: New configuration options to apply
|
392
1084
|
merge: If True, merge with existing kwargs. If False, replace entirely.
|
1085
|
+
"""
|
1086
|
+
config_context = {
|
1087
|
+
"operation": "update_client_kwargs",
|
1088
|
+
"merge_mode": merge,
|
1089
|
+
"new_config_keys": list(new_kwargs.keys()),
|
1090
|
+
}
|
393
1091
|
|
394
|
-
|
395
|
-
# Update proxy
|
396
|
-
client.update_client_kwargs({"proxies": {"https": "http://new-proxy:8080"}})
|
397
|
-
|
398
|
-
# Change impersonation
|
399
|
-
client.update_client_kwargs({"impersonate": "safari184"})
|
400
|
-
|
401
|
-
# Add custom headers
|
402
|
-
client.update_client_kwargs({"headers": {"X-Custom": "value"}})
|
1092
|
+
self.logger.debug("Updating client kwargs", context=config_context)
|
403
1093
|
|
404
|
-
# Replace all kwargs
|
405
|
-
client.update_client_kwargs({
|
406
|
-
"impersonate": "firefox135",
|
407
|
-
"timeout": 30,
|
408
|
-
"verify": False
|
409
|
-
}, merge=False)
|
410
|
-
"""
|
411
1094
|
with self._lock:
|
412
1095
|
if merge:
|
413
1096
|
self.client_kwargs.update(new_kwargs)
|
@@ -430,18 +1113,68 @@ class HttpClientCffi:
|
|
430
1113
|
|
431
1114
|
def get_stats(self) -> dict[str, Any]:
|
432
1115
|
"""
|
433
|
-
Get statistics.
|
1116
|
+
Get statistics including enhanced timing metrics.
|
1117
|
+
|
1118
|
+
Returns:
|
1119
|
+
Statistics dictionary with switches, requests, timing data, etc.
|
1120
|
+
"""
|
1121
|
+
with self._lock:
|
1122
|
+
stats = self._stats.copy()
|
1123
|
+
# Calculate additional metrics
|
1124
|
+
if stats["total_requests"] > 0:
|
1125
|
+
stats["success_rate"] = stats["successful_requests"] / stats["total_requests"]
|
1126
|
+
stats["failure_rate"] = stats["failed_requests"] / stats["total_requests"]
|
1127
|
+
if stats["retry_attempts"] > 0:
|
1128
|
+
stats["retry_success_rate"] = stats["retry_successes"] / stats["retry_attempts"]
|
1129
|
+
else:
|
1130
|
+
stats["retry_success_rate"] = 0.0
|
1131
|
+
else:
|
1132
|
+
stats["success_rate"] = 0.0
|
1133
|
+
stats["failure_rate"] = 0.0
|
1134
|
+
stats["retry_success_rate"] = 0.0
|
1135
|
+
|
1136
|
+
return stats
|
1137
|
+
|
1138
|
+
def update_retry_config(self, retry_config: RetryConfig):
|
1139
|
+
"""
|
1140
|
+
Update retry configuration at runtime.
|
1141
|
+
|
1142
|
+
Args:
|
1143
|
+
retry_config: New retry configuration
|
1144
|
+
"""
|
1145
|
+
config_context = {
|
1146
|
+
"operation": "update_retry_config",
|
1147
|
+
"max_retries": retry_config.max_retries,
|
1148
|
+
"base_delay": retry_config.base_delay,
|
1149
|
+
}
|
1150
|
+
|
1151
|
+
self.logger.debug("Updating retry configuration", context=config_context)
|
1152
|
+
|
1153
|
+
with self._lock:
|
1154
|
+
self.retry_config = retry_config
|
1155
|
+
|
1156
|
+
def get_retry_config(self) -> RetryConfig:
|
1157
|
+
"""
|
1158
|
+
Get current retry configuration.
|
434
1159
|
|
435
1160
|
Returns:
|
436
|
-
|
1161
|
+
Current retry configuration
|
437
1162
|
"""
|
438
1163
|
with self._lock:
|
439
|
-
return self.
|
1164
|
+
return self.retry_config
|
440
1165
|
|
441
1166
|
async def close(self):
|
442
1167
|
"""
|
443
1168
|
Close all sessions gracefully.
|
444
1169
|
"""
|
1170
|
+
close_context = {
|
1171
|
+
"operation": "close_all_sessions",
|
1172
|
+
"primary_state": self._primary_state.value if self._primary_state else "none",
|
1173
|
+
"secondary_state": self._secondary_state.value if self._secondary_state else "none",
|
1174
|
+
}
|
1175
|
+
|
1176
|
+
self.logger.info("Closing all HTTP sessions", context=close_context)
|
1177
|
+
|
445
1178
|
tasks = []
|
446
1179
|
|
447
1180
|
if self._primary_session:
|
@@ -457,4 +1190,15 @@ class HttpClientCffi:
|
|
457
1190
|
self._sync_secondary.close()
|
458
1191
|
|
459
1192
|
if tasks:
|
460
|
-
|
1193
|
+
try:
|
1194
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
1195
|
+
close_context.update({"async_close_successful": "true"})
|
1196
|
+
except Exception as e:
|
1197
|
+
close_context.update(
|
1198
|
+
{
|
1199
|
+
"async_close_successful": "false",
|
1200
|
+
"close_error": str(e),
|
1201
|
+
}
|
1202
|
+
)
|
1203
|
+
|
1204
|
+
self.logger.info("HTTP sessions closed", context=close_context)
|