httpx-frugal 0.2.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.
- httpx_frugal-0.2.0/.gitignore +12 -0
- httpx_frugal-0.2.0/CHANGELOG.md +34 -0
- httpx_frugal-0.2.0/PKG-INFO +160 -0
- httpx_frugal-0.2.0/README.md +131 -0
- httpx_frugal-0.2.0/original_source_code.py +295 -0
- httpx_frugal-0.2.0/pyproject.toml +66 -0
- httpx_frugal-0.2.0/roadmap.md +74 -0
- httpx_frugal-0.2.0/src/httpx_frugal/__init__.py +16 -0
- httpx_frugal-0.2.0/src/httpx_frugal/_cache_admin.py +41 -0
- httpx_frugal-0.2.0/src/httpx_frugal/_cache_lookup.py +50 -0
- httpx_frugal-0.2.0/src/httpx_frugal/_common.py +46 -0
- httpx_frugal-0.2.0/src/httpx_frugal/_rate_introspection.py +29 -0
- httpx_frugal-0.2.0/src/httpx_frugal/async_client.py +159 -0
- httpx_frugal-0.2.0/src/httpx_frugal/client.py +183 -0
- httpx_frugal-0.2.0/src/httpx_frugal/exceptions.py +9 -0
- httpx_frugal-0.2.0/src/httpx_frugal/policy.py +13 -0
- httpx_frugal-0.2.0/src/httpx_frugal/transport.py +82 -0
- httpx_frugal-0.2.0/src/httpx_frugal/ttl.py +26 -0
- httpx_frugal-0.2.0/tests/conftest.py +30 -0
- httpx_frugal-0.2.0/tests/helpers.py +68 -0
- httpx_frugal-0.2.0/tests/test_async_client.py +84 -0
- httpx_frugal-0.2.0/tests/test_client.py +243 -0
- httpx_frugal-0.2.0/tests/test_example.py +52 -0
- httpx_frugal-0.2.0/tests/test_transport.py +66 -0
- httpx_frugal-0.2.0/uv.lock +820 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-05-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `AsyncRateLimitedCacheClient` for async httpx pipelines (FastAPI, Dagster, etc.)
|
|
10
|
+
- `blocking` and `rate_limit_timeout_seconds` on sync and async clients
|
|
11
|
+
- `use_file_lock` for multi-process rate limiter safety
|
|
12
|
+
- `tokens_available(domain)` for rate-limit introspection
|
|
13
|
+
- `clear_cache_for(url)` for surgical cache invalidation
|
|
14
|
+
- `request_with_ttl()` helper and per-request `hishel_ttl` extension support
|
|
15
|
+
- `enable_http2` flag (default `False`; opt in with `httpx-frugal[http2]`)
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Path validation used wrong variable when cache parent directory was missing
|
|
20
|
+
- `validate_rate_list` now raises `ValueError` instead of `assert` (safe under `python -O`)
|
|
21
|
+
- Rate limiting rejects URLs without a host
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- HTTP/2 disabled by default to avoid requiring `httpx[http2]` on install
|
|
26
|
+
- `hishel[async]` included in core dependencies for async SQLite storage
|
|
27
|
+
|
|
28
|
+
## [0.1.0] - 2026-05-16
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- Initial release: `RateLimitedCacheClient` with SQLite cache and rate limiting
|
|
33
|
+
- `would_hit_cache()`, `clear_cache()`, `clear_rate_limiter()`
|
|
34
|
+
- `DomainRateLimitedTransport` and `AlwaysCachePolicy`
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httpx-frugal
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: An httpx client with persistent caching and rate limiting for data pipelines
|
|
5
|
+
Project-URL: Homepage, https://github.com/httpx-frugal/httpx-frugal
|
|
6
|
+
Project-URL: Documentation, https://github.com/httpx-frugal/httpx-frugal#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/httpx-frugal/httpx-frugal
|
|
8
|
+
Author: httpx-frugal contributors
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: cache,hishel,httpx,pyrate-limiter,rate-limit
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: hishel[async]<2,>=1.2
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Requires-Dist: pyrate-limiter<5,>=4
|
|
24
|
+
Provides-Extra: async
|
|
25
|
+
Requires-Dist: hishel[async]; extra == 'async'
|
|
26
|
+
Provides-Extra: http2
|
|
27
|
+
Requires-Dist: httpx[http2]; extra == 'http2'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# httpx-frugal
|
|
31
|
+
|
|
32
|
+
An [httpx](https://www.python-httpx.org/) client with persistent caching ([hishel](https://hishel.com/)) and per-domain rate limiting ([pyrate-limiter](https://pyratelimiter.readthedocs.io/)), designed for data pipelines that need to conserve API quota across process restarts.
|
|
33
|
+
|
|
34
|
+
Cache is checked **before** rate limiting, so cached responses do not consume rate-limit tokens.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install httpx-frugal
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Optional extras:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install httpx-frugal[http2] # HTTP/2 inner transport
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quickstart (sync)
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import pathlib
|
|
52
|
+
|
|
53
|
+
from pyrate_limiter import Duration, Rate
|
|
54
|
+
|
|
55
|
+
from httpx_frugal import RateLimitedCacheClient
|
|
56
|
+
|
|
57
|
+
rates = [Rate(5, Duration.MINUTE)]
|
|
58
|
+
cache_db = pathlib.Path("~/.cache/myapp/http-cache.sqlite").expanduser()
|
|
59
|
+
rate_db = pathlib.Path("~/.cache/myapp/rate-limiter.sqlite").expanduser()
|
|
60
|
+
cache_db.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
rate_db.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
client_wrapper = RateLimitedCacheClient(
|
|
64
|
+
rates=rates,
|
|
65
|
+
cache_db_path=cache_db,
|
|
66
|
+
rate_limiter_db_path=rate_db,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if client_wrapper.would_hit_cache("https://api.example.com/data"):
|
|
70
|
+
print("served from cache on next request")
|
|
71
|
+
|
|
72
|
+
with client_wrapper as client:
|
|
73
|
+
response = client.get("https://api.example.com/data")
|
|
74
|
+
print(response.extensions.get("hishel_from_cache"))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Async
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from httpx_frugal import AsyncRateLimitedCacheClient
|
|
81
|
+
|
|
82
|
+
async with AsyncRateLimitedCacheClient(
|
|
83
|
+
rates=rates,
|
|
84
|
+
cache_db_path=cache_db,
|
|
85
|
+
rate_limiter_db_path=rate_db,
|
|
86
|
+
) as client:
|
|
87
|
+
response = await client.get("https://api.example.com/data")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Blocking mode
|
|
91
|
+
|
|
92
|
+
Wait for a rate-limit token instead of raising immediately:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
client_wrapper = RateLimitedCacheClient(
|
|
96
|
+
rates=rates,
|
|
97
|
+
cache_db_path=cache_db,
|
|
98
|
+
rate_limiter_db_path=rate_db,
|
|
99
|
+
blocking=True,
|
|
100
|
+
rate_limit_timeout_seconds=30.0,
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Per-request cache TTL
|
|
105
|
+
|
|
106
|
+
Override TTL for a single request via httpx extensions:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
with client_wrapper as client:
|
|
110
|
+
client.get("https://api.example.com/short-lived", extensions={"hishel_ttl": 60})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Or use the helper:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from httpx_frugal import request_with_ttl
|
|
117
|
+
|
|
118
|
+
with client_wrapper as client:
|
|
119
|
+
req = request_with_ttl(client, "GET", "https://api.example.com/x", ttl=120)
|
|
120
|
+
client.send(req)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Rate limit introspection
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
remaining = client_wrapper.tokens_available("api.example.com")
|
|
127
|
+
# e.g. {"5/60000s": 3}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Cache invalidation
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
client_wrapper.clear_cache() # all entries
|
|
134
|
+
client_wrapper.clear_cache_for("https://...") # one URL
|
|
135
|
+
client_wrapper.clear_rate_limiter() # reset tokens
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Multi-process pipelines
|
|
139
|
+
|
|
140
|
+
Enable SQLite file locking on the rate limiter bucket:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
RateLimitedCacheClient(..., use_file_lock=True)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## `would_hit_cache` caveat
|
|
147
|
+
|
|
148
|
+
`would_hit_cache()` uses hishel internal APIs and may break if hishel changes them. Prefer checking `response.extensions["hishel_from_cache"]` after requests when possible. httpx-frugal pins `hishel>=1.2,<2`.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
uv sync
|
|
154
|
+
uv run ruff check .
|
|
155
|
+
uv run pytest
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# httpx-frugal
|
|
2
|
+
|
|
3
|
+
An [httpx](https://www.python-httpx.org/) client with persistent caching ([hishel](https://hishel.com/)) and per-domain rate limiting ([pyrate-limiter](https://pyratelimiter.readthedocs.io/)), designed for data pipelines that need to conserve API quota across process restarts.
|
|
4
|
+
|
|
5
|
+
Cache is checked **before** rate limiting, so cached responses do not consume rate-limit tokens.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install httpx-frugal
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Optional extras:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install httpx-frugal[http2] # HTTP/2 inner transport
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart (sync)
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import pathlib
|
|
23
|
+
|
|
24
|
+
from pyrate_limiter import Duration, Rate
|
|
25
|
+
|
|
26
|
+
from httpx_frugal import RateLimitedCacheClient
|
|
27
|
+
|
|
28
|
+
rates = [Rate(5, Duration.MINUTE)]
|
|
29
|
+
cache_db = pathlib.Path("~/.cache/myapp/http-cache.sqlite").expanduser()
|
|
30
|
+
rate_db = pathlib.Path("~/.cache/myapp/rate-limiter.sqlite").expanduser()
|
|
31
|
+
cache_db.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
rate_db.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
client_wrapper = RateLimitedCacheClient(
|
|
35
|
+
rates=rates,
|
|
36
|
+
cache_db_path=cache_db,
|
|
37
|
+
rate_limiter_db_path=rate_db,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if client_wrapper.would_hit_cache("https://api.example.com/data"):
|
|
41
|
+
print("served from cache on next request")
|
|
42
|
+
|
|
43
|
+
with client_wrapper as client:
|
|
44
|
+
response = client.get("https://api.example.com/data")
|
|
45
|
+
print(response.extensions.get("hishel_from_cache"))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Async
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from httpx_frugal import AsyncRateLimitedCacheClient
|
|
52
|
+
|
|
53
|
+
async with AsyncRateLimitedCacheClient(
|
|
54
|
+
rates=rates,
|
|
55
|
+
cache_db_path=cache_db,
|
|
56
|
+
rate_limiter_db_path=rate_db,
|
|
57
|
+
) as client:
|
|
58
|
+
response = await client.get("https://api.example.com/data")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Blocking mode
|
|
62
|
+
|
|
63
|
+
Wait for a rate-limit token instead of raising immediately:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
client_wrapper = RateLimitedCacheClient(
|
|
67
|
+
rates=rates,
|
|
68
|
+
cache_db_path=cache_db,
|
|
69
|
+
rate_limiter_db_path=rate_db,
|
|
70
|
+
blocking=True,
|
|
71
|
+
rate_limit_timeout_seconds=30.0,
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Per-request cache TTL
|
|
76
|
+
|
|
77
|
+
Override TTL for a single request via httpx extensions:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
with client_wrapper as client:
|
|
81
|
+
client.get("https://api.example.com/short-lived", extensions={"hishel_ttl": 60})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or use the helper:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from httpx_frugal import request_with_ttl
|
|
88
|
+
|
|
89
|
+
with client_wrapper as client:
|
|
90
|
+
req = request_with_ttl(client, "GET", "https://api.example.com/x", ttl=120)
|
|
91
|
+
client.send(req)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Rate limit introspection
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
remaining = client_wrapper.tokens_available("api.example.com")
|
|
98
|
+
# e.g. {"5/60000s": 3}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Cache invalidation
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
client_wrapper.clear_cache() # all entries
|
|
105
|
+
client_wrapper.clear_cache_for("https://...") # one URL
|
|
106
|
+
client_wrapper.clear_rate_limiter() # reset tokens
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Multi-process pipelines
|
|
110
|
+
|
|
111
|
+
Enable SQLite file locking on the rate limiter bucket:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
RateLimitedCacheClient(..., use_file_lock=True)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## `would_hit_cache` caveat
|
|
118
|
+
|
|
119
|
+
`would_hit_cache()` uses hishel internal APIs and may break if hishel changes them. Prefer checking `response.extensions["hishel_from_cache"]` after requests when possible. httpx-frugal pins `hishel>=1.2,<2`.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv sync
|
|
125
|
+
uv run ruff check .
|
|
126
|
+
uv run pytest
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
""" http_client.py """
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sqlite3
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import pathlib
|
|
7
|
+
|
|
8
|
+
# Third Party Libraries
|
|
9
|
+
from hishel import FilterPolicy, SyncSqliteStorage
|
|
10
|
+
from hishel.httpx import SyncCacheTransport
|
|
11
|
+
from hishel._core._spec import vary_headers_match
|
|
12
|
+
from hishel._sync_httpx import _httpx_to_internal
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pyrate_limiter import Limiter, Rate, SQLiteBucket, validate_rate_list
|
|
16
|
+
|
|
17
|
+
# ------------------------------------------------------------
|
|
18
|
+
# Composable Transports
|
|
19
|
+
# https://www.python-httpx.org/advanced/transports/#custom-transports
|
|
20
|
+
#
|
|
21
|
+
# RateLimitedCacheClient
|
|
22
|
+
# └── CacheTransport (hishel) ← checked first
|
|
23
|
+
# └── RateLimitedTransport ← only called on cache miss
|
|
24
|
+
# └── HTTPTransport(http2=True)
|
|
25
|
+
#
|
|
26
|
+
# ------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AlwaysCachePolicy(FilterPolicy):
|
|
31
|
+
"""Cache all responses using storage TTL; ignore RFC 9111 freshness headers."""
|
|
32
|
+
|
|
33
|
+
request_filters: list = field(default_factory=list)
|
|
34
|
+
response_filters: list = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HTTPRateLimitError(Exception):
|
|
38
|
+
"""
|
|
39
|
+
Exception raised when the rate limit is exceeded.
|
|
40
|
+
"""
|
|
41
|
+
def __init__(self, message: str):
|
|
42
|
+
self.message = message
|
|
43
|
+
super().__init__(self.message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DomainRateLimitedTransport(httpx.BaseTransport):
|
|
47
|
+
def __init__(
|
|
48
|
+
|
|
49
|
+
self,
|
|
50
|
+
limiter: Limiter,
|
|
51
|
+
inner_transport: httpx.BaseTransport,
|
|
52
|
+
timeout_seconds: float = 10.0,
|
|
53
|
+
blocking: bool = False,
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Initialize the transport.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
limiter : :class:`~pyrate_limiter.Limiter`
|
|
61
|
+
Limiter used to control request rate.
|
|
62
|
+
inner_transport : :class:`httpx.BaseTransport`
|
|
63
|
+
Inner transport to use for the request.
|
|
64
|
+
timeout_seconds : float
|
|
65
|
+
Timeout in seconds for acquiring a token.
|
|
66
|
+
blocking : bool
|
|
67
|
+
Whether to block the request until the token is acquired.
|
|
68
|
+
"""
|
|
69
|
+
self.limiter = limiter
|
|
70
|
+
self.inner_transport = inner_transport
|
|
71
|
+
self.timeout: float = timeout_seconds if blocking else -1 # cannot have a timeout value if not blocking
|
|
72
|
+
self.blocking: bool = blocking
|
|
73
|
+
|
|
74
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
75
|
+
# Extract domain as the rate limit identity
|
|
76
|
+
domain_name: str = request.url.host # example: "datahenge.com" or "brianpond.com"
|
|
77
|
+
|
|
78
|
+
# Acquire token with explicit timeout
|
|
79
|
+
success = self.limiter.try_acquire(
|
|
80
|
+
name=domain_name,
|
|
81
|
+
blocking=self.blocking,
|
|
82
|
+
timeout=self.timeout
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not success:
|
|
86
|
+
raise HTTPRateLimitError(
|
|
87
|
+
f"Rate limit exceeded for domain name = {domain_name}, "
|
|
88
|
+
f"could not acquire token within {self.timeout}s"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Proceed with actual HTTP request
|
|
92
|
+
return self.inner_transport.handle_request(request)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RateLimitedCacheClient:
|
|
96
|
+
"""
|
|
97
|
+
An httpx client for making HTTP requests with rate limiting and caching.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def get_default_cache_ttl() -> int:
|
|
102
|
+
return 300 # 5 minutes
|
|
103
|
+
|
|
104
|
+
def __init__(self,
|
|
105
|
+
rates: list[Rate],
|
|
106
|
+
cache_db_path: pathlib.Path,
|
|
107
|
+
rate_limiter_db_path: pathlib.Path,
|
|
108
|
+
cache_ttl: int = get_default_cache_ttl()):
|
|
109
|
+
"""
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
rates : list[Rate]
|
|
113
|
+
The rates to use for rate limiting.
|
|
114
|
+
cache_db_path : pathlib.Path
|
|
115
|
+
The path to the SQLite database to use for caching.
|
|
116
|
+
rate_limiter_db_path : pathlib.Path
|
|
117
|
+
The path to the SQLite database to use for rate limiting.
|
|
118
|
+
cache_ttl : int
|
|
119
|
+
The TTL for the cache in seconds.
|
|
120
|
+
"""
|
|
121
|
+
self._rates = rates
|
|
122
|
+
self._cache_ttl = cache_ttl
|
|
123
|
+
self._client: httpx.Client | None = None
|
|
124
|
+
self.__init_paths(cache_db_path, rate_limiter_db_path)
|
|
125
|
+
|
|
126
|
+
def __init_paths(self, cache_db_path: pathlib.Path, rate_limiter_db_path: pathlib.Path):
|
|
127
|
+
# Cache database path
|
|
128
|
+
if not cache_db_path.parent.exists():
|
|
129
|
+
raise FileNotFoundError(f"Cache database directory does not exist: {self._cache_db_path.parent}")
|
|
130
|
+
if cache_db_path.exists() and not cache_db_path.is_file():
|
|
131
|
+
raise FileExistsError(f"Cache database is not a file: {cache_db_path}")
|
|
132
|
+
self._cache_db_path = cache_db_path
|
|
133
|
+
|
|
134
|
+
# Rate limiter database path
|
|
135
|
+
if not rate_limiter_db_path.parent.exists():
|
|
136
|
+
raise FileNotFoundError(f"Rate limiter database directory does not exist: {rate_limiter_db_path.parent}")
|
|
137
|
+
if rate_limiter_db_path.exists() and not rate_limiter_db_path.is_file():
|
|
138
|
+
raise FileExistsError(f"Rate limiter database is not a file: {rate_limiter_db_path}")
|
|
139
|
+
self._rate_limiter_db_path = rate_limiter_db_path
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> httpx.Client: # returns the real httpx.Client
|
|
142
|
+
self._client = self._build_client()
|
|
143
|
+
return self._client # caller gets full httpx.Client
|
|
144
|
+
|
|
145
|
+
def __exit__(self, *args):
|
|
146
|
+
if self._client:
|
|
147
|
+
self._client.close()
|
|
148
|
+
self._client = None
|
|
149
|
+
|
|
150
|
+
def _cache_storage(self) -> SyncSqliteStorage:
|
|
151
|
+
return SyncSqliteStorage(
|
|
152
|
+
database_path=self._cache_db_path,
|
|
153
|
+
default_ttl=self._cache_ttl,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _rate_limiter_bucket(self) -> SQLiteBucket:
|
|
157
|
+
# NOTE: db_path must be set explicitly; otherwise the bucket uses a temp file
|
|
158
|
+
# deleted when the process exits. Set use_file_lock=True for multi-process use.
|
|
159
|
+
return SQLiteBucket.init_from_file(
|
|
160
|
+
self._rates,
|
|
161
|
+
db_path=str(self._rate_limiter_db_path),
|
|
162
|
+
use_file_lock=False,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _cache_key_for_url(url: str) -> str:
|
|
167
|
+
return hashlib.sha256(url.encode("utf-8")).hexdigest()
|
|
168
|
+
|
|
169
|
+
def clear_cache(self) -> None:
|
|
170
|
+
"""Remove all cached HTTP responses from the hishel SQLite database."""
|
|
171
|
+
if not self._cache_db_path.exists():
|
|
172
|
+
return
|
|
173
|
+
conn = sqlite3.connect(self._cache_db_path)
|
|
174
|
+
try:
|
|
175
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
176
|
+
conn.execute("DELETE FROM entries")
|
|
177
|
+
conn.commit()
|
|
178
|
+
finally:
|
|
179
|
+
conn.close()
|
|
180
|
+
|
|
181
|
+
def clear_rate_limiter(self) -> None:
|
|
182
|
+
"""Remove all rate-limit token records from the pyrate-limiter SQLite database."""
|
|
183
|
+
if not self._rate_limiter_db_path.exists():
|
|
184
|
+
return
|
|
185
|
+
bucket = self._rate_limiter_bucket()
|
|
186
|
+
try:
|
|
187
|
+
bucket.flush()
|
|
188
|
+
finally:
|
|
189
|
+
bucket.close()
|
|
190
|
+
|
|
191
|
+
def would_hit_cache(self, url: str, *, method: str = "GET", params: dict | None = None) -> bool:
|
|
192
|
+
"""
|
|
193
|
+
Return whether FilterPolicy would serve this request from cache.
|
|
194
|
+
|
|
195
|
+
Mirrors the lookup in hishel's ``_handle_request_with_filters`` using
|
|
196
|
+
the same storage path and TTL as :meth:`_build_client`.
|
|
197
|
+
"""
|
|
198
|
+
if params is None:
|
|
199
|
+
params = {}
|
|
200
|
+
|
|
201
|
+
with httpx.Client() as httpx_client:
|
|
202
|
+
httpx_request = httpx_client.build_request(method, url=url, params=params)
|
|
203
|
+
hishel_request = _httpx_to_internal(httpx_request)
|
|
204
|
+
|
|
205
|
+
storage = self._cache_storage()
|
|
206
|
+
try:
|
|
207
|
+
cache_key = self._cache_key_for_url(str(hishel_request.url))
|
|
208
|
+
entries = storage.get_entries(cache_key)
|
|
209
|
+
for entry in entries:
|
|
210
|
+
if (
|
|
211
|
+
str(entry.request.url) == str(hishel_request.url)
|
|
212
|
+
and entry.request.method == hishel_request.method
|
|
213
|
+
and vary_headers_match(hishel_request, entry)
|
|
214
|
+
):
|
|
215
|
+
return True
|
|
216
|
+
return False
|
|
217
|
+
finally:
|
|
218
|
+
storage.close()
|
|
219
|
+
|
|
220
|
+
def _build_client(self) -> httpx.Client:
|
|
221
|
+
"""
|
|
222
|
+
Build the transport chain
|
|
223
|
+
"""
|
|
224
|
+
# Part One: Rate limiting
|
|
225
|
+
|
|
226
|
+
# NOTE: Rates must be properly ordered:
|
|
227
|
+
# Rates' intervals & limits must be ordered from least to greatest
|
|
228
|
+
# Rates' ratio of limit/interval must be ordered from greatest to least
|
|
229
|
+
# Buckets validate rates during initialization. If using a custom implementation, use the built-in validator:
|
|
230
|
+
assert validate_rate_list(self._rates)
|
|
231
|
+
|
|
232
|
+
bucket = self._rate_limiter_bucket()
|
|
233
|
+
rate_limiter = Limiter(bucket) # rate limit layer (only invoked on cache miss)
|
|
234
|
+
|
|
235
|
+
# NOTE: prerequisites for http2=True = install httpx with the http2 extra: `pip install httpx[http2]`
|
|
236
|
+
rate_transport = DomainRateLimitedTransport(limiter=rate_limiter,
|
|
237
|
+
inner_transport=httpx.HTTPTransport(http2=True)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# ----------------
|
|
241
|
+
# Part Two: Cache Setup
|
|
242
|
+
# ----------------
|
|
243
|
+
|
|
244
|
+
cache_transport = SyncCacheTransport(
|
|
245
|
+
next_transport=rate_transport,
|
|
246
|
+
storage=self._cache_storage(),
|
|
247
|
+
policy=AlwaysCachePolicy(),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# NOTE: Storage also respects the `hishel_ttl` request metadata, useful to set a custom TTL for a specific request.
|
|
251
|
+
return httpx.Client(transport=cache_transport)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_http_client():
|
|
255
|
+
"""
|
|
256
|
+
Get a recipe from the DummyJSON API.
|
|
257
|
+
|
|
258
|
+
If you change the recipe number:
|
|
259
|
+
1. The cache might be missed, depending on the default TTL.
|
|
260
|
+
2. If you rapidly make multiple requests, each with a different recipe number, you might exceed the rate limit.
|
|
261
|
+
"""
|
|
262
|
+
import sys
|
|
263
|
+
from pyrate_limiter import Duration, Rate
|
|
264
|
+
|
|
265
|
+
recipe_number = int(sys.argv[1]) if len(sys.argv) > 1 else 1
|
|
266
|
+
recipe_url = f"https://dummyjson.com/recipes/{recipe_number}"
|
|
267
|
+
print(f"Recipe URL: {recipe_url}")
|
|
268
|
+
|
|
269
|
+
minute_rate = Rate(5, Duration.MINUTE) # 5 requests per minute
|
|
270
|
+
hourly_rate = Rate(15, Duration.HOUR) # 15 requests per hour
|
|
271
|
+
days_rate = Rate(100, Duration.DAY * 2) # 100 requests per 2 days
|
|
272
|
+
my_rates = [minute_rate, hourly_rate, days_rate]
|
|
273
|
+
|
|
274
|
+
my_cache_database = pathlib.Path.home() / ".cache/cascadia-watch/http-cache.sqlite"
|
|
275
|
+
my_rate_limiter_database = pathlib.Path.home() / ".cache/cascadia-watch/rate-limiter.sqlite"
|
|
276
|
+
|
|
277
|
+
cache_client = RateLimitedCacheClient(
|
|
278
|
+
rates=my_rates,
|
|
279
|
+
cache_db_path=my_cache_database,
|
|
280
|
+
rate_limiter_db_path=my_rate_limiter_database,
|
|
281
|
+
)
|
|
282
|
+
print(f"\nWould hit cache (before request) = {cache_client.would_hit_cache(recipe_url)}")
|
|
283
|
+
|
|
284
|
+
with cache_client as http_client:
|
|
285
|
+
response = http_client.get(recipe_url)
|
|
286
|
+
print(f"Response was from cache = {response.extensions['hishel_from_cache']}")
|
|
287
|
+
print(f"Response status code = {response.status_code}")
|
|
288
|
+
# print(f"Response headers = {response.headers}")
|
|
289
|
+
print(f"ResponseCache-Control: {response.headers.get('cache-control', 'not set')}")
|
|
290
|
+
print(f"Response content = {response.text}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
test_http_client()
|
|
295
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "httpx-frugal"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "An httpx client with persistent caching and rate limiting for data pipelines"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "httpx-frugal contributors" }]
|
|
9
|
+
keywords = ["httpx", "cache", "rate-limit", "hishel", "pyrate-limiter"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.27,<1",
|
|
23
|
+
"hishel[async]>=1.2,<2",
|
|
24
|
+
"pyrate-limiter>=4,<5",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
http2 = ["httpx[http2]"]
|
|
29
|
+
async = ["hishel[async]"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/httpx-frugal/httpx-frugal"
|
|
33
|
+
Documentation = "https://github.com/httpx-frugal/httpx-frugal#readme"
|
|
34
|
+
Repository = "https://github.com/httpx-frugal/httpx-frugal"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/httpx_frugal"]
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=8,<9",
|
|
46
|
+
"pytest-asyncio>=0.24,<1",
|
|
47
|
+
"ruff>=0.8,<1",
|
|
48
|
+
]
|
|
49
|
+
publish = [
|
|
50
|
+
"twine>=6.2.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
target-version = "py311"
|
|
55
|
+
line-length = 100
|
|
56
|
+
src = ["src", "tests"]
|
|
57
|
+
extend-exclude = ["original_source_code.py"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
pythonpath = ["tests"]
|
|
65
|
+
asyncio_mode = "auto"
|
|
66
|
+
asyncio_default_fixture_loop_scope = "function"
|