politeclient 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.
- politeclient-0.1.0/.gitignore +36 -0
- politeclient-0.1.0/LICENSE +21 -0
- politeclient-0.1.0/PKG-INFO +273 -0
- politeclient-0.1.0/README.md +245 -0
- politeclient-0.1.0/SECURITY.md +41 -0
- politeclient-0.1.0/examples/demo.py +128 -0
- politeclient-0.1.0/pyproject.toml +46 -0
- politeclient-0.1.0/src/politeclient/__init__.py +47 -0
- politeclient-0.1.0/src/politeclient/cache.py +360 -0
- politeclient-0.1.0/src/politeclient/client.py +448 -0
- politeclient-0.1.0/src/politeclient/decorator.py +43 -0
- politeclient-0.1.0/src/politeclient/exceptions.py +44 -0
- politeclient-0.1.0/src/politeclient/logging_utils.py +90 -0
- politeclient-0.1.0/src/politeclient/pagination.py +126 -0
- politeclient-0.1.0/src/politeclient/py.typed +0 -0
- politeclient-0.1.0/src/politeclient/ratelimit.py +131 -0
- politeclient-0.1.0/src/politeclient/retry.py +118 -0
- politeclient-0.1.0/tests/conftest.py +162 -0
- politeclient-0.1.0/tests/test_cache.py +384 -0
- politeclient-0.1.0/tests/test_client.py +593 -0
- politeclient-0.1.0/tests/test_decorator.py +28 -0
- politeclient-0.1.0/tests/test_logging.py +181 -0
- politeclient-0.1.0/tests/test_package.py +16 -0
- politeclient-0.1.0/tests/test_pagination.py +72 -0
- politeclient-0.1.0/tests/test_ratelimit.py +67 -0
- politeclient-0.1.0/tests/test_retry.py +65 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Virtual environments
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
env/
|
|
5
|
+
|
|
6
|
+
# Python bytecode
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*$py.class
|
|
10
|
+
*.so
|
|
11
|
+
|
|
12
|
+
# Packaging / build
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
.eggs/
|
|
17
|
+
pip-wheel-metadata/
|
|
18
|
+
|
|
19
|
+
# Test / coverage
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.coverage
|
|
22
|
+
.coverage.*
|
|
23
|
+
htmlcov/
|
|
24
|
+
.tox/
|
|
25
|
+
.mypy_cache/
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
|
|
28
|
+
# Caches produced by the demo / cache module
|
|
29
|
+
.politecache/
|
|
30
|
+
*.cache
|
|
31
|
+
|
|
32
|
+
# Editors / OS
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
*.swp
|
|
36
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fernando Aporta Franco (ferinazumaDEV)
|
|
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,273 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: politeclient
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A careful, well-behaved HTTP client: retries with backoff+jitter, Retry-After, a per-host rate-limit governor, disk cache, pagination and structured logging.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ferinazumaDEV/politeclient
|
|
6
|
+
Project-URL: Repository, https://github.com/ferinazumaDEV/politeclient
|
|
7
|
+
Project-URL: Issues, https://github.com/ferinazumaDEV/politeclient/issues
|
|
8
|
+
Author-email: Fernando Aporta Franco <ferinazumaDEV@users.noreply.github.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,backoff,client,http,pagination,rate-limit,requests,retry,scraping
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: requests<3,>=2.28
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest<9,>=7.4; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# politeclient
|
|
30
|
+
|
|
31
|
+
 
|
|
32
|
+
|
|
33
|
+
**A careful, well-behaved HTTP client for Python — every good-citizen behaviour you keep re-writing for each new API, in one small wrapper around `requests`.**
|
|
34
|
+
|
|
35
|
+
Retries with exponential backoff **and jitter**, `Retry-After` support, a per-host rate-limit governor, an honest default `User-Agent`, an optional on-disk GET cache, cursor & offset pagination, sane timeouts and structured logging — behind a clean, pythonic API.
|
|
36
|
+
|
|
37
|
+
It is a **building block for developers**, not a scraper. You bring the endpoints; politeclient makes sure your client behaves.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from politeclient import PoliteClient, RateLimit, RetryPolicy
|
|
41
|
+
|
|
42
|
+
with PoliteClient(
|
|
43
|
+
base_url="https://api.example.com",
|
|
44
|
+
rate_limit=RateLimit(rate=5), # 5 requests/second, per host
|
|
45
|
+
retry=RetryPolicy(max_retries=4), # backoff + jitter, honours Retry-After
|
|
46
|
+
cache="~/.cache/politeclient", # optional disk cache for GETs
|
|
47
|
+
) as client:
|
|
48
|
+
users = client.get("/users").json()
|
|
49
|
+
for post in client.paginate_cursor("/posts", items_key="data", cursor_key="paging.next"):
|
|
50
|
+
...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Why
|
|
56
|
+
|
|
57
|
+
Almost every "quick script that talks to an API" grows the same crufty appendages once it hits production: a retry loop that forgets jitter and stampedes the server, a `time.sleep(1)` that pretends to be rate limiting, a hand-rolled JSON cache with a race condition, and the eternal *"why do I get a 403 in my script but not my browser?"* (spoiler: your `User-Agent` says `python-requests/2.x`).
|
|
58
|
+
|
|
59
|
+
`politeclient` packages the correct version of each of those, once.
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
- **Retries done right** — exponential backoff with **full jitter** (the [AWS-recommended](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) anti-thundering-herd strategy), only for idempotent methods by default, capped and configurable.
|
|
64
|
+
- **`Retry-After` aware** — when a server tells you when to come back (seconds *or* an HTTP date), politeclient listens instead of guessing (the wait is clamped to `max_backoff`, 60 s by default).
|
|
65
|
+
- **Per-host rate-limit governor** — a thread-safe token bucket per host, so a slow, rate-limited API never starves a fast one. Supports sustained rate + bursts.
|
|
66
|
+
- **Honest default `User-Agent`** — sends an identifying UA instead of `python-requests/x.y.z`, the single most common cause of surprise `403`s. Override it with one kwarg.
|
|
67
|
+
- **Optional disk cache for GETs** — content-addressed, TTL'd, atomic writes. Iterate on a scraper without hammering the API every run. **Authenticated requests are not cached** unless you ask for it explicitly. It stores an allowlist of response headers only, skips `no-store` and `Vary` responses, and lets the server's `max-age`/`Expires` shorten the TTL — see [Cache limits](#cache-limits).
|
|
68
|
+
- **Pagination helpers** — lazy generators for both **cursor** and **offset/limit** APIs, with dotted-path extraction (`items_key="data.results"`).
|
|
69
|
+
- **Sane timeouts** — a request with no timeout can hang forever; politeclient defaults to `(5s connect, 30s read)`.
|
|
70
|
+
- **Structured logging** — every request, retry, wait and cache hit as a greppable `key=value` line, or newline-delimited JSON (`POLITECLIENT_LOG=json`).
|
|
71
|
+
- **Clean API** — context manager, verb shortcuts, and a `@polite` decorator.
|
|
72
|
+
- **Typed & tested** — full type hints, zero dependencies beyond `requests`, 92 tests against a local mock server.
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install politeclient # from PyPI (once published)
|
|
78
|
+
# or, from source:
|
|
79
|
+
pip install .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Requires Python 3.9+ and `requests`. That's the whole dependency tree.
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
### The client
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from politeclient import PoliteClient, RateLimit, RetryPolicy
|
|
90
|
+
|
|
91
|
+
client = PoliteClient(
|
|
92
|
+
base_url="https://api.github.com",
|
|
93
|
+
rate_limit=RateLimit(rate=10, per=1.0, burst=10), # 10/s, burst up to 10
|
|
94
|
+
retry=RetryPolicy(max_retries=5, backoff_factor=0.5, max_backoff=30),
|
|
95
|
+
user_agent="my-app/1.0 (+https://my-app.example)",
|
|
96
|
+
timeout=(5, 30),
|
|
97
|
+
)
|
|
98
|
+
resp = client.get("/repos/psf/requests")
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
print(resp.json()["stargazers_count"])
|
|
101
|
+
client.close()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`base_url` is joined to the path you pass with `urllib.parse.urljoin`, so the usual RFC 3986 rules apply. A bare host works either way, but a base that carries a path prefix must end with `/` and the paths must be relative:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
client = PoliteClient(base_url="https://api.example.com/v1/")
|
|
108
|
+
client.get("users") # -> https://api.example.com/v1/users
|
|
109
|
+
client.get("/users") # -> https://api.example.com/users (a leading slash resets to the host root)
|
|
110
|
+
# base_url="https://api.example.com/v1" (no trailing slash) drops the /v1 segment the same way.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
An absolute URL passed to a verb is used as-is.
|
|
114
|
+
|
|
115
|
+
Or as a context manager (recommended):
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
with PoliteClient(rate_limit=RateLimit(rate=5)) as client:
|
|
119
|
+
data = client.get("https://httpbin.org/json").json()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### The `@polite` decorator
|
|
123
|
+
|
|
124
|
+
When you'd rather not pass a client around, `@polite` builds one and injects it:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from politeclient import polite, RateLimit
|
|
128
|
+
|
|
129
|
+
@polite(rate_limit=RateLimit(rate=5), base_url="https://api.example.com")
|
|
130
|
+
def fetch_user(client, user_id):
|
|
131
|
+
return client.get(f"/users/{user_id}").json()
|
|
132
|
+
|
|
133
|
+
fetch_user(42) # the shared, rate-limited client is injected
|
|
134
|
+
fetch_user.client.close() # ...and reachable when you're done
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Pagination
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# Offset / limit — stops automatically on the last (short) page:
|
|
141
|
+
for row in client.paginate_offset("/records", items_key="data", limit=100):
|
|
142
|
+
process(row)
|
|
143
|
+
|
|
144
|
+
# Cursor — follows the "next" token until it runs out:
|
|
145
|
+
for row in client.paginate_cursor("/feed", items_key="items", cursor_key="paging.next"):
|
|
146
|
+
process(row)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both are lazy generators, so `itertools.islice(...)` or an early `break` only fetches the pages you actually consume.
|
|
150
|
+
|
|
151
|
+
### Caching
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
with PoliteClient(cache="~/.cache/myscraper", cache_ttl=3600) as client:
|
|
155
|
+
a = client.get("/expensive") # network
|
|
156
|
+
b = client.get("/expensive") # served from disk
|
|
157
|
+
assert b.from_cache is True
|
|
158
|
+
fresh = client.get("/expensive", use_cache=False) # force network
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Cache limits
|
|
162
|
+
|
|
163
|
+
**Requests that carry credentials are not cached.** The cache key is built from
|
|
164
|
+
method, URL and params only — never from headers, so no credential ever reaches a
|
|
165
|
+
filename. The consequence is that two callers with different tokens would produce
|
|
166
|
+
the *same* key, and one could be served the other's personalised response. Rather
|
|
167
|
+
than put credentials in the key, `politeclient` skips the cache entirely when the
|
|
168
|
+
request carries credentials by any of the routes `requests` accepts: an explicit
|
|
169
|
+
`Authorization`, `Cookie`, `Proxy-Authorization` or `WWW-Authenticate` header on
|
|
170
|
+
the request or the session, a per-request `auth=` or `cookies=`, `session.auth`,
|
|
171
|
+
a non-empty session cookie jar (any cookie, for any domain — for example one set
|
|
172
|
+
by an earlier login response), and `~/.netrc` when the session's `trust_env` is
|
|
173
|
+
on (the `requests` default). The check is deliberately conservative: when in
|
|
174
|
+
doubt, nothing is written.
|
|
175
|
+
|
|
176
|
+
If you know a given authenticated response is identical for every caller, opt in
|
|
177
|
+
per request:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
client.request("GET", url, headers={"Authorization": token}, use_cache=True)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
That is a deliberate statement, not a default. Servers *should* mark unshareable
|
|
184
|
+
responses `no-store` or `Vary`, and those are honoured too — but many do not, so
|
|
185
|
+
the safe default does not depend on the server getting it right.
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
The cache is **off by default**; it only exists if you pass `cache=`. When you do turn it on, this is exactly what it is — a small private cache for iterating on a script, not an HTTP caching implementation:
|
|
189
|
+
|
|
190
|
+
- **The key is `method + URL + sorted(params)`, and nothing else.** No request headers, no cookies, no credentials go into it — which is also why a response that declares `Vary` is not cached at all: the key cannot tell one variant from another, so serving it back would risk handing you the wrong one.
|
|
191
|
+
- **Entries are plain, unencrypted JSON files** (the body is base64, which is encoding, not encryption) in the directory *you* choose. You own that path and its permissions — see [SECURITY.md](SECURITY.md).
|
|
192
|
+
- **Only these response headers are persisted:** `Content-Type`, `Content-Encoding`, `ETag`, `Last-Modified`, `Date`, `Vary`. Everything else — `Set-Cookie`, `Authorization`, `WWW-Authenticate` and any header nobody thought about — is dropped on the way to disk.
|
|
193
|
+
- **`Cache-Control: no-store` is honoured on write**, and `no-cache` is treated as "never fresh", because this cache cannot revalidate.
|
|
194
|
+
- **The server's `max-age` / `Expires` is an upper bound on your TTL.** The shorter of the two wins; `cache_ttl` can only make an entry expire *sooner* than the server said, never later.
|
|
195
|
+
- **It is not RFC 9111.** No revalidation with `ETag`/`Last-Modified`, no `stale-while-revalidate`, no shared-cache semantics. If you need those, put a real caching proxy in front.
|
|
196
|
+
|
|
197
|
+
## Demo
|
|
198
|
+
|
|
199
|
+
`examples/demo.py` is fully self-contained — it starts a local server that misbehaves on purpose (429s with `Retry-After`, offset pagination, a cacheable endpoint) and drives it. No network, no keys:
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
$ python examples/demo.py
|
|
203
|
+
|
|
204
|
+
=== Retries + Retry-After ===
|
|
205
|
+
final status: 200 body: {'ok': True} (server was hit 3x)
|
|
206
|
+
|
|
207
|
+
=== Rate-limit governor (5 req/s, burst 5) ===
|
|
208
|
+
8 requests took 0.60s (first 5 instant, then throttled to ~0.2s each)
|
|
209
|
+
|
|
210
|
+
=== Disk cache for GETs ===
|
|
211
|
+
call 1 from_cache=False body={'served_at_hit': 1}
|
|
212
|
+
call 2 from_cache=True body={'served_at_hit': 1} (server hit 1x total)
|
|
213
|
+
|
|
214
|
+
=== Offset pagination ===
|
|
215
|
+
collected 23 items across pages: [0, 1, 2, ..., 22]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The structured log lines it emits along the way (here in `key=value` mode):
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
[warning] event=retry method=GET url=.../rate-limited status=429 attempt=1 retry_after=1 sleep=1.0
|
|
222
|
+
[warning] event=retry method=GET url=.../rate-limited status=429 attempt=2 retry_after=1 sleep=1.0
|
|
223
|
+
[info] event=request method=GET url=.../rate-limited status=200 attempt=3 elapsed_ms=2.0
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
…or as JSON with `POLITECLIENT_LOG=json`:
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{"level":"warning","event":"retry","method":"GET","url":".../x","status":429,"attempt":1,"retry_after":"1","sleep":1.0}
|
|
230
|
+
{"level":"info","event":"request","method":"GET","url":".../x","status":200,"attempt":2,"elapsed_ms":1.9}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## How it works
|
|
234
|
+
|
|
235
|
+
Each `request()` runs through the same pipeline:
|
|
236
|
+
|
|
237
|
+
1. **Cache lookup** (GET only) — a content-addressed key over `method + url + sorted(params)`; a fresh hit short-circuits the whole thing and returns a response with `from_cache is True`. "Fresh" is the shorter of your TTL and the freshness the server declared.
|
|
238
|
+
2. **Rate-limit gate** — the request acquires a token from the host's bucket, blocking just long enough if the bucket is empty. Buckets refill lazily (no background threads): each acquire computes how many tokens *would* have dripped in since the last call.
|
|
239
|
+
3. **Send + evaluate** — on a retryable status (`429`, `5xx`) or a transient transport error (connection reset, timeout), it computes the next delay. `Retry-After` wins when present and valid (clamped to `max_backoff`, 60 s by default — raise it if your API asks for longer waits); otherwise it's `backoff_factor · 2ⁿ` capped at `max_backoff`, then **full jitter** picks a random point in `[0, that]`.
|
|
240
|
+
4. **Retry or return** — non-idempotent methods (`POST`) aren't retried by default, because retrying them can duplicate work. Once the budget is spent, an HTTP failure is returned as-is (so you can `raise_for_status()`), while a transport failure raises `RetryBudgetExceeded`.
|
|
241
|
+
5. **Store** — a successful GET is written to the cache atomically (temp file + `os.replace`), keeping only allowlisted headers and skipping responses marked `no-store` or `Vary` ([Cache limits](#cache-limits)).
|
|
242
|
+
|
|
243
|
+
The token bucket, retry policy, cache and pagination are each independent, importable pieces (`TokenBucket`, `RetryPolicy`, `DiskCache`, `paginate_cursor`), so you can reuse one without buying into the whole client.
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
pip install -e ".[dev]"
|
|
249
|
+
pytest # 92 tests, all offline
|
|
250
|
+
python examples/demo.py # the tour above
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The test suite spins up a small programmable HTTP server (`tests/conftest.py`) and scripts exact failure sequences — three 429s then a 200, a 500 storm, a `Retry-After` header, paginated datasets — so retries, backoff, rate limiting and caching are verified against real sockets, deterministically and without touching the network.
|
|
254
|
+
|
|
255
|
+
## Part of a family of small tools
|
|
256
|
+
|
|
257
|
+
politeclient is one of a family of small, focused building blocks I maintain for Python developers. Its good-citizen HTTP behaviour — honest `User-Agent`s, backoff and per-host rate-limiting — is also the baseline hygiene expected of well-behaved crawlers and AI bots, which is where it brushes lightly against technical GEO (generative engine optimization).
|
|
258
|
+
|
|
259
|
+
- [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
|
|
260
|
+
- [webhook-replay](https://github.com/ferinazumaDEV/webhook-replay) — capture a webhook once, then replay it at your local app as many times as you need; the other half of the "HTTP that behaves" toolkit.
|
|
261
|
+
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from any LLM: schema-validated JSON with tolerant repair and retries.
|
|
262
|
+
- [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
|
|
263
|
+
- Hub & writing: [zentimes.es](https://zentimes.es).
|
|
264
|
+
|
|
265
|
+
By [ferinazumaDEV](https://github.com/ferinazumaDEV).
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT — see [LICENSE](LICENSE).
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
*Built by Fernando Aporta Franco ([@ferinazumaDEV](https://github.com/ferinazumaDEV)).*
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# politeclient
|
|
2
|
+
|
|
3
|
+
 
|
|
4
|
+
|
|
5
|
+
**A careful, well-behaved HTTP client for Python — every good-citizen behaviour you keep re-writing for each new API, in one small wrapper around `requests`.**
|
|
6
|
+
|
|
7
|
+
Retries with exponential backoff **and jitter**, `Retry-After` support, a per-host rate-limit governor, an honest default `User-Agent`, an optional on-disk GET cache, cursor & offset pagination, sane timeouts and structured logging — behind a clean, pythonic API.
|
|
8
|
+
|
|
9
|
+
It is a **building block for developers**, not a scraper. You bring the endpoints; politeclient makes sure your client behaves.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from politeclient import PoliteClient, RateLimit, RetryPolicy
|
|
13
|
+
|
|
14
|
+
with PoliteClient(
|
|
15
|
+
base_url="https://api.example.com",
|
|
16
|
+
rate_limit=RateLimit(rate=5), # 5 requests/second, per host
|
|
17
|
+
retry=RetryPolicy(max_retries=4), # backoff + jitter, honours Retry-After
|
|
18
|
+
cache="~/.cache/politeclient", # optional disk cache for GETs
|
|
19
|
+
) as client:
|
|
20
|
+
users = client.get("/users").json()
|
|
21
|
+
for post in client.paginate_cursor("/posts", items_key="data", cursor_key="paging.next"):
|
|
22
|
+
...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Why
|
|
28
|
+
|
|
29
|
+
Almost every "quick script that talks to an API" grows the same crufty appendages once it hits production: a retry loop that forgets jitter and stampedes the server, a `time.sleep(1)` that pretends to be rate limiting, a hand-rolled JSON cache with a race condition, and the eternal *"why do I get a 403 in my script but not my browser?"* (spoiler: your `User-Agent` says `python-requests/2.x`).
|
|
30
|
+
|
|
31
|
+
`politeclient` packages the correct version of each of those, once.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Retries done right** — exponential backoff with **full jitter** (the [AWS-recommended](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) anti-thundering-herd strategy), only for idempotent methods by default, capped and configurable.
|
|
36
|
+
- **`Retry-After` aware** — when a server tells you when to come back (seconds *or* an HTTP date), politeclient listens instead of guessing (the wait is clamped to `max_backoff`, 60 s by default).
|
|
37
|
+
- **Per-host rate-limit governor** — a thread-safe token bucket per host, so a slow, rate-limited API never starves a fast one. Supports sustained rate + bursts.
|
|
38
|
+
- **Honest default `User-Agent`** — sends an identifying UA instead of `python-requests/x.y.z`, the single most common cause of surprise `403`s. Override it with one kwarg.
|
|
39
|
+
- **Optional disk cache for GETs** — content-addressed, TTL'd, atomic writes. Iterate on a scraper without hammering the API every run. **Authenticated requests are not cached** unless you ask for it explicitly. It stores an allowlist of response headers only, skips `no-store` and `Vary` responses, and lets the server's `max-age`/`Expires` shorten the TTL — see [Cache limits](#cache-limits).
|
|
40
|
+
- **Pagination helpers** — lazy generators for both **cursor** and **offset/limit** APIs, with dotted-path extraction (`items_key="data.results"`).
|
|
41
|
+
- **Sane timeouts** — a request with no timeout can hang forever; politeclient defaults to `(5s connect, 30s read)`.
|
|
42
|
+
- **Structured logging** — every request, retry, wait and cache hit as a greppable `key=value` line, or newline-delimited JSON (`POLITECLIENT_LOG=json`).
|
|
43
|
+
- **Clean API** — context manager, verb shortcuts, and a `@polite` decorator.
|
|
44
|
+
- **Typed & tested** — full type hints, zero dependencies beyond `requests`, 92 tests against a local mock server.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install politeclient # from PyPI (once published)
|
|
50
|
+
# or, from source:
|
|
51
|
+
pip install .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Requires Python 3.9+ and `requests`. That's the whole dependency tree.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
### The client
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from politeclient import PoliteClient, RateLimit, RetryPolicy
|
|
62
|
+
|
|
63
|
+
client = PoliteClient(
|
|
64
|
+
base_url="https://api.github.com",
|
|
65
|
+
rate_limit=RateLimit(rate=10, per=1.0, burst=10), # 10/s, burst up to 10
|
|
66
|
+
retry=RetryPolicy(max_retries=5, backoff_factor=0.5, max_backoff=30),
|
|
67
|
+
user_agent="my-app/1.0 (+https://my-app.example)",
|
|
68
|
+
timeout=(5, 30),
|
|
69
|
+
)
|
|
70
|
+
resp = client.get("/repos/psf/requests")
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
print(resp.json()["stargazers_count"])
|
|
73
|
+
client.close()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`base_url` is joined to the path you pass with `urllib.parse.urljoin`, so the usual RFC 3986 rules apply. A bare host works either way, but a base that carries a path prefix must end with `/` and the paths must be relative:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
client = PoliteClient(base_url="https://api.example.com/v1/")
|
|
80
|
+
client.get("users") # -> https://api.example.com/v1/users
|
|
81
|
+
client.get("/users") # -> https://api.example.com/users (a leading slash resets to the host root)
|
|
82
|
+
# base_url="https://api.example.com/v1" (no trailing slash) drops the /v1 segment the same way.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
An absolute URL passed to a verb is used as-is.
|
|
86
|
+
|
|
87
|
+
Or as a context manager (recommended):
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
with PoliteClient(rate_limit=RateLimit(rate=5)) as client:
|
|
91
|
+
data = client.get("https://httpbin.org/json").json()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### The `@polite` decorator
|
|
95
|
+
|
|
96
|
+
When you'd rather not pass a client around, `@polite` builds one and injects it:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from politeclient import polite, RateLimit
|
|
100
|
+
|
|
101
|
+
@polite(rate_limit=RateLimit(rate=5), base_url="https://api.example.com")
|
|
102
|
+
def fetch_user(client, user_id):
|
|
103
|
+
return client.get(f"/users/{user_id}").json()
|
|
104
|
+
|
|
105
|
+
fetch_user(42) # the shared, rate-limited client is injected
|
|
106
|
+
fetch_user.client.close() # ...and reachable when you're done
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Pagination
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
# Offset / limit — stops automatically on the last (short) page:
|
|
113
|
+
for row in client.paginate_offset("/records", items_key="data", limit=100):
|
|
114
|
+
process(row)
|
|
115
|
+
|
|
116
|
+
# Cursor — follows the "next" token until it runs out:
|
|
117
|
+
for row in client.paginate_cursor("/feed", items_key="items", cursor_key="paging.next"):
|
|
118
|
+
process(row)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Both are lazy generators, so `itertools.islice(...)` or an early `break` only fetches the pages you actually consume.
|
|
122
|
+
|
|
123
|
+
### Caching
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
with PoliteClient(cache="~/.cache/myscraper", cache_ttl=3600) as client:
|
|
127
|
+
a = client.get("/expensive") # network
|
|
128
|
+
b = client.get("/expensive") # served from disk
|
|
129
|
+
assert b.from_cache is True
|
|
130
|
+
fresh = client.get("/expensive", use_cache=False) # force network
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Cache limits
|
|
134
|
+
|
|
135
|
+
**Requests that carry credentials are not cached.** The cache key is built from
|
|
136
|
+
method, URL and params only — never from headers, so no credential ever reaches a
|
|
137
|
+
filename. The consequence is that two callers with different tokens would produce
|
|
138
|
+
the *same* key, and one could be served the other's personalised response. Rather
|
|
139
|
+
than put credentials in the key, `politeclient` skips the cache entirely when the
|
|
140
|
+
request carries credentials by any of the routes `requests` accepts: an explicit
|
|
141
|
+
`Authorization`, `Cookie`, `Proxy-Authorization` or `WWW-Authenticate` header on
|
|
142
|
+
the request or the session, a per-request `auth=` or `cookies=`, `session.auth`,
|
|
143
|
+
a non-empty session cookie jar (any cookie, for any domain — for example one set
|
|
144
|
+
by an earlier login response), and `~/.netrc` when the session's `trust_env` is
|
|
145
|
+
on (the `requests` default). The check is deliberately conservative: when in
|
|
146
|
+
doubt, nothing is written.
|
|
147
|
+
|
|
148
|
+
If you know a given authenticated response is identical for every caller, opt in
|
|
149
|
+
per request:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
client.request("GET", url, headers={"Authorization": token}, use_cache=True)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
That is a deliberate statement, not a default. Servers *should* mark unshareable
|
|
156
|
+
responses `no-store` or `Vary`, and those are honoured too — but many do not, so
|
|
157
|
+
the safe default does not depend on the server getting it right.
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
The cache is **off by default**; it only exists if you pass `cache=`. When you do turn it on, this is exactly what it is — a small private cache for iterating on a script, not an HTTP caching implementation:
|
|
161
|
+
|
|
162
|
+
- **The key is `method + URL + sorted(params)`, and nothing else.** No request headers, no cookies, no credentials go into it — which is also why a response that declares `Vary` is not cached at all: the key cannot tell one variant from another, so serving it back would risk handing you the wrong one.
|
|
163
|
+
- **Entries are plain, unencrypted JSON files** (the body is base64, which is encoding, not encryption) in the directory *you* choose. You own that path and its permissions — see [SECURITY.md](SECURITY.md).
|
|
164
|
+
- **Only these response headers are persisted:** `Content-Type`, `Content-Encoding`, `ETag`, `Last-Modified`, `Date`, `Vary`. Everything else — `Set-Cookie`, `Authorization`, `WWW-Authenticate` and any header nobody thought about — is dropped on the way to disk.
|
|
165
|
+
- **`Cache-Control: no-store` is honoured on write**, and `no-cache` is treated as "never fresh", because this cache cannot revalidate.
|
|
166
|
+
- **The server's `max-age` / `Expires` is an upper bound on your TTL.** The shorter of the two wins; `cache_ttl` can only make an entry expire *sooner* than the server said, never later.
|
|
167
|
+
- **It is not RFC 9111.** No revalidation with `ETag`/`Last-Modified`, no `stale-while-revalidate`, no shared-cache semantics. If you need those, put a real caching proxy in front.
|
|
168
|
+
|
|
169
|
+
## Demo
|
|
170
|
+
|
|
171
|
+
`examples/demo.py` is fully self-contained — it starts a local server that misbehaves on purpose (429s with `Retry-After`, offset pagination, a cacheable endpoint) and drives it. No network, no keys:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
$ python examples/demo.py
|
|
175
|
+
|
|
176
|
+
=== Retries + Retry-After ===
|
|
177
|
+
final status: 200 body: {'ok': True} (server was hit 3x)
|
|
178
|
+
|
|
179
|
+
=== Rate-limit governor (5 req/s, burst 5) ===
|
|
180
|
+
8 requests took 0.60s (first 5 instant, then throttled to ~0.2s each)
|
|
181
|
+
|
|
182
|
+
=== Disk cache for GETs ===
|
|
183
|
+
call 1 from_cache=False body={'served_at_hit': 1}
|
|
184
|
+
call 2 from_cache=True body={'served_at_hit': 1} (server hit 1x total)
|
|
185
|
+
|
|
186
|
+
=== Offset pagination ===
|
|
187
|
+
collected 23 items across pages: [0, 1, 2, ..., 22]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The structured log lines it emits along the way (here in `key=value` mode):
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
[warning] event=retry method=GET url=.../rate-limited status=429 attempt=1 retry_after=1 sleep=1.0
|
|
194
|
+
[warning] event=retry method=GET url=.../rate-limited status=429 attempt=2 retry_after=1 sleep=1.0
|
|
195
|
+
[info] event=request method=GET url=.../rate-limited status=200 attempt=3 elapsed_ms=2.0
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
…or as JSON with `POLITECLIENT_LOG=json`:
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{"level":"warning","event":"retry","method":"GET","url":".../x","status":429,"attempt":1,"retry_after":"1","sleep":1.0}
|
|
202
|
+
{"level":"info","event":"request","method":"GET","url":".../x","status":200,"attempt":2,"elapsed_ms":1.9}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## How it works
|
|
206
|
+
|
|
207
|
+
Each `request()` runs through the same pipeline:
|
|
208
|
+
|
|
209
|
+
1. **Cache lookup** (GET only) — a content-addressed key over `method + url + sorted(params)`; a fresh hit short-circuits the whole thing and returns a response with `from_cache is True`. "Fresh" is the shorter of your TTL and the freshness the server declared.
|
|
210
|
+
2. **Rate-limit gate** — the request acquires a token from the host's bucket, blocking just long enough if the bucket is empty. Buckets refill lazily (no background threads): each acquire computes how many tokens *would* have dripped in since the last call.
|
|
211
|
+
3. **Send + evaluate** — on a retryable status (`429`, `5xx`) or a transient transport error (connection reset, timeout), it computes the next delay. `Retry-After` wins when present and valid (clamped to `max_backoff`, 60 s by default — raise it if your API asks for longer waits); otherwise it's `backoff_factor · 2ⁿ` capped at `max_backoff`, then **full jitter** picks a random point in `[0, that]`.
|
|
212
|
+
4. **Retry or return** — non-idempotent methods (`POST`) aren't retried by default, because retrying them can duplicate work. Once the budget is spent, an HTTP failure is returned as-is (so you can `raise_for_status()`), while a transport failure raises `RetryBudgetExceeded`.
|
|
213
|
+
5. **Store** — a successful GET is written to the cache atomically (temp file + `os.replace`), keeping only allowlisted headers and skipping responses marked `no-store` or `Vary` ([Cache limits](#cache-limits)).
|
|
214
|
+
|
|
215
|
+
The token bucket, retry policy, cache and pagination are each independent, importable pieces (`TokenBucket`, `RetryPolicy`, `DiskCache`, `paginate_cursor`), so you can reuse one without buying into the whole client.
|
|
216
|
+
|
|
217
|
+
## Development
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pip install -e ".[dev]"
|
|
221
|
+
pytest # 92 tests, all offline
|
|
222
|
+
python examples/demo.py # the tour above
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The test suite spins up a small programmable HTTP server (`tests/conftest.py`) and scripts exact failure sequences — three 429s then a 200, a 500 storm, a `Retry-After` header, paginated datasets — so retries, backoff, rate limiting and caching are verified against real sockets, deterministically and without touching the network.
|
|
226
|
+
|
|
227
|
+
## Part of a family of small tools
|
|
228
|
+
|
|
229
|
+
politeclient is one of a family of small, focused building blocks I maintain for Python developers. Its good-citizen HTTP behaviour — honest `User-Agent`s, backoff and per-host rate-limiting — is also the baseline hygiene expected of well-behaved crawlers and AI bots, which is where it brushes lightly against technical GEO (generative engine optimization).
|
|
230
|
+
|
|
231
|
+
- [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
|
|
232
|
+
- [webhook-replay](https://github.com/ferinazumaDEV/webhook-replay) — capture a webhook once, then replay it at your local app as many times as you need; the other half of the "HTTP that behaves" toolkit.
|
|
233
|
+
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from any LLM: schema-validated JSON with tolerant repair and retries.
|
|
234
|
+
- [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
|
|
235
|
+
- Hub & writing: [zentimes.es](https://zentimes.es).
|
|
236
|
+
|
|
237
|
+
By [ferinazumaDEV](https://github.com/ferinazumaDEV).
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT — see [LICENSE](LICENSE).
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
*Built by Fernando Aporta Franco ([@ferinazumaDEV](https://github.com/ferinazumaDEV)).*
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
politeclient has no published release yet: `0.1.0` is the version declared in `pyproject.toml`, and the README notes that PyPI is still pending. Fixes land on `main`, and there is no long-term support branch.
|
|
6
|
+
|
|
7
|
+
## Reporting a problem
|
|
8
|
+
|
|
9
|
+
Please report privately first.
|
|
10
|
+
|
|
11
|
+
1. Preferred: GitHub's private vulnerability reporting on this repository — **Security → Report a vulnerability** ([direct link](https://github.com/ferinazumaDEV/politeclient/security/advisories/new)).
|
|
12
|
+
2. If that form is not available to you, [open an issue](https://github.com/ferinazumaDEV/politeclient/issues) saying only that you have a security report and how to reach you. **Do not put exploit details, tokens, cookies or raw logs in a public issue** — a private channel will be arranged from there.
|
|
13
|
+
|
|
14
|
+
Expect a first reply within a week. This is a small project maintained in spare time, so please be patient rather than surprised; there is no bounty programme.
|
|
15
|
+
|
|
16
|
+
When you report, the most useful things to include are the politeclient and Python versions, a minimal reproduction, and what an attacker gains.
|
|
17
|
+
|
|
18
|
+
## What politeclient writes to disk
|
|
19
|
+
|
|
20
|
+
**Nothing, unless you enable the cache.** `PoliteClient(cache=...)` is opt-in and off by default.
|
|
21
|
+
|
|
22
|
+
When it is enabled:
|
|
23
|
+
|
|
24
|
+
- Entries are one **plain JSON file per request** in the directory you pass. They are *not* encrypted; the response body is base64, which is an encoding, not a protection.
|
|
25
|
+
- Each file holds the status code, the response body, the final URL, the timestamp, the freshness the server declared, and only these response headers: `Content-Type`, `Content-Encoding`, `ETag`, `Last-Modified`, `Date`, `Vary`. Every other header — including `Set-Cookie`, `Authorization` and `WWW-Authenticate` — is dropped before anything is written.
|
|
26
|
+
- The cache key is a SHA-256 of `method + URL + sorted(params)`. Request headers and cookies are never part of it, so no credential ends up in a filename.
|
|
27
|
+
- Responses marked `Cache-Control: no-store`, and responses carrying a non-empty `Vary`, are not stored at all.
|
|
28
|
+
- File names are hashes, but the **directory listing still reveals how many entries exist**, and any body you fetch is readable by anyone who can read the directory.
|
|
29
|
+
|
|
30
|
+
**You choose the directory, so you own its permissions.** politeclient creates it with your process's default umask and does not change it. If the responses you cache are sensitive, put the cache somewhere only your user can read (for example `chmod 700` on the directory), or leave the cache off. `DiskCache.clear()` deletes every entry when you are done — only files named `<sha256>.json`, the shape every entry has, so anything else you keep in that directory is left alone.
|
|
31
|
+
|
|
32
|
+
## Other things worth knowing
|
|
33
|
+
|
|
34
|
+
- **Logs.** Structured log lines include the request method, the URL you passed and the status code. Query parameters supplied via `params=` are not logged, but a credential embedded directly in a URL string would be.
|
|
35
|
+
- **Redirects.** politeclient uses `requests`' default redirect handling; a redirect to another host is followed, and the cache stores the final URL.
|
|
36
|
+
- **TLS verification** is `requests`' default (on). politeclient never disables it for you.
|
|
37
|
+
- **No telemetry.** It makes no requests you did not ask for, and politeclient itself reads no configuration beyond the `POLITECLIENT_LOG` environment variable. The underlying `requests` session, however, honours `~/.netrc` (or the file named by `NETRC`) and the proxy and CA-bundle environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`) unless you pass a session with `trust_env=False`. Credentials that `requests` picks up from `~/.netrc` count as credentials for the cache rule above: such responses are not cached.
|
|
38
|
+
|
|
39
|
+
## Out of scope
|
|
40
|
+
|
|
41
|
+
Reports that a caller can hurt themselves — passing a cache directory that is world-readable, disabling TLS verification through `requests` themselves, or feeding the client a hostile URL on purpose — are documentation issues rather than vulnerabilities, but they are still welcome as issues.
|