resilient-httpx 0.1.0__tar.gz
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.
- resilient_httpx-0.1.0/.gitignore +12 -0
- resilient_httpx-0.1.0/LICENSE +21 -0
- resilient_httpx-0.1.0/PKG-INFO +135 -0
- resilient_httpx-0.1.0/README.md +104 -0
- resilient_httpx-0.1.0/pyproject.toml +50 -0
- resilient_httpx-0.1.0/src/resilient_httpx/__init__.py +12 -0
- resilient_httpx-0.1.0/src/resilient_httpx/client.py +205 -0
- resilient_httpx-0.1.0/src/resilient_httpx/exceptions.py +6 -0
- resilient_httpx-0.1.0/src/resilient_httpx/pool.py +71 -0
- resilient_httpx-0.1.0/src/resilient_httpx/retry.py +52 -0
- resilient_httpx-0.1.0/tests/__init__.py +0 -0
- resilient_httpx-0.1.0/tests/conftest.py +10 -0
- resilient_httpx-0.1.0/tests/test_client.py +154 -0
- resilient_httpx-0.1.0/tests/test_named_pools.py +119 -0
- resilient_httpx-0.1.0/tests/test_pool.py +95 -0
- resilient_httpx-0.1.0/tests/test_retry.py +46 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 galthran-wq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: resilient-httpx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async HTTP client with retry, backoff, and proxy rotation
|
|
5
|
+
Project-URL: Homepage, https://github.com/galthran-wq/resilient-httpx
|
|
6
|
+
Project-URL: Repository, https://github.com/galthran-wq/resilient-httpx
|
|
7
|
+
Project-URL: Issues, https://github.com/galthran-wq/resilient-httpx/issues
|
|
8
|
+
Author: galthran-wq
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx>=0.28.0
|
|
22
|
+
Requires-Dist: structlog>=25.0.0
|
|
23
|
+
Requires-Dist: tenacity>=9.1.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# resilient-httpx
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
Async HTTP client with retry, backoff, and proxy rotation. Built on top of `httpx`, `tenacity`, and `structlog`.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install git+https://github.com/galthran-wq/resilient-httpx.git
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Basic (no proxies)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from resilient_httpx import ProxyHttpClient, RetryPolicy
|
|
51
|
+
|
|
52
|
+
async with ProxyHttpClient(retry=RetryPolicy(max_attempts=5), timeout=15.0) as client:
|
|
53
|
+
response = await client.get("https://api.example.com/data")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### With proxy rotation
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from resilient_httpx import ProxyHttpClient, RetryPolicy
|
|
60
|
+
|
|
61
|
+
client = ProxyHttpClient(
|
|
62
|
+
proxies=[
|
|
63
|
+
"http://proxy1:8080",
|
|
64
|
+
"http://user:pass@proxy2:8080",
|
|
65
|
+
"socks5://proxy3:1080",
|
|
66
|
+
],
|
|
67
|
+
proxy_strategy="round-robin", # or "random"
|
|
68
|
+
retry=RetryPolicy(max_attempts=5, backoff="exponential"),
|
|
69
|
+
timeout=15.0,
|
|
70
|
+
blacklist_threshold=3,
|
|
71
|
+
blacklist_ttl=300.0,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async with client:
|
|
75
|
+
response = await client.get("https://api.example.com/data")
|
|
76
|
+
data = response.json()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Custom retry policy
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import httpx
|
|
83
|
+
from resilient_httpx import RetryPolicy
|
|
84
|
+
|
|
85
|
+
policy = RetryPolicy(
|
|
86
|
+
max_attempts=5,
|
|
87
|
+
backoff="exponential", # "exponential" | "fixed" | "random_jitter"
|
|
88
|
+
min_wait=1.0,
|
|
89
|
+
max_wait=30.0,
|
|
90
|
+
retry_on=[429, 500, 502, 503, 504],
|
|
91
|
+
retry_on_exception=[httpx.TimeoutException, httpx.ConnectError],
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Error handling
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from resilient_httpx import ProxyHttpClient, AllProxiesExhausted, MaxRetriesExceeded
|
|
99
|
+
|
|
100
|
+
async with ProxyHttpClient(proxies=proxy_list) as client:
|
|
101
|
+
try:
|
|
102
|
+
response = await client.get("https://api.example.com/data")
|
|
103
|
+
except AllProxiesExhausted:
|
|
104
|
+
... # all proxies blacklisted
|
|
105
|
+
except MaxRetriesExceeded as exc:
|
|
106
|
+
... # retries exhausted, exc.__cause__ has the last error
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Configuration Reference
|
|
110
|
+
|
|
111
|
+
| Parameter | Type | Default | Description |
|
|
112
|
+
|---|---|---|---|
|
|
113
|
+
| `proxies` | `list[str] \| None` | `None` | Proxy URLs |
|
|
114
|
+
| `proxy_strategy` | `str` | `"round-robin"` | `"round-robin"` or `"random"` |
|
|
115
|
+
| `retry` | `RetryPolicy` | `RetryPolicy()` | Retry configuration |
|
|
116
|
+
| `timeout` | `float` | `30.0` | Request timeout in seconds |
|
|
117
|
+
| `headers` | `dict[str, str] \| None` | `None` | Default headers |
|
|
118
|
+
| `blacklist_threshold` | `int` | `3` | Consecutive failures before blacklisting |
|
|
119
|
+
| `blacklist_ttl` | `float` | `300.0` | Blacklist duration in seconds |
|
|
120
|
+
| `fallback_to_direct` | `bool` | `False` | Request without proxy when all are blacklisted |
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python -m venv .venv
|
|
126
|
+
source .venv/bin/activate
|
|
127
|
+
pip install -e ".[dev]"
|
|
128
|
+
pytest
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Run with coverage:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pytest --cov=resilient_httpx --cov-report=term-missing
|
|
135
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# resilient-httpx
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
Async HTTP client with retry, backoff, and proxy rotation. Built on top of `httpx`, `tenacity`, and `structlog`.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install git+https://github.com/galthran-wq/resilient-httpx.git
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
### Basic (no proxies)
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from resilient_httpx import ProxyHttpClient, RetryPolicy
|
|
20
|
+
|
|
21
|
+
async with ProxyHttpClient(retry=RetryPolicy(max_attempts=5), timeout=15.0) as client:
|
|
22
|
+
response = await client.get("https://api.example.com/data")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### With proxy rotation
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from resilient_httpx import ProxyHttpClient, RetryPolicy
|
|
29
|
+
|
|
30
|
+
client = ProxyHttpClient(
|
|
31
|
+
proxies=[
|
|
32
|
+
"http://proxy1:8080",
|
|
33
|
+
"http://user:pass@proxy2:8080",
|
|
34
|
+
"socks5://proxy3:1080",
|
|
35
|
+
],
|
|
36
|
+
proxy_strategy="round-robin", # or "random"
|
|
37
|
+
retry=RetryPolicy(max_attempts=5, backoff="exponential"),
|
|
38
|
+
timeout=15.0,
|
|
39
|
+
blacklist_threshold=3,
|
|
40
|
+
blacklist_ttl=300.0,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async with client:
|
|
44
|
+
response = await client.get("https://api.example.com/data")
|
|
45
|
+
data = response.json()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Custom retry policy
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import httpx
|
|
52
|
+
from resilient_httpx import RetryPolicy
|
|
53
|
+
|
|
54
|
+
policy = RetryPolicy(
|
|
55
|
+
max_attempts=5,
|
|
56
|
+
backoff="exponential", # "exponential" | "fixed" | "random_jitter"
|
|
57
|
+
min_wait=1.0,
|
|
58
|
+
max_wait=30.0,
|
|
59
|
+
retry_on=[429, 500, 502, 503, 504],
|
|
60
|
+
retry_on_exception=[httpx.TimeoutException, httpx.ConnectError],
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Error handling
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from resilient_httpx import ProxyHttpClient, AllProxiesExhausted, MaxRetriesExceeded
|
|
68
|
+
|
|
69
|
+
async with ProxyHttpClient(proxies=proxy_list) as client:
|
|
70
|
+
try:
|
|
71
|
+
response = await client.get("https://api.example.com/data")
|
|
72
|
+
except AllProxiesExhausted:
|
|
73
|
+
... # all proxies blacklisted
|
|
74
|
+
except MaxRetriesExceeded as exc:
|
|
75
|
+
... # retries exhausted, exc.__cause__ has the last error
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Configuration Reference
|
|
79
|
+
|
|
80
|
+
| Parameter | Type | Default | Description |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| `proxies` | `list[str] \| None` | `None` | Proxy URLs |
|
|
83
|
+
| `proxy_strategy` | `str` | `"round-robin"` | `"round-robin"` or `"random"` |
|
|
84
|
+
| `retry` | `RetryPolicy` | `RetryPolicy()` | Retry configuration |
|
|
85
|
+
| `timeout` | `float` | `30.0` | Request timeout in seconds |
|
|
86
|
+
| `headers` | `dict[str, str] \| None` | `None` | Default headers |
|
|
87
|
+
| `blacklist_threshold` | `int` | `3` | Consecutive failures before blacklisting |
|
|
88
|
+
| `blacklist_ttl` | `float` | `300.0` | Blacklist duration in seconds |
|
|
89
|
+
| `fallback_to_direct` | `bool` | `False` | Request without proxy when all are blacklisted |
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m venv .venv
|
|
95
|
+
source .venv/bin/activate
|
|
96
|
+
pip install -e ".[dev]"
|
|
97
|
+
pytest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Run with coverage:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pytest --cov=resilient_httpx --cov-report=term-missing
|
|
104
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "resilient-httpx"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async HTTP client with retry, backoff, and proxy rotation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "galthran-wq" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Framework :: AsyncIO",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.28.0",
|
|
28
|
+
"tenacity>=9.1.0",
|
|
29
|
+
"structlog>=25.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/galthran-wq/resilient-httpx"
|
|
34
|
+
Repository = "https://github.com/galthran-wq/resilient-httpx"
|
|
35
|
+
Issues = "https://github.com/galthran-wq/resilient-httpx/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest",
|
|
40
|
+
"pytest-asyncio",
|
|
41
|
+
"pytest-cov",
|
|
42
|
+
"respx",
|
|
43
|
+
"ruff",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/resilient_httpx"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from resilient_httpx.client import ProxyHttpClient
|
|
2
|
+
from resilient_httpx.exceptions import AllProxiesExhausted, MaxRetriesExceeded
|
|
3
|
+
from resilient_httpx.retry import RetryPolicy
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ProxyHttpClient",
|
|
9
|
+
"RetryPolicy",
|
|
10
|
+
"AllProxiesExhausted",
|
|
11
|
+
"MaxRetriesExceeded",
|
|
12
|
+
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import structlog
|
|
5
|
+
from tenacity import RetryError
|
|
6
|
+
|
|
7
|
+
from resilient_httpx.exceptions import AllProxiesExhausted, MaxRetriesExceeded
|
|
8
|
+
from resilient_httpx.pool import ProxyPool
|
|
9
|
+
from resilient_httpx.retry import RetryPolicy
|
|
10
|
+
|
|
11
|
+
_ALL = "_all"
|
|
12
|
+
_DEFAULT = "_default"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _RetriableStatusError(Exception):
|
|
16
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
17
|
+
self.response = response
|
|
18
|
+
super().__init__(f"HTTP {response.status_code}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProxyHttpClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
proxies: list[str] | dict[str, list[str]] | None = None,
|
|
25
|
+
proxy_strategy: str = "round-robin",
|
|
26
|
+
retry: RetryPolicy | None = None,
|
|
27
|
+
timeout: float = 30.0,
|
|
28
|
+
headers: dict[str, str] | None = None,
|
|
29
|
+
blacklist_threshold: int = 3,
|
|
30
|
+
blacklist_ttl: float = 300.0,
|
|
31
|
+
fallback_to_direct: bool = False,
|
|
32
|
+
default_pool: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._strategy = proxy_strategy
|
|
35
|
+
self._blacklist_threshold = blacklist_threshold
|
|
36
|
+
self._blacklist_ttl = blacklist_ttl
|
|
37
|
+
self._pools: dict[str, ProxyPool] = {}
|
|
38
|
+
self._default_pool_name: str | None = None
|
|
39
|
+
|
|
40
|
+
if default_pool is not None and not isinstance(proxies, dict):
|
|
41
|
+
raise ValueError("default_pool requires proxies to be a dict")
|
|
42
|
+
|
|
43
|
+
if isinstance(proxies, list):
|
|
44
|
+
self._pools[_DEFAULT] = self._make_pool(proxies)
|
|
45
|
+
self._default_pool_name = _DEFAULT
|
|
46
|
+
elif isinstance(proxies, dict):
|
|
47
|
+
reserved = {_ALL, _DEFAULT}
|
|
48
|
+
overlap = reserved.intersection(proxies)
|
|
49
|
+
if overlap:
|
|
50
|
+
raise ValueError(f"Reserved pool name(s): {sorted(overlap)!r}")
|
|
51
|
+
for name, proxy_list in proxies.items():
|
|
52
|
+
self._pools[name] = self._make_pool(proxy_list)
|
|
53
|
+
if default_pool is not None:
|
|
54
|
+
if default_pool not in self._pools:
|
|
55
|
+
raise ValueError(f"Unknown default pool: {default_pool!r}")
|
|
56
|
+
self._default_pool_name = default_pool
|
|
57
|
+
else:
|
|
58
|
+
self._build_combined_pool()
|
|
59
|
+
|
|
60
|
+
self._retry = retry or RetryPolicy()
|
|
61
|
+
self._timeout = timeout
|
|
62
|
+
self._headers = headers or {}
|
|
63
|
+
self._fallback = fallback_to_direct
|
|
64
|
+
self._clients: dict[str | None, httpx.AsyncClient] = {}
|
|
65
|
+
self._log = structlog.get_logger()
|
|
66
|
+
|
|
67
|
+
def _make_pool(
|
|
68
|
+
self,
|
|
69
|
+
proxies: list[str],
|
|
70
|
+
state: dict[str, object] | None = None,
|
|
71
|
+
) -> ProxyPool:
|
|
72
|
+
return ProxyPool(
|
|
73
|
+
proxies=proxies,
|
|
74
|
+
strategy=self._strategy,
|
|
75
|
+
blacklist_threshold=self._blacklist_threshold,
|
|
76
|
+
blacklist_ttl=self._blacklist_ttl,
|
|
77
|
+
state=state,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _build_combined_pool(self) -> None:
|
|
81
|
+
all_proxies = []
|
|
82
|
+
state = {}
|
|
83
|
+
for name, pool in self._pools.items():
|
|
84
|
+
if name != _ALL:
|
|
85
|
+
for proxy in pool._proxies:
|
|
86
|
+
all_proxies.append(proxy)
|
|
87
|
+
if proxy not in state:
|
|
88
|
+
state[proxy] = pool._state[proxy]
|
|
89
|
+
if all_proxies:
|
|
90
|
+
self._pools[_ALL] = self._make_pool(all_proxies, state=state)
|
|
91
|
+
self._default_pool_name = _ALL
|
|
92
|
+
|
|
93
|
+
def add_pool(self, name: str, proxies: list[str]) -> None:
|
|
94
|
+
if name in (_ALL, _DEFAULT):
|
|
95
|
+
raise ValueError(f"Reserved pool name: {name!r}")
|
|
96
|
+
self._pools[name] = self._make_pool(proxies)
|
|
97
|
+
if self._default_pool_name == _ALL or self._default_pool_name is None:
|
|
98
|
+
self._build_combined_pool()
|
|
99
|
+
|
|
100
|
+
def _resolve_pool(self, pool: str | None) -> ProxyPool | None:
|
|
101
|
+
if pool is not None:
|
|
102
|
+
if pool not in self._pools:
|
|
103
|
+
raise ValueError(f"Unknown pool: {pool!r}")
|
|
104
|
+
return self._pools[pool]
|
|
105
|
+
if self._default_pool_name is not None:
|
|
106
|
+
return self._pools[self._default_pool_name]
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def _get_client(self, proxy: str | None) -> httpx.AsyncClient:
|
|
110
|
+
if proxy not in self._clients:
|
|
111
|
+
self._clients[proxy] = httpx.AsyncClient(
|
|
112
|
+
proxy=proxy,
|
|
113
|
+
timeout=self._timeout,
|
|
114
|
+
headers=self._headers,
|
|
115
|
+
)
|
|
116
|
+
return self._clients[proxy]
|
|
117
|
+
|
|
118
|
+
async def _request(
|
|
119
|
+
self, method: str, url: str, *, pool: str | None = None, **kwargs,
|
|
120
|
+
) -> httpx.Response:
|
|
121
|
+
active_pool = self._resolve_pool(pool)
|
|
122
|
+
retrying = self._retry.build_retrying(
|
|
123
|
+
extra_exceptions=[_RetriableStatusError],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
async for attempt in retrying:
|
|
128
|
+
with attempt:
|
|
129
|
+
proxy = None
|
|
130
|
+
if active_pool:
|
|
131
|
+
proxy = await active_pool.get_proxy()
|
|
132
|
+
if proxy is None:
|
|
133
|
+
if self._fallback:
|
|
134
|
+
self._log.warning(
|
|
135
|
+
"all_proxies_blacklisted",
|
|
136
|
+
fallback="direct",
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
raise AllProxiesExhausted()
|
|
140
|
+
|
|
141
|
+
self._log.debug(
|
|
142
|
+
"request_attempt",
|
|
143
|
+
method=method,
|
|
144
|
+
url=url,
|
|
145
|
+
proxy=proxy,
|
|
146
|
+
attempt=attempt.retry_state.attempt_number,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
client = self._get_client(proxy)
|
|
150
|
+
try:
|
|
151
|
+
response = await client.request(method, url, **kwargs)
|
|
152
|
+
except tuple(self._retry.retry_on_exception):
|
|
153
|
+
if active_pool and proxy:
|
|
154
|
+
blacklisted = await active_pool.report_failure(proxy)
|
|
155
|
+
if blacklisted:
|
|
156
|
+
self._log.warning(
|
|
157
|
+
"proxy_blacklisted", proxy=proxy
|
|
158
|
+
)
|
|
159
|
+
raise
|
|
160
|
+
|
|
161
|
+
if response.status_code in self._retry.retry_on:
|
|
162
|
+
if active_pool and proxy:
|
|
163
|
+
blacklisted = await active_pool.report_failure(proxy)
|
|
164
|
+
if blacklisted:
|
|
165
|
+
self._log.warning(
|
|
166
|
+
"proxy_blacklisted", proxy=proxy
|
|
167
|
+
)
|
|
168
|
+
raise _RetriableStatusError(response)
|
|
169
|
+
|
|
170
|
+
if active_pool and proxy:
|
|
171
|
+
await active_pool.report_success(proxy)
|
|
172
|
+
return response
|
|
173
|
+
except AllProxiesExhausted:
|
|
174
|
+
self._log.error("all_proxies_exhausted")
|
|
175
|
+
raise
|
|
176
|
+
except RetryError as exc:
|
|
177
|
+
last = exc.last_attempt.exception()
|
|
178
|
+
self._log.error("max_retries_exceeded", last_error=str(last))
|
|
179
|
+
raise MaxRetriesExceeded(str(last)) from last
|
|
180
|
+
|
|
181
|
+
async def get(self, url: str, *, pool: str | None = None, **kwargs) -> httpx.Response:
|
|
182
|
+
return await self._request("GET", url, pool=pool, **kwargs)
|
|
183
|
+
|
|
184
|
+
async def post(self, url: str, *, pool: str | None = None, **kwargs) -> httpx.Response:
|
|
185
|
+
return await self._request("POST", url, pool=pool, **kwargs)
|
|
186
|
+
|
|
187
|
+
async def put(self, url: str, *, pool: str | None = None, **kwargs) -> httpx.Response:
|
|
188
|
+
return await self._request("PUT", url, pool=pool, **kwargs)
|
|
189
|
+
|
|
190
|
+
async def patch(self, url: str, *, pool: str | None = None, **kwargs) -> httpx.Response:
|
|
191
|
+
return await self._request("PATCH", url, pool=pool, **kwargs)
|
|
192
|
+
|
|
193
|
+
async def delete(self, url: str, *, pool: str | None = None, **kwargs) -> httpx.Response:
|
|
194
|
+
return await self._request("DELETE", url, pool=pool, **kwargs)
|
|
195
|
+
|
|
196
|
+
async def aclose(self) -> None:
|
|
197
|
+
for client in self._clients.values():
|
|
198
|
+
await client.aclose()
|
|
199
|
+
self._clients.clear()
|
|
200
|
+
|
|
201
|
+
async def __aenter__(self) -> ProxyHttpClient:
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
async def __aexit__(self, *args) -> None:
|
|
205
|
+
await self.aclose()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class _ProxyState:
|
|
11
|
+
fail_count: int = 0
|
|
12
|
+
blacklisted_until: float | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProxyPool:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
proxies: list[str],
|
|
19
|
+
strategy: str = "round-robin",
|
|
20
|
+
blacklist_threshold: int = 3,
|
|
21
|
+
blacklist_ttl: float = 300.0,
|
|
22
|
+
state: dict[str, _ProxyState] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._proxies = list(proxies)
|
|
25
|
+
self._strategy = strategy
|
|
26
|
+
self._threshold = blacklist_threshold
|
|
27
|
+
self._ttl = blacklist_ttl
|
|
28
|
+
existing_state = state or {}
|
|
29
|
+
self._state = {p: existing_state.get(p, _ProxyState()) for p in self._proxies}
|
|
30
|
+
self._index = 0
|
|
31
|
+
self._lock = asyncio.Lock()
|
|
32
|
+
|
|
33
|
+
def _is_available(self, proxy: str) -> bool:
|
|
34
|
+
state = self._state[proxy]
|
|
35
|
+
if state.blacklisted_until is None:
|
|
36
|
+
return True
|
|
37
|
+
if time.monotonic() >= state.blacklisted_until:
|
|
38
|
+
state.blacklisted_until = None
|
|
39
|
+
state.fail_count = self._threshold - 1
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
async def get_proxy(self) -> str | None:
|
|
44
|
+
async with self._lock:
|
|
45
|
+
available = [p for p in self._proxies if self._is_available(p)]
|
|
46
|
+
if not available:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
if self._strategy == "random":
|
|
50
|
+
return random.choice(available)
|
|
51
|
+
|
|
52
|
+
while True:
|
|
53
|
+
proxy = self._proxies[self._index % len(self._proxies)]
|
|
54
|
+
self._index += 1
|
|
55
|
+
if proxy in available:
|
|
56
|
+
return proxy
|
|
57
|
+
|
|
58
|
+
async def report_failure(self, proxy: str) -> bool:
|
|
59
|
+
async with self._lock:
|
|
60
|
+
state = self._state[proxy]
|
|
61
|
+
state.fail_count += 1
|
|
62
|
+
if state.fail_count >= self._threshold:
|
|
63
|
+
state.blacklisted_until = time.monotonic() + self._ttl
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
async def report_success(self, proxy: str) -> None:
|
|
68
|
+
async with self._lock:
|
|
69
|
+
state = self._state[proxy]
|
|
70
|
+
state.fail_count = 0
|
|
71
|
+
state.blacklisted_until = None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from tenacity import (
|
|
7
|
+
AsyncRetrying,
|
|
8
|
+
retry_if_exception_type,
|
|
9
|
+
stop_after_attempt,
|
|
10
|
+
wait_exponential,
|
|
11
|
+
wait_fixed,
|
|
12
|
+
wait_random,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default_retry_on_exception() -> list[type[Exception]]:
|
|
17
|
+
return [httpx.TimeoutException, httpx.ConnectError]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RetryPolicy:
|
|
22
|
+
max_attempts: int = 3
|
|
23
|
+
backoff: str = "exponential"
|
|
24
|
+
min_wait: float = 1.0
|
|
25
|
+
max_wait: float = 30.0
|
|
26
|
+
retry_on: list[int] = field(
|
|
27
|
+
default_factory=lambda: [429, 500, 502, 503, 504],
|
|
28
|
+
)
|
|
29
|
+
retry_on_exception: list[type[Exception]] = field(
|
|
30
|
+
default_factory=_default_retry_on_exception,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def build_retrying(
|
|
34
|
+
self,
|
|
35
|
+
extra_exceptions: list[type[Exception]] | None = None,
|
|
36
|
+
) -> AsyncRetrying:
|
|
37
|
+
if self.backoff == "fixed":
|
|
38
|
+
wait = wait_fixed(self.min_wait)
|
|
39
|
+
elif self.backoff == "random_jitter":
|
|
40
|
+
wait = wait_random(min=self.min_wait, max=self.max_wait)
|
|
41
|
+
else:
|
|
42
|
+
wait = wait_exponential(min=self.min_wait, max=self.max_wait)
|
|
43
|
+
|
|
44
|
+
exceptions = list(self.retry_on_exception)
|
|
45
|
+
if extra_exceptions:
|
|
46
|
+
exceptions.extend(extra_exceptions)
|
|
47
|
+
|
|
48
|
+
return AsyncRetrying(
|
|
49
|
+
stop=stop_after_attempt(self.max_attempts),
|
|
50
|
+
wait=wait,
|
|
51
|
+
retry=retry_if_exception_type(tuple(exceptions)),
|
|
52
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from resilient_httpx import (
|
|
8
|
+
AllProxiesExhausted,
|
|
9
|
+
MaxRetriesExceeded,
|
|
10
|
+
ProxyHttpClient,
|
|
11
|
+
RetryPolicy,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
URL = "https://api.example.com/data"
|
|
15
|
+
NO_WAIT = RetryPolicy(min_wait=0, max_wait=0)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _response(status: int = 200, text: str = "ok") -> httpx.Response:
|
|
19
|
+
return httpx.Response(status, text=text, request=httpx.Request("GET", URL))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def mock_httpx(side_effect):
|
|
24
|
+
mock = AsyncMock(side_effect=side_effect)
|
|
25
|
+
with (
|
|
26
|
+
patch.object(httpx.AsyncClient, "request", mock),
|
|
27
|
+
patch.object(httpx.AsyncClient, "aclose", new_callable=AsyncMock),
|
|
28
|
+
):
|
|
29
|
+
yield mock
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def test_successful_request_no_proxies():
|
|
33
|
+
with mock_httpx([_response(200)]) as mock:
|
|
34
|
+
async with ProxyHttpClient(retry=NO_WAIT) as client:
|
|
35
|
+
resp = await client.get(URL)
|
|
36
|
+
assert resp.status_code == 200
|
|
37
|
+
assert mock.call_count == 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_successful_request_with_proxies(proxy_list):
|
|
41
|
+
with mock_httpx([_response(200)]):
|
|
42
|
+
async with ProxyHttpClient(proxies=proxy_list, retry=NO_WAIT) as client:
|
|
43
|
+
resp = await client.get(URL)
|
|
44
|
+
assert resp.status_code == 200
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_retry_on_502():
|
|
48
|
+
with mock_httpx([_response(502), _response(200)]) as mock:
|
|
49
|
+
async with ProxyHttpClient(retry=NO_WAIT) as client:
|
|
50
|
+
resp = await client.get(URL)
|
|
51
|
+
assert resp.status_code == 200
|
|
52
|
+
assert mock.call_count == 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def test_retry_on_429():
|
|
56
|
+
with mock_httpx([_response(429), _response(429), _response(200)]) as mock:
|
|
57
|
+
policy = RetryPolicy(max_attempts=5, min_wait=0, max_wait=0)
|
|
58
|
+
async with ProxyHttpClient(retry=policy) as client:
|
|
59
|
+
resp = await client.get(URL)
|
|
60
|
+
assert resp.status_code == 200
|
|
61
|
+
assert mock.call_count == 3
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def test_retry_on_network_exception():
|
|
65
|
+
side_effect = [httpx.ConnectError("connection refused"), _response(200)]
|
|
66
|
+
with mock_httpx(side_effect) as mock:
|
|
67
|
+
async with ProxyHttpClient(retry=NO_WAIT) as client:
|
|
68
|
+
resp = await client.get(URL)
|
|
69
|
+
assert resp.status_code == 200
|
|
70
|
+
assert mock.call_count == 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_max_retries_exceeded():
|
|
74
|
+
with mock_httpx([_response(502)] * 3):
|
|
75
|
+
async with ProxyHttpClient(retry=NO_WAIT) as client:
|
|
76
|
+
with pytest.raises(MaxRetriesExceeded):
|
|
77
|
+
await client.get(URL)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_max_retries_exceeded_has_cause():
|
|
81
|
+
with mock_httpx([httpx.ConnectError("fail")] * 3):
|
|
82
|
+
async with ProxyHttpClient(retry=NO_WAIT) as client:
|
|
83
|
+
with pytest.raises(MaxRetriesExceeded) as exc_info:
|
|
84
|
+
await client.get(URL)
|
|
85
|
+
assert isinstance(exc_info.value.__cause__, httpx.ConnectError)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_all_proxies_exhausted(proxy_list):
|
|
89
|
+
responses = [_response(502)] * 4
|
|
90
|
+
with mock_httpx(responses):
|
|
91
|
+
policy = RetryPolicy(max_attempts=5, min_wait=0, max_wait=0)
|
|
92
|
+
async with ProxyHttpClient(
|
|
93
|
+
proxies=proxy_list,
|
|
94
|
+
retry=policy,
|
|
95
|
+
blacklist_threshold=1,
|
|
96
|
+
) as client:
|
|
97
|
+
with pytest.raises(AllProxiesExhausted):
|
|
98
|
+
await client.get(URL)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_all_proxies_exhausted_fallback(proxy_list):
|
|
102
|
+
responses = [_response(502)] * 3 + [_response(200)]
|
|
103
|
+
with mock_httpx(responses) as mock:
|
|
104
|
+
policy = RetryPolicy(max_attempts=5, min_wait=0, max_wait=0)
|
|
105
|
+
async with ProxyHttpClient(
|
|
106
|
+
proxies=proxy_list,
|
|
107
|
+
retry=policy,
|
|
108
|
+
blacklist_threshold=1,
|
|
109
|
+
fallback_to_direct=True,
|
|
110
|
+
) as client:
|
|
111
|
+
resp = await client.get(URL)
|
|
112
|
+
assert resp.status_code == 200
|
|
113
|
+
assert mock.call_count == 4
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def test_proxy_rotation_on_failure(proxy_list):
|
|
117
|
+
captured_proxies = []
|
|
118
|
+
original_get_client = ProxyHttpClient._get_client
|
|
119
|
+
|
|
120
|
+
def spy_get_client(self, proxy):
|
|
121
|
+
captured_proxies.append(proxy)
|
|
122
|
+
return original_get_client(self, proxy)
|
|
123
|
+
|
|
124
|
+
with mock_httpx([_response(502), _response(502), _response(200)]):
|
|
125
|
+
with patch.object(ProxyHttpClient, "_get_client", spy_get_client):
|
|
126
|
+
async with ProxyHttpClient(proxies=proxy_list, retry=NO_WAIT) as client:
|
|
127
|
+
await client.get(URL)
|
|
128
|
+
assert len(captured_proxies) == 3
|
|
129
|
+
assert captured_proxies[0] != captured_proxies[1]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def test_context_manager_closes_clients():
|
|
133
|
+
with mock_httpx([_response(200)]):
|
|
134
|
+
client = ProxyHttpClient(retry=NO_WAIT)
|
|
135
|
+
async with client:
|
|
136
|
+
await client.get(URL)
|
|
137
|
+
assert client._clients == {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def test_http_methods():
|
|
141
|
+
for method in ["get", "post", "put", "patch", "delete"]:
|
|
142
|
+
with mock_httpx([_response(200)]) as mock:
|
|
143
|
+
async with ProxyHttpClient(retry=NO_WAIT) as client:
|
|
144
|
+
resp = await getattr(client, method)(URL)
|
|
145
|
+
assert resp.status_code == 200
|
|
146
|
+
assert mock.call_args[0][0] == method.upper()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_no_retry_on_non_retriable_status():
|
|
150
|
+
with mock_httpx([_response(400)]) as mock:
|
|
151
|
+
async with ProxyHttpClient(retry=NO_WAIT) as client:
|
|
152
|
+
resp = await client.get(URL)
|
|
153
|
+
assert resp.status_code == 400
|
|
154
|
+
assert mock.call_count == 1
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, patch
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from resilient_httpx import ProxyHttpClient, RetryPolicy
|
|
7
|
+
|
|
8
|
+
from .test_client import URL, NO_WAIT, _response, mock_httpx
|
|
9
|
+
|
|
10
|
+
EXTERNAL = ["http://external1:8080", "http://external2:8080"]
|
|
11
|
+
INTERNAL = ["http://internal1:8080", "http://internal2:8080"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def test_named_pools_per_request():
|
|
15
|
+
captured_proxies = []
|
|
16
|
+
original_get_client = ProxyHttpClient._get_client
|
|
17
|
+
|
|
18
|
+
def spy_get_client(self, proxy):
|
|
19
|
+
captured_proxies.append(proxy)
|
|
20
|
+
return original_get_client(self, proxy)
|
|
21
|
+
|
|
22
|
+
with mock_httpx([_response(200)] * 2):
|
|
23
|
+
with patch.object(ProxyHttpClient, "_get_client", spy_get_client):
|
|
24
|
+
async with ProxyHttpClient(
|
|
25
|
+
proxies={"external": EXTERNAL, "internal": INTERNAL},
|
|
26
|
+
retry=NO_WAIT,
|
|
27
|
+
) as client:
|
|
28
|
+
await client.get(URL, pool="external")
|
|
29
|
+
await client.get(URL, pool="internal")
|
|
30
|
+
assert captured_proxies[0] in EXTERNAL
|
|
31
|
+
assert captured_proxies[1] in INTERNAL
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_named_pools_default_combined():
|
|
35
|
+
captured_proxies = []
|
|
36
|
+
original_get_client = ProxyHttpClient._get_client
|
|
37
|
+
|
|
38
|
+
def spy_get_client(self, proxy):
|
|
39
|
+
captured_proxies.append(proxy)
|
|
40
|
+
return original_get_client(self, proxy)
|
|
41
|
+
|
|
42
|
+
with mock_httpx([_response(200)] * 4):
|
|
43
|
+
with patch.object(ProxyHttpClient, "_get_client", spy_get_client):
|
|
44
|
+
async with ProxyHttpClient(
|
|
45
|
+
proxies={"external": EXTERNAL, "internal": INTERNAL},
|
|
46
|
+
retry=NO_WAIT,
|
|
47
|
+
) as client:
|
|
48
|
+
for _ in range(4):
|
|
49
|
+
await client.get(URL)
|
|
50
|
+
assert set(captured_proxies) == set(EXTERNAL + INTERNAL)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_named_pools_explicit_default():
|
|
54
|
+
captured_proxies = []
|
|
55
|
+
original_get_client = ProxyHttpClient._get_client
|
|
56
|
+
|
|
57
|
+
def spy_get_client(self, proxy):
|
|
58
|
+
captured_proxies.append(proxy)
|
|
59
|
+
return original_get_client(self, proxy)
|
|
60
|
+
|
|
61
|
+
with mock_httpx([_response(200)] * 2):
|
|
62
|
+
with patch.object(ProxyHttpClient, "_get_client", spy_get_client):
|
|
63
|
+
async with ProxyHttpClient(
|
|
64
|
+
proxies={"external": EXTERNAL, "internal": INTERNAL},
|
|
65
|
+
default_pool="internal",
|
|
66
|
+
retry=NO_WAIT,
|
|
67
|
+
) as client:
|
|
68
|
+
await client.get(URL)
|
|
69
|
+
await client.get(URL)
|
|
70
|
+
assert all(p in INTERNAL for p in captured_proxies)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_add_pool():
|
|
74
|
+
extra = ["http://extra1:8080"]
|
|
75
|
+
captured_proxies = []
|
|
76
|
+
original_get_client = ProxyHttpClient._get_client
|
|
77
|
+
|
|
78
|
+
def spy_get_client(self, proxy):
|
|
79
|
+
captured_proxies.append(proxy)
|
|
80
|
+
return original_get_client(self, proxy)
|
|
81
|
+
|
|
82
|
+
with mock_httpx([_response(200)]):
|
|
83
|
+
with patch.object(ProxyHttpClient, "_get_client", spy_get_client):
|
|
84
|
+
async with ProxyHttpClient(
|
|
85
|
+
proxies={"external": EXTERNAL},
|
|
86
|
+
retry=NO_WAIT,
|
|
87
|
+
) as client:
|
|
88
|
+
client.add_pool("extra", extra)
|
|
89
|
+
await client.get(URL, pool="extra")
|
|
90
|
+
assert captured_proxies[0] in extra
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_add_pool_updates_combined_default():
|
|
94
|
+
extra = ["http://extra1:8080"]
|
|
95
|
+
client = ProxyHttpClient(
|
|
96
|
+
proxies={"external": EXTERNAL},
|
|
97
|
+
retry=NO_WAIT,
|
|
98
|
+
)
|
|
99
|
+
client.add_pool("extra", extra)
|
|
100
|
+
combined = client._pools["_all"]
|
|
101
|
+
assert "http://extra1:8080" in combined._proxies
|
|
102
|
+
assert all(p in combined._proxies for p in EXTERNAL)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def test_unknown_pool_raises():
|
|
106
|
+
async with ProxyHttpClient(
|
|
107
|
+
proxies={"external": EXTERNAL},
|
|
108
|
+
retry=NO_WAIT,
|
|
109
|
+
) as client:
|
|
110
|
+
with pytest.raises(ValueError, match="Unknown pool"):
|
|
111
|
+
await client.get(URL, pool="nonexistent")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def test_unknown_default_pool_raises():
|
|
115
|
+
with pytest.raises(ValueError, match="Unknown default pool"):
|
|
116
|
+
ProxyHttpClient(
|
|
117
|
+
proxies={"external": EXTERNAL},
|
|
118
|
+
default_pool="nonexistent",
|
|
119
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from resilient_httpx.pool import ProxyPool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def pool(proxy_list):
|
|
10
|
+
return ProxyPool(proxies=proxy_list, blacklist_threshold=3, blacklist_ttl=10.0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def test_round_robin_order(pool, proxy_list):
|
|
14
|
+
results = [await pool.get_proxy() for _ in range(6)]
|
|
15
|
+
assert results == proxy_list * 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def test_random_strategy(proxy_list):
|
|
19
|
+
pool = ProxyPool(proxies=proxy_list, strategy="random")
|
|
20
|
+
result = await pool.get_proxy()
|
|
21
|
+
assert result in proxy_list
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def test_blacklist_after_threshold(pool):
|
|
25
|
+
for _ in range(3):
|
|
26
|
+
await pool.report_failure("http://proxy1:8080")
|
|
27
|
+
result = await pool.get_proxy()
|
|
28
|
+
assert result == "http://proxy2:8080"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def test_report_failure_returns_blacklisted_flag(pool):
|
|
32
|
+
assert await pool.report_failure("http://proxy1:8080") is False
|
|
33
|
+
assert await pool.report_failure("http://proxy1:8080") is False
|
|
34
|
+
assert await pool.report_failure("http://proxy1:8080") is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_all_blacklisted_returns_none(pool):
|
|
38
|
+
for proxy in ["http://proxy1:8080", "http://proxy2:8080", "http://proxy3:8080"]:
|
|
39
|
+
for _ in range(3):
|
|
40
|
+
await pool.report_failure(proxy)
|
|
41
|
+
assert await pool.get_proxy() is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def test_ttl_recovery(proxy_list):
|
|
45
|
+
pool = ProxyPool(
|
|
46
|
+
proxies=proxy_list, blacklist_threshold=2, blacklist_ttl=0.1,
|
|
47
|
+
)
|
|
48
|
+
for _ in range(2):
|
|
49
|
+
await pool.report_failure("http://proxy1:8080")
|
|
50
|
+
|
|
51
|
+
assert await pool.get_proxy() == "http://proxy2:8080"
|
|
52
|
+
|
|
53
|
+
await _sleep(0.15)
|
|
54
|
+
|
|
55
|
+
results = [await pool.get_proxy() for _ in range(3)]
|
|
56
|
+
assert "http://proxy1:8080" in results
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def test_lowered_priority_after_recovery(proxy_list):
|
|
60
|
+
pool = ProxyPool(
|
|
61
|
+
proxies=proxy_list, blacklist_threshold=3, blacklist_ttl=0.1,
|
|
62
|
+
)
|
|
63
|
+
for _ in range(3):
|
|
64
|
+
await pool.report_failure("http://proxy1:8080")
|
|
65
|
+
|
|
66
|
+
await _sleep(0.15)
|
|
67
|
+
|
|
68
|
+
await pool.get_proxy()
|
|
69
|
+
state = pool._state["http://proxy1:8080"]
|
|
70
|
+
assert state.fail_count == 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_report_success_resets(pool):
|
|
74
|
+
await pool.report_failure("http://proxy1:8080")
|
|
75
|
+
await pool.report_failure("http://proxy1:8080")
|
|
76
|
+
await pool.report_success("http://proxy1:8080")
|
|
77
|
+
state = pool._state["http://proxy1:8080"]
|
|
78
|
+
assert state.fail_count == 0
|
|
79
|
+
assert state.blacklisted_until is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_round_robin_skips_blacklisted(pool):
|
|
83
|
+
for _ in range(3):
|
|
84
|
+
await pool.report_failure("http://proxy2:8080")
|
|
85
|
+
results = [await pool.get_proxy() for _ in range(4)]
|
|
86
|
+
assert results == [
|
|
87
|
+
"http://proxy1:8080",
|
|
88
|
+
"http://proxy3:8080",
|
|
89
|
+
"http://proxy1:8080",
|
|
90
|
+
"http://proxy3:8080",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _sleep(seconds: float) -> None:
|
|
95
|
+
await asyncio.sleep(seconds)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from tenacity import wait_exponential, wait_fixed, wait_random
|
|
3
|
+
|
|
4
|
+
from resilient_httpx.retry import RetryPolicy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_defaults():
|
|
8
|
+
policy = RetryPolicy()
|
|
9
|
+
assert policy.max_attempts == 3
|
|
10
|
+
assert policy.backoff == "exponential"
|
|
11
|
+
assert policy.min_wait == 1.0
|
|
12
|
+
assert policy.max_wait == 30.0
|
|
13
|
+
assert policy.retry_on == [429, 500, 502, 503, 504]
|
|
14
|
+
assert httpx.TimeoutException in policy.retry_on_exception
|
|
15
|
+
assert httpx.ConnectError in policy.retry_on_exception
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_build_retrying_exponential():
|
|
19
|
+
retrying = RetryPolicy(backoff="exponential", min_wait=2.0, max_wait=60.0).build_retrying()
|
|
20
|
+
assert isinstance(retrying.wait, wait_exponential)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_build_retrying_fixed():
|
|
24
|
+
retrying = RetryPolicy(backoff="fixed", min_wait=5.0).build_retrying()
|
|
25
|
+
assert isinstance(retrying.wait, wait_fixed)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_build_retrying_random_jitter():
|
|
29
|
+
retrying = RetryPolicy(backoff="random_jitter").build_retrying()
|
|
30
|
+
assert isinstance(retrying.wait, wait_random)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_build_retrying_stop_after_attempts():
|
|
34
|
+
retrying = RetryPolicy(max_attempts=7).build_retrying()
|
|
35
|
+
assert retrying.stop.max_attempt_number == 7
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_build_retrying_extra_exceptions():
|
|
39
|
+
class CustomError(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
retrying = RetryPolicy().build_retrying(extra_exceptions=[CustomError])
|
|
43
|
+
retry_check = retrying.retry
|
|
44
|
+
assert hasattr(retry_check, "exception_types")
|
|
45
|
+
assert CustomError in retry_check.exception_types
|
|
46
|
+
assert httpx.TimeoutException in retry_check.exception_types
|