robotframework-parallel-requests 0.1.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.
- robot_parallel_requests/__init__.py +24 -0
- robot_parallel_requests/library.py +513 -0
- robot_parallel_requests/metrics.py +95 -0
- robot_parallel_requests/rate_limiter.py +76 -0
- robot_parallel_requests/response_store.py +33 -0
- robot_parallel_requests/retry.py +106 -0
- robot_parallel_requests/session.py +27 -0
- robot_parallel_requests/tasks.py +18 -0
- robot_parallel_requests/transport/__init__.py +3 -0
- robot_parallel_requests/transport/base.py +9 -0
- robot_parallel_requests/transport/httpx_async_future.py +82 -0
- robot_parallel_requests/transport/httpx_sync.py +53 -0
- robot_parallel_requests/worker.py +136 -0
- robotframework_parallel_requests-0.1.0.dist-info/METADATA +524 -0
- robotframework_parallel_requests-0.1.0.dist-info/RECORD +18 -0
- robotframework_parallel_requests-0.1.0.dist-info/WHEEL +5 -0
- robotframework_parallel_requests-0.1.0.dist-info/licenses/LICENSE +21 -0
- robotframework_parallel_requests-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Rate limiting utilities using token bucket algorithm."""
|
|
2
|
+
import time
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenBucket:
|
|
8
|
+
"""Token bucket rate limiter for controlling request rates."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, rate: float, burst_size: int):
|
|
11
|
+
"""
|
|
12
|
+
Initialize token bucket.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
rate: Tokens per second (e.g., 1.75 for 105 requests/minute)
|
|
16
|
+
burst_size: Maximum tokens that can accumulate
|
|
17
|
+
"""
|
|
18
|
+
if rate <= 0:
|
|
19
|
+
raise ValueError(f"Rate must be positive, got: {rate}")
|
|
20
|
+
if burst_size <= 0:
|
|
21
|
+
raise ValueError(f"Burst size must be positive, got: {burst_size}")
|
|
22
|
+
|
|
23
|
+
self.rate = rate
|
|
24
|
+
self.burst_size = burst_size
|
|
25
|
+
self.tokens = float(self.burst_size)
|
|
26
|
+
self.last_update = time.time()
|
|
27
|
+
self.lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def acquire(self, tokens: int = 1, blocking: bool = True, timeout: Optional[float] = None) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Acquire tokens from the bucket.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
tokens: Number of tokens to acquire
|
|
35
|
+
blocking: If True, wait until tokens available
|
|
36
|
+
timeout: Maximum time to wait if blocking
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True if tokens acquired, False otherwise
|
|
40
|
+
"""
|
|
41
|
+
if self.rate <= 0:
|
|
42
|
+
raise ValueError("Cannot acquire tokens when rate is zero or negative")
|
|
43
|
+
|
|
44
|
+
deadline = None if timeout is None else time.time() + timeout
|
|
45
|
+
|
|
46
|
+
while True:
|
|
47
|
+
with self.lock:
|
|
48
|
+
now = time.time()
|
|
49
|
+
elapsed = now - self.last_update
|
|
50
|
+
|
|
51
|
+
# Add tokens based on elapsed time
|
|
52
|
+
self.tokens = min(self.burst_size, self.tokens + elapsed * self.rate)
|
|
53
|
+
self.last_update = now
|
|
54
|
+
|
|
55
|
+
if self.tokens >= tokens:
|
|
56
|
+
self.tokens -= tokens
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
if not blocking:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
if deadline and now >= deadline:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Calculate wait time
|
|
66
|
+
tokens_needed = tokens - self.tokens
|
|
67
|
+
wait_time = tokens_needed / self.rate
|
|
68
|
+
|
|
69
|
+
# Release lock while sleeping
|
|
70
|
+
if deadline:
|
|
71
|
+
wait_time = min(wait_time, deadline - time.time())
|
|
72
|
+
|
|
73
|
+
if wait_time > 0:
|
|
74
|
+
time.sleep(wait_time)
|
|
75
|
+
else:
|
|
76
|
+
return False
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from threading import Lock
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ResponseStore:
|
|
6
|
+
"""Simple in-memory store for responses and exceptions keyed by id."""
|
|
7
|
+
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._responses: Dict[str, Any] = {}
|
|
10
|
+
self._lock = Lock()
|
|
11
|
+
|
|
12
|
+
def set_response(self, id: str, value: Any) -> None:
|
|
13
|
+
with self._lock:
|
|
14
|
+
self._responses[id] = value
|
|
15
|
+
|
|
16
|
+
def get(self, id: str) -> Optional[Any]:
|
|
17
|
+
with self._lock:
|
|
18
|
+
return self._responses.get(id)
|
|
19
|
+
|
|
20
|
+
def has(self, id: str) -> bool:
|
|
21
|
+
with self._lock:
|
|
22
|
+
return id in self._responses
|
|
23
|
+
|
|
24
|
+
def __contains__(self, id: str) -> bool:
|
|
25
|
+
return self.has(id)
|
|
26
|
+
|
|
27
|
+
def all_ids(self):
|
|
28
|
+
with self._lock:
|
|
29
|
+
return list(self._responses.keys())
|
|
30
|
+
|
|
31
|
+
def clear(self):
|
|
32
|
+
with self._lock:
|
|
33
|
+
self._responses.clear()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Retry logic with exponential backoff and jitter."""
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from typing import List, Optional, Callable, Any, Tuple
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Transport-level failures that are usually safe to retry by default.
|
|
11
|
+
DEFAULT_RETRY_EXCEPTIONS: List[type] = [
|
|
12
|
+
httpx.TimeoutException,
|
|
13
|
+
httpx.NetworkError,
|
|
14
|
+
httpx.RemoteProtocolError,
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RetryPolicy:
|
|
20
|
+
"""Configuration for retry behavior with exponential backoff."""
|
|
21
|
+
max_retries: int = 3
|
|
22
|
+
backoff_factor: float = 2.0
|
|
23
|
+
retry_statuses: Optional[List[int]] = None
|
|
24
|
+
retry_exceptions: Optional[List[type]] = None
|
|
25
|
+
jitter: float = 0.1 # Fraction of wait time added as random jitter (0 disables)
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if self.retry_statuses is None:
|
|
29
|
+
self.retry_statuses = [429, 500, 502, 503, 504]
|
|
30
|
+
if self.retry_exceptions is None:
|
|
31
|
+
self.retry_exceptions = list(DEFAULT_RETRY_EXCEPTIONS)
|
|
32
|
+
if self.jitter < 0:
|
|
33
|
+
raise ValueError(f"jitter must be >= 0, got: {self.jitter}")
|
|
34
|
+
|
|
35
|
+
def should_retry(
|
|
36
|
+
self,
|
|
37
|
+
attempt: int,
|
|
38
|
+
status_code: Optional[int] = None,
|
|
39
|
+
exception: Optional[Exception] = None,
|
|
40
|
+
) -> bool:
|
|
41
|
+
"""Determine if a request should be retried."""
|
|
42
|
+
if attempt >= self.max_retries:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
if status_code and status_code in self.retry_statuses:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
if exception and any(isinstance(exception, exc_type) for exc_type in self.retry_exceptions):
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def get_wait_time(self, attempt: int) -> float:
|
|
54
|
+
"""Calculate wait time before retry using exponential backoff + jitter."""
|
|
55
|
+
base = self.backoff_factor ** attempt
|
|
56
|
+
if self.jitter <= 0:
|
|
57
|
+
return base
|
|
58
|
+
return base + (random.random() * self.jitter * base)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def retry_with_backoff(func: Callable, policy: RetryPolicy, *args, **kwargs) -> Tuple[Any, int]:
|
|
62
|
+
"""
|
|
63
|
+
Execute function with retry and exponential backoff.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
func: Function to execute
|
|
67
|
+
policy: Retry policy configuration
|
|
68
|
+
*args: Positional arguments for func
|
|
69
|
+
**kwargs: Keyword arguments for func
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Tuple of (result, retry_count) where retry_count is the number of re-attempts
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
Last exception if all retries exhausted
|
|
76
|
+
"""
|
|
77
|
+
attempt = 0
|
|
78
|
+
last_exception = None
|
|
79
|
+
|
|
80
|
+
while attempt <= policy.max_retries:
|
|
81
|
+
try:
|
|
82
|
+
result = func(*args, **kwargs)
|
|
83
|
+
|
|
84
|
+
# Check if result has status_code (httpx.Response)
|
|
85
|
+
if hasattr(result, 'status_code'):
|
|
86
|
+
if policy.should_retry(attempt, status_code=result.status_code):
|
|
87
|
+
wait_time = policy.get_wait_time(attempt)
|
|
88
|
+
time.sleep(wait_time)
|
|
89
|
+
attempt += 1
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
return result, attempt
|
|
93
|
+
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
last_exception = exc
|
|
96
|
+
if policy.should_retry(attempt, exception=exc):
|
|
97
|
+
wait_time = policy.get_wait_time(attempt)
|
|
98
|
+
time.sleep(wait_time)
|
|
99
|
+
attempt += 1
|
|
100
|
+
continue
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
# If we exhausted retries, raise last exception or return last result
|
|
104
|
+
if last_exception:
|
|
105
|
+
raise last_exception
|
|
106
|
+
raise RuntimeError("Retry loop exhausted without a result or exception")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Session management for parallel requests."""
|
|
2
|
+
from typing import Optional, Dict
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Session:
|
|
8
|
+
"""Represents a named session with base URL and default headers."""
|
|
9
|
+
alias: str
|
|
10
|
+
base_url: Optional[str] = None
|
|
11
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
def resolve_url(self, url: str) -> str:
|
|
14
|
+
"""Resolve URL against base_url if relative."""
|
|
15
|
+
if self.base_url and not url.startswith(('http://', 'https://')):
|
|
16
|
+
# Remove leading slash from url if base_url ends with slash
|
|
17
|
+
base = self.base_url.rstrip('/')
|
|
18
|
+
url_part = url.lstrip('/')
|
|
19
|
+
return f"{base}/{url_part}"
|
|
20
|
+
return url
|
|
21
|
+
|
|
22
|
+
def merge_headers(self, request_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
23
|
+
"""Merge session headers with request-specific headers."""
|
|
24
|
+
merged = self.headers.copy()
|
|
25
|
+
if request_headers:
|
|
26
|
+
merged.update(request_headers)
|
|
27
|
+
return merged
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .rate_limiter import TokenBucket
|
|
7
|
+
from .retry import RetryPolicy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RequestTask:
|
|
12
|
+
method: str
|
|
13
|
+
url: str
|
|
14
|
+
kwargs: Dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
16
|
+
session_name: Optional[str] = None
|
|
17
|
+
rate_limiter: Optional['TokenBucket'] = None
|
|
18
|
+
retry_policy: Optional['RetryPolicy'] = None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Skeleton for future async httpx transport.
|
|
2
|
+
|
|
3
|
+
This module shows how to add high-concurrency async support later.
|
|
4
|
+
Not implemented in MVP, but provides a roadmap for v1.2+.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# from typing import Any
|
|
8
|
+
# import httpx
|
|
9
|
+
# import asyncio
|
|
10
|
+
# from .tasks import RequestTask
|
|
11
|
+
# from .transport.base import TransportBase
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# class HttpxAsyncTransport(TransportBase):
|
|
15
|
+
# """Async httpx transport for high-concurrency scenarios (10k+).
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# transport = HttpxAsyncTransport()
|
|
19
|
+
# worker = AsyncWorkerPool(transport, max_workers=1000)
|
|
20
|
+
# # or provide an event loop runner
|
|
21
|
+
# """
|
|
22
|
+
#
|
|
23
|
+
# def __init__(self, timeout: float = 30.0):
|
|
24
|
+
# self._client = httpx.AsyncClient(timeout=timeout)
|
|
25
|
+
#
|
|
26
|
+
# async def send(self, task: RequestTask) -> Any:
|
|
27
|
+
# """Send async request and return httpx.Response."""
|
|
28
|
+
# resp = await self._client.request(task.method, task.url, **task.kwargs)
|
|
29
|
+
# return resp
|
|
30
|
+
#
|
|
31
|
+
# async def close(self):
|
|
32
|
+
# await self._client.aclose()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# class AsyncWorkerPool:
|
|
36
|
+
# """Asyncio-based worker pool for high-concurrency.
|
|
37
|
+
#
|
|
38
|
+
# Manages an event loop and dispatches coroutines to handle requests.
|
|
39
|
+
# """
|
|
40
|
+
#
|
|
41
|
+
# def __init__(self, transport: HttpxAsyncTransport, max_workers: int = 1000):
|
|
42
|
+
# self.transport = transport
|
|
43
|
+
# self.max_workers = max_workers
|
|
44
|
+
# self._event_loop = None # Managed separately or in background thread
|
|
45
|
+
# self._tasks = []
|
|
46
|
+
#
|
|
47
|
+
# def submit(self, task: RequestTask) -> str:
|
|
48
|
+
# """Queue a task for async execution."""
|
|
49
|
+
# coro = self.transport.send(task)
|
|
50
|
+
# # Schedule on event loop (implementation TBD)
|
|
51
|
+
# return task.id
|
|
52
|
+
#
|
|
53
|
+
# async def wait_all_async(self, timeout: float = None):
|
|
54
|
+
# """Await all pending coroutines."""
|
|
55
|
+
# pass
|
|
56
|
+
#
|
|
57
|
+
# def shutdown(self):
|
|
58
|
+
# """Cleanup event loop and transport."""
|
|
59
|
+
# pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# STRATEGY FOR BRIDGING TO ROBOT:
|
|
63
|
+
#
|
|
64
|
+
# Since Robot Framework keywords are synchronous, we'd need to:
|
|
65
|
+
# 1. Run the async event loop in a background thread
|
|
66
|
+
# 2. Submit tasks to it from keywords (thread-safe queue)
|
|
67
|
+
# 3. Use asyncio.run_coroutine_threadsafe() for thread-safe calls
|
|
68
|
+
# 4. or use trio/curio for better integration
|
|
69
|
+
#
|
|
70
|
+
# Example:
|
|
71
|
+
# def __init__(self):
|
|
72
|
+
# self.loop = asyncio.new_event_loop()
|
|
73
|
+
# self.thread = threading.Thread(target=self.loop.run_forever, daemon=True)
|
|
74
|
+
# self.thread.start()
|
|
75
|
+
#
|
|
76
|
+
# def submit(self, task):
|
|
77
|
+
# future = asyncio.run_coroutine_threadsafe(
|
|
78
|
+
# self._send_async(task),
|
|
79
|
+
# self.loop
|
|
80
|
+
# )
|
|
81
|
+
# self._futures[task.id] = future
|
|
82
|
+
# return task.id
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from ..tasks import RequestTask
|
|
4
|
+
from .base import TransportBase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def limits_for_workers(max_workers: int) -> httpx.Limits:
|
|
8
|
+
"""Size the connection pool so workers are not starved waiting for sockets."""
|
|
9
|
+
workers = max(1, int(max_workers))
|
|
10
|
+
# Keepalive slightly above workers so a rolling set of warm connections is available.
|
|
11
|
+
keepalive = max(workers, min(workers + 5, workers * 2))
|
|
12
|
+
max_connections = max(workers * 2, keepalive)
|
|
13
|
+
return httpx.Limits(
|
|
14
|
+
max_connections=max_connections,
|
|
15
|
+
max_keepalive_connections=keepalive,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpxSyncTransport(TransportBase):
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
timeout: float = 30.0,
|
|
23
|
+
max_workers: int = 5,
|
|
24
|
+
limits: Optional[httpx.Limits] = None,
|
|
25
|
+
http2: bool = False,
|
|
26
|
+
):
|
|
27
|
+
self.max_workers = max(1, int(max_workers))
|
|
28
|
+
self.http2 = bool(http2)
|
|
29
|
+
self._limits = limits if limits is not None else limits_for_workers(self.max_workers)
|
|
30
|
+
try:
|
|
31
|
+
self._client = httpx.Client(
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
limits=self._limits,
|
|
34
|
+
http2=self.http2,
|
|
35
|
+
)
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
if self.http2:
|
|
38
|
+
raise ImportError(
|
|
39
|
+
"HTTP/2 support requires the optional 'h2' package. "
|
|
40
|
+
"Install with: pip install 'robotframework-parallel-requests[http2]'"
|
|
41
|
+
) from exc
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
def send(self, task: RequestTask) -> Any:
|
|
45
|
+
# task.kwargs expected to be compatible with httpx.Client.request
|
|
46
|
+
resp = self._client.request(task.method, task.url, **task.kwargs)
|
|
47
|
+
return resp
|
|
48
|
+
|
|
49
|
+
def close(self):
|
|
50
|
+
try:
|
|
51
|
+
self._client.close()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor, Future, wait, ALL_COMPLETED
|
|
2
|
+
from typing import Optional, List, Tuple
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from .tasks import RequestTask
|
|
6
|
+
from .response_store import ResponseStore
|
|
7
|
+
from .metrics import MetricsCollector, RequestMetric
|
|
8
|
+
from .retry import retry_with_backoff
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WorkerPool:
|
|
12
|
+
def __init__(self, transport, max_workers: int = 5, metrics_collector: Optional[MetricsCollector] = None):
|
|
13
|
+
self.transport = transport
|
|
14
|
+
self.max_workers = max_workers
|
|
15
|
+
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
|
16
|
+
self._store = ResponseStore()
|
|
17
|
+
self._futures: dict[str, Future] = {}
|
|
18
|
+
self._pending_ids: List[str] = []
|
|
19
|
+
self.metrics_collector = metrics_collector
|
|
20
|
+
|
|
21
|
+
def submit(self, task: RequestTask) -> str:
|
|
22
|
+
if task.id in self._futures or self._store.has(task.id):
|
|
23
|
+
raise ValueError(f"Duplicate request id: {task.id}")
|
|
24
|
+
|
|
25
|
+
future = self._executor.submit(self._run_task, task)
|
|
26
|
+
self._futures[task.id] = future
|
|
27
|
+
self._pending_ids.append(task.id)
|
|
28
|
+
return task.id
|
|
29
|
+
|
|
30
|
+
def _send_once(self, task: RequestTask):
|
|
31
|
+
if task.rate_limiter:
|
|
32
|
+
task.rate_limiter.acquire()
|
|
33
|
+
return self.transport.send(task)
|
|
34
|
+
|
|
35
|
+
def _run_task(self, task: RequestTask):
|
|
36
|
+
start_time = time.perf_counter()
|
|
37
|
+
wall_start = time.time()
|
|
38
|
+
metric = RequestMetric(
|
|
39
|
+
request_id=task.id,
|
|
40
|
+
method=task.method,
|
|
41
|
+
url=task.url,
|
|
42
|
+
timestamp=wall_start,
|
|
43
|
+
)
|
|
44
|
+
retry_count = 0
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
if task.retry_policy:
|
|
48
|
+
resp, retry_count = retry_with_backoff(
|
|
49
|
+
self._send_once, task.retry_policy, task
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
resp = self._send_once(task)
|
|
53
|
+
|
|
54
|
+
metric.duration = time.perf_counter() - start_time
|
|
55
|
+
metric.retries = retry_count
|
|
56
|
+
|
|
57
|
+
if hasattr(resp, 'status_code'):
|
|
58
|
+
metric.status_code = resp.status_code
|
|
59
|
+
|
|
60
|
+
self._store.set_response(task.id, resp)
|
|
61
|
+
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
metric.duration = time.perf_counter() - start_time
|
|
64
|
+
metric.retries = retry_count
|
|
65
|
+
metric.error = str(exc)
|
|
66
|
+
self._store.set_response(task.id, exc)
|
|
67
|
+
|
|
68
|
+
finally:
|
|
69
|
+
metric.completed_at = time.time()
|
|
70
|
+
if self.metrics_collector:
|
|
71
|
+
self.metrics_collector.record_request(metric)
|
|
72
|
+
|
|
73
|
+
def get_response(self, id: str):
|
|
74
|
+
if id not in self._futures and not self._store.has(id):
|
|
75
|
+
raise KeyError(
|
|
76
|
+
f"Unknown response id: {id!r}. Queue a request first or check the id."
|
|
77
|
+
)
|
|
78
|
+
if not self._store.has(id):
|
|
79
|
+
future = self._futures.get(id)
|
|
80
|
+
if future is not None and not future.done():
|
|
81
|
+
raise LookupError(
|
|
82
|
+
f"Response for id {id!r} is not ready yet. "
|
|
83
|
+
f"Call Parallel Wait For All Requests first."
|
|
84
|
+
)
|
|
85
|
+
raise LookupError(f"No response stored for id: {id!r}")
|
|
86
|
+
return self._store.get(id)
|
|
87
|
+
|
|
88
|
+
def get_responses_in_order(self, ids: List[str]) -> list:
|
|
89
|
+
# Soft lookup: incomplete timed-out ids may still be absent from the store.
|
|
90
|
+
return [self._store.get(request_id) for request_id in ids]
|
|
91
|
+
|
|
92
|
+
def wait_all(
|
|
93
|
+
self,
|
|
94
|
+
timeout: Optional[float] = None,
|
|
95
|
+
cancel_pending: bool = True,
|
|
96
|
+
) -> Tuple[int, int, List[str]]:
|
|
97
|
+
"""Wait for pending requests. Returns (completed_count, incomplete_count, batch_ids)."""
|
|
98
|
+
if timeout is not None and not isinstance(timeout, float):
|
|
99
|
+
try:
|
|
100
|
+
timeout = float(timeout)
|
|
101
|
+
except Exception:
|
|
102
|
+
raise ValueError(f"Timeout must be a float or convertible to float, got: {timeout!r}")
|
|
103
|
+
|
|
104
|
+
batch_ids = list(self._pending_ids)
|
|
105
|
+
if not batch_ids:
|
|
106
|
+
return 0, 0, batch_ids
|
|
107
|
+
|
|
108
|
+
pending_futures = [self._futures[request_id] for request_id in batch_ids]
|
|
109
|
+
done, not_done = wait(pending_futures, timeout=timeout, return_when=ALL_COMPLETED)
|
|
110
|
+
|
|
111
|
+
if cancel_pending and not_done:
|
|
112
|
+
for future in not_done:
|
|
113
|
+
future.cancel()
|
|
114
|
+
|
|
115
|
+
completed_count = len(done)
|
|
116
|
+
incomplete_count = len(not_done)
|
|
117
|
+
self._pending_ids.clear()
|
|
118
|
+
|
|
119
|
+
return completed_count, incomplete_count, batch_ids
|
|
120
|
+
|
|
121
|
+
def shutdown(self):
|
|
122
|
+
try:
|
|
123
|
+
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
124
|
+
except TypeError:
|
|
125
|
+
# cancel_futures added in Python 3.9
|
|
126
|
+
try:
|
|
127
|
+
self._executor.shutdown(wait=True)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
try:
|
|
133
|
+
if hasattr(self.transport, "close"):
|
|
134
|
+
self.transport.close()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|