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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ *.egg
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ coverage.json
12
+ htmlcov/
@@ -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
+ ![python](https://img.shields.io/badge/python-3.10%2B-blue)
35
+ ![coverage](https://img.shields.io/badge/coverage-99%25-brightgreen)
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
+ ![python](https://img.shields.io/badge/python-3.10%2B-blue)
4
+ ![coverage](https://img.shields.io/badge/coverage-99%25-brightgreen)
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,6 @@
1
+ class AllProxiesExhausted(Exception):
2
+ pass
3
+
4
+
5
+ class MaxRetriesExceeded(Exception):
6
+ pass
@@ -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,10 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.fixture
5
+ def proxy_list():
6
+ return [
7
+ "http://proxy1:8080",
8
+ "http://proxy2:8080",
9
+ "http://proxy3:8080",
10
+ ]
@@ -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