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/core/http.py CHANGED
@@ -1,9 +1,10 @@
1
1
  """
2
- Enhanced with realworld browser impersonation and custom configuration support
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
- # Statistics
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
- # If primary session is not active, create it
113
- if self._primary_state != SessionState.ACTIVE and self._primary_session is None:
114
- self._primary_session = AsyncSession(**self.client_kwargs)
115
- # Warm up connection
116
- warmup_success = False
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
- warmup_url = self._create_absolute_url(self.warmup_url)
119
- response = await self._primary_session.get(warmup_url)
120
- if response.status_code == 200:
121
- warmup_success = True
122
- except Exception:
123
- pass # Warmup failure doesn't affect usage
124
-
125
- # Only activate if warmup succeeded
126
- if warmup_success:
127
- self._primary_state = SessionState.ACTIVE
128
- else:
129
- # Keep trying with the session even if warmup failed
130
- # This maintains backward compatibility
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
- self._sync_primary = Session(**self.client_kwargs)
142
- # Warm up
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
- warmup_url = self._create_absolute_url(self.warmup_url)
145
- response = self._sync_primary.get(warmup_url)
146
- # Check if warmup was successful
147
- if response.status_code != 200:
148
- pass # Log warning in production
149
- except Exception:
150
- pass
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
- response = session.request(method, url, **kwargs) # type: ignore
173
- response.raise_for_status()
174
-
175
- # Check if response is JSON
176
- content_type = response.headers.get("content-type", "")
177
- if "application/json" in content_type:
178
- # Use orjson for better performance
179
- return orjson.loads(response.content)
180
- else:
181
- # Non-JSON response (e.g., HTML error page)
182
- return None
183
- except Exception:
184
- return None
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 with self._limiter:
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
- # Statistics
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._stats["successful_requests"] += 1
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
- except Exception:
225
- with self._lock:
226
- self._stats["failed_requests"] += 1
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
- # Try failover to secondary session if available
229
- if self._secondary_state == SessionState.ACTIVE:
230
- return await self._failover_request(method, url, **kwargs)
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
- return None
774
+ failover_context = {
775
+ "method": method,
776
+ "url": url,
777
+ "failover_attempt": True,
778
+ "request_id": generate_correlation_id()[:8],
779
+ }
233
780
 
234
- finally:
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
- content_type = response.headers.get("content-type", "")
250
- if "application/json" in content_type:
251
- # Use orjson for better performance
252
- return orjson.loads(response.content)
253
- else:
254
- return None
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
- return None
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
- with contextlib.suppress(Exception):
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
- # Handle special case: if proxy is None, remove it
342
- if "proxy" in new_kwargs and new_kwargs["proxy"] is None:
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
- new_session = AsyncSession(**config)
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
- except Exception:
361
- pass
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
- Example:
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
- Statistics dictionary with switches, requests, etc.
1161
+ Current retry configuration
437
1162
  """
438
1163
  with self._lock:
439
- return self._stats.copy()
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
- await asyncio.gather(*tasks, return_exceptions=True)
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)