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.
@@ -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
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue) ![License](https://img.shields.io/badge/license-MIT-green)
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
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue) ![License](https://img.shields.io/badge/license-MIT-green)
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.