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.
@@ -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,3 @@
1
+ from .base import TransportBase
2
+
3
+ __all__ = ["TransportBase"]
@@ -0,0 +1,9 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+ from ..tasks import RequestTask
4
+
5
+
6
+ class TransportBase(ABC):
7
+ @abstractmethod
8
+ def send(self, task: RequestTask):
9
+ """Send a RequestTask and return a response-like object or raise."""
@@ -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