beavercore 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.
- beavercore-0.1.0/.gitignore +23 -0
- beavercore-0.1.0/LICENSE +21 -0
- beavercore-0.1.0/PKG-INFO +260 -0
- beavercore-0.1.0/README.md +211 -0
- beavercore-0.1.0/beavercore/__init__.py +25 -0
- beavercore-0.1.0/beavercore/client.py +204 -0
- beavercore-0.1.0/beavercore/exceptions.py +59 -0
- beavercore-0.1.0/beavercore/retry.py +31 -0
- beavercore-0.1.0/example.py +73 -0
- beavercore-0.1.0/pyproject.toml +59 -0
- beavercore-0.1.0/tests/test_client.py +258 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.eggs/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
.env
|
|
10
|
+
.envrc
|
|
11
|
+
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
htmlcov/
|
|
16
|
+
.coverage
|
|
17
|
+
.coverage.*
|
|
18
|
+
|
|
19
|
+
.DS_Store
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
*.swp
|
|
23
|
+
uv.lock
|
beavercore-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kalyan Ram Chimmili
|
|
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,260 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: beavercore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An opinionated HTTP client framework on top of requests — retries, backoff, 429 handling, session refresh, typed exceptions, observability hooks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kalyanramchimmili/beaverCore
|
|
6
|
+
Project-URL: Repository, https://github.com/kalyanramchimmili/beaverCore
|
|
7
|
+
Project-URL: Issues, https://github.com/kalyanramchimmili/beaverCore/issues
|
|
8
|
+
Author: Kalyan Ram Chimmili
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Kalyan Ram Chimmili
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: client,framework,http,rate-limit,requests,retry
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
40
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
42
|
+
Requires-Python: >=3.10
|
|
43
|
+
Requires-Dist: requests<3,>=2.31
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: pytest<10,>=7.0; extra == 'dev'
|
|
46
|
+
Requires-Dist: responses<1,>=0.24; extra == 'dev'
|
|
47
|
+
Requires-Dist: ruff<1,>=0.4; extra == 'dev'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# beaverCore
|
|
51
|
+
|
|
52
|
+
[](https://pypi.org/project/beavercore/)
|
|
53
|
+
[](https://pypi.org/project/beavercore/)
|
|
54
|
+
[](LICENSE)
|
|
55
|
+
|
|
56
|
+
A HTTP client wrapper on top of `requests`. You bring the URL and the credentials — beaverCore handles the parts of every HTTP client you'd otherwise copy-paste: retries with exponential backoff, 429 rate-limit compliance, one-shot session-token refresh on 401, typed exceptions, connection pooling, and an observability hook.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install beavercore
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The idea
|
|
63
|
+
|
|
64
|
+
Every time someone writes a new HTTP client in Python — for an internal service, a vendor API, a third-party integration — they solve the same six problems:
|
|
65
|
+
|
|
66
|
+
1. Turning transient failures (5xx, connection resets, timeouts) into retries with backoff.
|
|
67
|
+
2. Respecting `Retry-After` when the upstream throttles them.
|
|
68
|
+
3. Refreshing an expired session token *once*, transparently, on a 401.
|
|
69
|
+
4. Distinguishing "auth broke" from "server sad" from "the request itself is wrong" without callers having to inspect status codes.
|
|
70
|
+
5. Pooling the underlying TCP connections instead of tearing them down every call.
|
|
71
|
+
6. Wiring the whole thing into logs/metrics without turning every method into a callback tree.
|
|
72
|
+
|
|
73
|
+
beavercore does all six once. Downstream clients become endpoint code and nothing else — the GitHub client in `example.py` is exactly that shape: a factory function plus the endpoints, no retry code, no error classification, no rate-limit code.
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from beavercore import Client, RetryPolicy
|
|
79
|
+
|
|
80
|
+
def auth(request_kwargs: dict) -> None:
|
|
81
|
+
request_kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {TOKEN}"
|
|
82
|
+
|
|
83
|
+
with Client(
|
|
84
|
+
"https://api.example.com",
|
|
85
|
+
auth=auth,
|
|
86
|
+
retry=RetryPolicy(max_attempts=4),
|
|
87
|
+
) as client:
|
|
88
|
+
things = client.get("/things").json()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Extension is via callables — no subclassing required. If you need to refresh a session token on a 401:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
def refresh() -> bool:
|
|
95
|
+
global TOKEN
|
|
96
|
+
TOKEN = login()
|
|
97
|
+
return True # tells beavercore to retry the original request once
|
|
98
|
+
|
|
99
|
+
Client("https://api.example.com", auth=auth, refresh=refresh)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`refresh` runs at most once per request. If the retry also 401s, you get `AuthError`.
|
|
103
|
+
|
|
104
|
+
## Exception hierarchy
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
HttpError base — carries .status, .response, .attempts
|
|
108
|
+
├── AuthError 401/403 (extra: .refresh_attempted)
|
|
109
|
+
├── RateLimitError 429 after retries exhausted (extra: .retry_after)
|
|
110
|
+
└── TransientError 5xx or network fault, retries exhausted (extra: .last_reason)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Every exception carries `.response` (the raw `requests.Response`, or `None` for network faults) and `.attempts` (how many tries were made). Non-retryable non-2xx that doesn't fit one of the subclasses (e.g. 404, 418, 400) raises the base `HttpError`.
|
|
114
|
+
|
|
115
|
+
## Design decisions
|
|
116
|
+
|
|
117
|
+
The choices worth explaining because they're not obvious from the code.
|
|
118
|
+
|
|
119
|
+
### Built on `requests`, not transport-agnostic
|
|
120
|
+
|
|
121
|
+
beavercore is a *policy* layer — retries, backoff, error classification, session refresh, observability. The *transport* is [`requests`](https://requests.readthedocs.io/). A transport-agnostic version (pluggable `requests`/`httpx`) is possible but doubles the surface area for one real use case (async, via `httpx`), so it's deferred. If you need async today, use `httpx` directly — that's the correct answer.
|
|
122
|
+
|
|
123
|
+
### Compose with callables, don't subclass
|
|
124
|
+
|
|
125
|
+
The old shape of this library required subclassing `BaseClient` and overriding `_apply_auth` / `_refresh_auth`. It's gone. `Client` takes `auth` and `refresh` as callables at construction. Reasons:
|
|
126
|
+
|
|
127
|
+
- **One class, one instance per upstream.** No `GitHubClient(BaseClient)` boilerplate per API.
|
|
128
|
+
- **Auth is state, not behavior.** Making it a callable makes token rotation, environment-driven auth, and testing trivial.
|
|
129
|
+
- **Composition scales.** Wrap the callable, don't subclass the class.
|
|
130
|
+
|
|
131
|
+
### Raise, don't return
|
|
132
|
+
|
|
133
|
+
Most Python HTTP wrappers return `{"success": bool, "response": ..., "error": ...}` dicts. beavercore raises typed exceptions instead. Reasons:
|
|
134
|
+
|
|
135
|
+
- **Composes with `try/except`.** Callers can catch `AuthError` in one place instead of checking `result["success"]` on every call.
|
|
136
|
+
- **Doesn't collide with success payloads.** If the API you're calling returns `{"success": false, ...}` as *data*, dict-based error contracts get confusing.
|
|
137
|
+
- **Matches the ecosystem.** `requests.HTTPError`, `httpx.HTTPStatusError`, `openai.APIError` — every mature Python HTTP library raises.
|
|
138
|
+
|
|
139
|
+
### Session refresh runs *once*, not forever
|
|
140
|
+
|
|
141
|
+
On a 401, the `refresh` callable runs exactly once. If it returns `True`, the original request is retried once. If the retry also 401s (or `refresh` returned `False`), you get `AuthError`. Reasons:
|
|
142
|
+
|
|
143
|
+
- **Bounded blast radius.** A misconfigured refresh function can't turn a single failed call into an infinite refresh loop.
|
|
144
|
+
- **Refresh failures surface immediately.** If the refresh itself is broken (bad refresh token, revoked session), you see it now, not after N retries.
|
|
145
|
+
- **Refresh is expensive.** Most upstreams charge for refresh calls (rate-limit budget, database write, OAuth roundtrip). Don't spam it.
|
|
146
|
+
|
|
147
|
+
### Retries only on genuinely retryable failures
|
|
148
|
+
|
|
149
|
+
Retryable: connection error, timeout, 500/502/503/504, 429. Not retryable: 4xx (except 401, which becomes a refresh path). Reasons:
|
|
150
|
+
|
|
151
|
+
- **4xx is your bug.** `400 Bad Request`, `403 Forbidden`, `422 Unprocessable Entity` — retrying won't fix them. Fail fast, surface loud.
|
|
152
|
+
- **5xx and network faults are upstream's bug.** Retrying with jitter is the right response.
|
|
153
|
+
- **429 is a special case.** Technically "your fault" for exceeding a quota, but the upstream's `Retry-After` header tells you exactly how to fix it.
|
|
154
|
+
|
|
155
|
+
### Bring-your-own rate limiter
|
|
156
|
+
|
|
157
|
+
`Client` accepts a `throttle: Callable[[], None]` argument. It's called before every attempt. That's the whole contract. Reasons:
|
|
158
|
+
|
|
159
|
+
- **Different upstreams have different rules.** Some are per-second, some per-minute, some per-user, some token-bucket, some sliding-window. One shipped limiter would only fit one shape.
|
|
160
|
+
- **Optional by default.** Most APIs don't advertise a client-side limit — you only add one when the upstream tells you or 429s you.
|
|
161
|
+
- **You already have one.** Every real project has a rate-limit primitive (Redis, in-memory semaphore, third-party lib). Pass it as a callable.
|
|
162
|
+
|
|
163
|
+
### One observer, not three hooks
|
|
164
|
+
|
|
165
|
+
The client emits events to a single `observer(event: dict)` callable. Events include `request`, `response`, `retry`, `auth_refresh`, each carrying `method`, `url`, `attempt`, and event-specific fields. Reasons:
|
|
166
|
+
|
|
167
|
+
- **One signal path, not three.** Log aggregation, tracing, and metrics all speak dict.
|
|
168
|
+
- **Extensible.** Adding a new event kind doesn't change the API.
|
|
169
|
+
- **Zero cost when unused.** `observer` defaults to `None`; the fast path has one `is not None` check.
|
|
170
|
+
|
|
171
|
+
## Constructor options
|
|
172
|
+
|
|
173
|
+
| arg | default | notes |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `base_url` | required | Trailing slash optional. |
|
|
176
|
+
| `auth` | `None` | `(request_kwargs) -> None`. Mutate to attach credentials. |
|
|
177
|
+
| `refresh` | `None` | `() -> bool`. Return `True` to retry once after a 401. |
|
|
178
|
+
| `retry` | `RetryPolicy()` | See below. |
|
|
179
|
+
| `throttle` | `None` | `() -> None`. Called before every attempt. |
|
|
180
|
+
| `observer` | `None` | `(event_dict) -> None`. Fires for `request`, `response`, `retry`, `auth_refresh`. |
|
|
181
|
+
| `timeout` | `30` | Seconds. Applied to every request. |
|
|
182
|
+
| `verify_ssl` | `True` | Set `False` for self-signed internal endpoints. |
|
|
183
|
+
| `session` | `None` | Provide a preconfigured `requests.Session` for custom adapters. |
|
|
184
|
+
|
|
185
|
+
## RetryPolicy
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
RetryPolicy(
|
|
189
|
+
max_attempts=3, # total attempts including the first
|
|
190
|
+
backoff_base=0.5, # seconds — multiplier for 2^attempt
|
|
191
|
+
backoff_cap=30.0, # seconds — upper bound on any single sleep
|
|
192
|
+
jitter=0.5, # 0 = no jitter, 1 = full jitter of backoff_base
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Immutable dataclass. `delay_for(attempt)` returns the sleep duration for a given attempt index.
|
|
197
|
+
|
|
198
|
+
## Observability
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
def observer(event: dict) -> None:
|
|
202
|
+
if event["event"] == "retry":
|
|
203
|
+
logger.warning(
|
|
204
|
+
"retrying %s %s (attempt %d): %s",
|
|
205
|
+
event["method"], event["url"], event["attempt"] + 1, event["reason"],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
client = Client("https://api.example.com", auth=auth, observer=observer)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
One hook, one signal path — enough to wire beavercore into any log aggregator, Prometheus histogram, or OpenTelemetry span.
|
|
212
|
+
|
|
213
|
+
## Worked example
|
|
214
|
+
|
|
215
|
+
`example.py` at the repo root is a small GitHub REST API client built on beavercore — bearer auth, real 429s, real 404s. It's the reference implementation for what a client on top of beavercore should look like. Try it:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
export GITHUB_TOKEN=ghp_your_token
|
|
219
|
+
python example.py
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Repository layout
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
beaverCore/
|
|
226
|
+
├── pyproject.toml # package + dev tools + all config
|
|
227
|
+
├── README.md # this file (also the PyPI description)
|
|
228
|
+
├── LICENSE
|
|
229
|
+
├── beavercore/
|
|
230
|
+
│ ├── __init__.py # public API re-exports
|
|
231
|
+
│ ├── client.py # Client
|
|
232
|
+
│ ├── retry.py # RetryPolicy
|
|
233
|
+
│ └── exceptions.py # HttpError, AuthError, RateLimitError, TransientError
|
|
234
|
+
├── tests/
|
|
235
|
+
│ └── test_client.py
|
|
236
|
+
├── example.py # runnable GitHub API demo
|
|
237
|
+
└── .github/workflows/
|
|
238
|
+
└── publish.yml # manual: Actions tab → Run workflow → PyPI
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Development
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
pip install -e ".[dev]"
|
|
245
|
+
pytest
|
|
246
|
+
ruff check .
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Publishing
|
|
250
|
+
|
|
251
|
+
Published to PyPI via GitHub Actions using [trusted publishing](https://docs.pypi.org/trusted-publishers/). Releases are **manual** — trigger the `publish` workflow from the Actions tab (click **Run workflow**). It publishes whatever version is set in `pyproject.toml` at that commit.
|
|
252
|
+
|
|
253
|
+
## Sibling projects
|
|
254
|
+
|
|
255
|
+
- [beaverWeb](https://github.com/kalyanramchimmili/beaverWeb) — A micro web framework for building the services beaverCore *calls*.
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
MIT
|
|
260
|
+
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# beaverCore
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/beavercore/)
|
|
4
|
+
[](https://pypi.org/project/beavercore/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
A HTTP client wrapper on top of `requests`. You bring the URL and the credentials — beaverCore handles the parts of every HTTP client you'd otherwise copy-paste: retries with exponential backoff, 429 rate-limit compliance, one-shot session-token refresh on 401, typed exceptions, connection pooling, and an observability hook.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install beavercore
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The idea
|
|
14
|
+
|
|
15
|
+
Every time someone writes a new HTTP client in Python — for an internal service, a vendor API, a third-party integration — they solve the same six problems:
|
|
16
|
+
|
|
17
|
+
1. Turning transient failures (5xx, connection resets, timeouts) into retries with backoff.
|
|
18
|
+
2. Respecting `Retry-After` when the upstream throttles them.
|
|
19
|
+
3. Refreshing an expired session token *once*, transparently, on a 401.
|
|
20
|
+
4. Distinguishing "auth broke" from "server sad" from "the request itself is wrong" without callers having to inspect status codes.
|
|
21
|
+
5. Pooling the underlying TCP connections instead of tearing them down every call.
|
|
22
|
+
6. Wiring the whole thing into logs/metrics without turning every method into a callback tree.
|
|
23
|
+
|
|
24
|
+
beavercore does all six once. Downstream clients become endpoint code and nothing else — the GitHub client in `example.py` is exactly that shape: a factory function plus the endpoints, no retry code, no error classification, no rate-limit code.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from beavercore import Client, RetryPolicy
|
|
30
|
+
|
|
31
|
+
def auth(request_kwargs: dict) -> None:
|
|
32
|
+
request_kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {TOKEN}"
|
|
33
|
+
|
|
34
|
+
with Client(
|
|
35
|
+
"https://api.example.com",
|
|
36
|
+
auth=auth,
|
|
37
|
+
retry=RetryPolicy(max_attempts=4),
|
|
38
|
+
) as client:
|
|
39
|
+
things = client.get("/things").json()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Extension is via callables — no subclassing required. If you need to refresh a session token on a 401:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
def refresh() -> bool:
|
|
46
|
+
global TOKEN
|
|
47
|
+
TOKEN = login()
|
|
48
|
+
return True # tells beavercore to retry the original request once
|
|
49
|
+
|
|
50
|
+
Client("https://api.example.com", auth=auth, refresh=refresh)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`refresh` runs at most once per request. If the retry also 401s, you get `AuthError`.
|
|
54
|
+
|
|
55
|
+
## Exception hierarchy
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
HttpError base — carries .status, .response, .attempts
|
|
59
|
+
├── AuthError 401/403 (extra: .refresh_attempted)
|
|
60
|
+
├── RateLimitError 429 after retries exhausted (extra: .retry_after)
|
|
61
|
+
└── TransientError 5xx or network fault, retries exhausted (extra: .last_reason)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every exception carries `.response` (the raw `requests.Response`, or `None` for network faults) and `.attempts` (how many tries were made). Non-retryable non-2xx that doesn't fit one of the subclasses (e.g. 404, 418, 400) raises the base `HttpError`.
|
|
65
|
+
|
|
66
|
+
## Design decisions
|
|
67
|
+
|
|
68
|
+
The choices worth explaining because they're not obvious from the code.
|
|
69
|
+
|
|
70
|
+
### Built on `requests`, not transport-agnostic
|
|
71
|
+
|
|
72
|
+
beavercore is a *policy* layer — retries, backoff, error classification, session refresh, observability. The *transport* is [`requests`](https://requests.readthedocs.io/). A transport-agnostic version (pluggable `requests`/`httpx`) is possible but doubles the surface area for one real use case (async, via `httpx`), so it's deferred. If you need async today, use `httpx` directly — that's the correct answer.
|
|
73
|
+
|
|
74
|
+
### Compose with callables, don't subclass
|
|
75
|
+
|
|
76
|
+
The old shape of this library required subclassing `BaseClient` and overriding `_apply_auth` / `_refresh_auth`. It's gone. `Client` takes `auth` and `refresh` as callables at construction. Reasons:
|
|
77
|
+
|
|
78
|
+
- **One class, one instance per upstream.** No `GitHubClient(BaseClient)` boilerplate per API.
|
|
79
|
+
- **Auth is state, not behavior.** Making it a callable makes token rotation, environment-driven auth, and testing trivial.
|
|
80
|
+
- **Composition scales.** Wrap the callable, don't subclass the class.
|
|
81
|
+
|
|
82
|
+
### Raise, don't return
|
|
83
|
+
|
|
84
|
+
Most Python HTTP wrappers return `{"success": bool, "response": ..., "error": ...}` dicts. beavercore raises typed exceptions instead. Reasons:
|
|
85
|
+
|
|
86
|
+
- **Composes with `try/except`.** Callers can catch `AuthError` in one place instead of checking `result["success"]` on every call.
|
|
87
|
+
- **Doesn't collide with success payloads.** If the API you're calling returns `{"success": false, ...}` as *data*, dict-based error contracts get confusing.
|
|
88
|
+
- **Matches the ecosystem.** `requests.HTTPError`, `httpx.HTTPStatusError`, `openai.APIError` — every mature Python HTTP library raises.
|
|
89
|
+
|
|
90
|
+
### Session refresh runs *once*, not forever
|
|
91
|
+
|
|
92
|
+
On a 401, the `refresh` callable runs exactly once. If it returns `True`, the original request is retried once. If the retry also 401s (or `refresh` returned `False`), you get `AuthError`. Reasons:
|
|
93
|
+
|
|
94
|
+
- **Bounded blast radius.** A misconfigured refresh function can't turn a single failed call into an infinite refresh loop.
|
|
95
|
+
- **Refresh failures surface immediately.** If the refresh itself is broken (bad refresh token, revoked session), you see it now, not after N retries.
|
|
96
|
+
- **Refresh is expensive.** Most upstreams charge for refresh calls (rate-limit budget, database write, OAuth roundtrip). Don't spam it.
|
|
97
|
+
|
|
98
|
+
### Retries only on genuinely retryable failures
|
|
99
|
+
|
|
100
|
+
Retryable: connection error, timeout, 500/502/503/504, 429. Not retryable: 4xx (except 401, which becomes a refresh path). Reasons:
|
|
101
|
+
|
|
102
|
+
- **4xx is your bug.** `400 Bad Request`, `403 Forbidden`, `422 Unprocessable Entity` — retrying won't fix them. Fail fast, surface loud.
|
|
103
|
+
- **5xx and network faults are upstream's bug.** Retrying with jitter is the right response.
|
|
104
|
+
- **429 is a special case.** Technically "your fault" for exceeding a quota, but the upstream's `Retry-After` header tells you exactly how to fix it.
|
|
105
|
+
|
|
106
|
+
### Bring-your-own rate limiter
|
|
107
|
+
|
|
108
|
+
`Client` accepts a `throttle: Callable[[], None]` argument. It's called before every attempt. That's the whole contract. Reasons:
|
|
109
|
+
|
|
110
|
+
- **Different upstreams have different rules.** Some are per-second, some per-minute, some per-user, some token-bucket, some sliding-window. One shipped limiter would only fit one shape.
|
|
111
|
+
- **Optional by default.** Most APIs don't advertise a client-side limit — you only add one when the upstream tells you or 429s you.
|
|
112
|
+
- **You already have one.** Every real project has a rate-limit primitive (Redis, in-memory semaphore, third-party lib). Pass it as a callable.
|
|
113
|
+
|
|
114
|
+
### One observer, not three hooks
|
|
115
|
+
|
|
116
|
+
The client emits events to a single `observer(event: dict)` callable. Events include `request`, `response`, `retry`, `auth_refresh`, each carrying `method`, `url`, `attempt`, and event-specific fields. Reasons:
|
|
117
|
+
|
|
118
|
+
- **One signal path, not three.** Log aggregation, tracing, and metrics all speak dict.
|
|
119
|
+
- **Extensible.** Adding a new event kind doesn't change the API.
|
|
120
|
+
- **Zero cost when unused.** `observer` defaults to `None`; the fast path has one `is not None` check.
|
|
121
|
+
|
|
122
|
+
## Constructor options
|
|
123
|
+
|
|
124
|
+
| arg | default | notes |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `base_url` | required | Trailing slash optional. |
|
|
127
|
+
| `auth` | `None` | `(request_kwargs) -> None`. Mutate to attach credentials. |
|
|
128
|
+
| `refresh` | `None` | `() -> bool`. Return `True` to retry once after a 401. |
|
|
129
|
+
| `retry` | `RetryPolicy()` | See below. |
|
|
130
|
+
| `throttle` | `None` | `() -> None`. Called before every attempt. |
|
|
131
|
+
| `observer` | `None` | `(event_dict) -> None`. Fires for `request`, `response`, `retry`, `auth_refresh`. |
|
|
132
|
+
| `timeout` | `30` | Seconds. Applied to every request. |
|
|
133
|
+
| `verify_ssl` | `True` | Set `False` for self-signed internal endpoints. |
|
|
134
|
+
| `session` | `None` | Provide a preconfigured `requests.Session` for custom adapters. |
|
|
135
|
+
|
|
136
|
+
## RetryPolicy
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
RetryPolicy(
|
|
140
|
+
max_attempts=3, # total attempts including the first
|
|
141
|
+
backoff_base=0.5, # seconds — multiplier for 2^attempt
|
|
142
|
+
backoff_cap=30.0, # seconds — upper bound on any single sleep
|
|
143
|
+
jitter=0.5, # 0 = no jitter, 1 = full jitter of backoff_base
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Immutable dataclass. `delay_for(attempt)` returns the sleep duration for a given attempt index.
|
|
148
|
+
|
|
149
|
+
## Observability
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
def observer(event: dict) -> None:
|
|
153
|
+
if event["event"] == "retry":
|
|
154
|
+
logger.warning(
|
|
155
|
+
"retrying %s %s (attempt %d): %s",
|
|
156
|
+
event["method"], event["url"], event["attempt"] + 1, event["reason"],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
client = Client("https://api.example.com", auth=auth, observer=observer)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
One hook, one signal path — enough to wire beavercore into any log aggregator, Prometheus histogram, or OpenTelemetry span.
|
|
163
|
+
|
|
164
|
+
## Worked example
|
|
165
|
+
|
|
166
|
+
`example.py` at the repo root is a small GitHub REST API client built on beavercore — bearer auth, real 429s, real 404s. It's the reference implementation for what a client on top of beavercore should look like. Try it:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
export GITHUB_TOKEN=ghp_your_token
|
|
170
|
+
python example.py
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Repository layout
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
beaverCore/
|
|
177
|
+
├── pyproject.toml # package + dev tools + all config
|
|
178
|
+
├── README.md # this file (also the PyPI description)
|
|
179
|
+
├── LICENSE
|
|
180
|
+
├── beavercore/
|
|
181
|
+
│ ├── __init__.py # public API re-exports
|
|
182
|
+
│ ├── client.py # Client
|
|
183
|
+
│ ├── retry.py # RetryPolicy
|
|
184
|
+
│ └── exceptions.py # HttpError, AuthError, RateLimitError, TransientError
|
|
185
|
+
├── tests/
|
|
186
|
+
│ └── test_client.py
|
|
187
|
+
├── example.py # runnable GitHub API demo
|
|
188
|
+
└── .github/workflows/
|
|
189
|
+
└── publish.yml # manual: Actions tab → Run workflow → PyPI
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
pip install -e ".[dev]"
|
|
196
|
+
pytest
|
|
197
|
+
ruff check .
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Publishing
|
|
201
|
+
|
|
202
|
+
Published to PyPI via GitHub Actions using [trusted publishing](https://docs.pypi.org/trusted-publishers/). Releases are **manual** — trigger the `publish` workflow from the Actions tab (click **Run workflow**). It publishes whatever version is set in `pyproject.toml` at that commit.
|
|
203
|
+
|
|
204
|
+
## Sibling projects
|
|
205
|
+
|
|
206
|
+
- [beaverWeb](https://github.com/kalyanramchimmili/beaverWeb) — A micro web framework for building the services beaverCore *calls*.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
211
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""beavercore — a small HTTP client wrapper.
|
|
2
|
+
|
|
3
|
+
One class (:class:`Client`) that runs the request, retries transient failures,
|
|
4
|
+
respects ``Retry-After``, refreshes stale auth once, and raises typed errors.
|
|
5
|
+
Compose with callables — no subclassing required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from beavercore.client import Client
|
|
9
|
+
from beavercore.exceptions import (
|
|
10
|
+
AuthError,
|
|
11
|
+
HttpError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
TransientError,
|
|
14
|
+
)
|
|
15
|
+
from beavercore.retry import RetryPolicy
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AuthError",
|
|
19
|
+
"Client",
|
|
20
|
+
"HttpError",
|
|
21
|
+
"RateLimitError",
|
|
22
|
+
"RetryPolicy",
|
|
23
|
+
"TransientError",
|
|
24
|
+
]
|
|
25
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The Client — runs the request, retries transient failures, refreshes auth."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from beavercore.exceptions import AuthError, HttpError, RateLimitError, TransientError
|
|
11
|
+
from beavercore.retry import RetryPolicy
|
|
12
|
+
|
|
13
|
+
_RETRYABLE_STATUS = frozenset({500, 502, 503, 504})
|
|
14
|
+
|
|
15
|
+
AuthHook = Callable[[dict], None]
|
|
16
|
+
RefreshHook = Callable[[], bool]
|
|
17
|
+
ThrottleHook = Callable[[], None]
|
|
18
|
+
ObserverHook = Callable[[dict], None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client:
|
|
22
|
+
"""HTTP client with retry, backoff, 429 handling, one-shot auth refresh.
|
|
23
|
+
|
|
24
|
+
All extension points are callables passed at construction — no subclassing.
|
|
25
|
+
|
|
26
|
+
:param auth: mutates ``request_kwargs`` before each attempt to attach credentials.
|
|
27
|
+
:param refresh: called once on the first 401; return ``True`` to retry, ``False``
|
|
28
|
+
(or omit) to raise :class:`AuthError`.
|
|
29
|
+
:param throttle: called before every attempt (bring your own rate limiter).
|
|
30
|
+
:param observer: called with an event dict (``event``, ``method``, ``url`` and
|
|
31
|
+
event-specific fields). Events: ``request``, ``response``, ``retry``,
|
|
32
|
+
``auth_refresh``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
base_url: str,
|
|
38
|
+
*,
|
|
39
|
+
auth: AuthHook | None = None,
|
|
40
|
+
refresh: RefreshHook | None = None,
|
|
41
|
+
retry: RetryPolicy | None = None,
|
|
42
|
+
throttle: ThrottleHook | None = None,
|
|
43
|
+
observer: ObserverHook | None = None,
|
|
44
|
+
timeout: float = 30.0,
|
|
45
|
+
verify_ssl: bool = True,
|
|
46
|
+
session: requests.Session | None = None,
|
|
47
|
+
):
|
|
48
|
+
if not base_url:
|
|
49
|
+
raise ValueError("base_url is required")
|
|
50
|
+
|
|
51
|
+
self._base_url = base_url.rstrip("/") + "/"
|
|
52
|
+
self._auth = auth
|
|
53
|
+
self._refresh = refresh
|
|
54
|
+
self._retry = retry or RetryPolicy()
|
|
55
|
+
self._throttle = throttle
|
|
56
|
+
self._observer = observer
|
|
57
|
+
self._timeout = timeout
|
|
58
|
+
self._verify_ssl = verify_ssl
|
|
59
|
+
self._session = session or requests.Session()
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
self._session.close()
|
|
63
|
+
|
|
64
|
+
def __enter__(self) -> "Client":
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
68
|
+
self.close()
|
|
69
|
+
|
|
70
|
+
def get(self, path: str, **kw: Any) -> requests.Response:
|
|
71
|
+
return self._request("GET", path, **kw)
|
|
72
|
+
|
|
73
|
+
def post(self, path: str, **kw: Any) -> requests.Response:
|
|
74
|
+
return self._request("POST", path, **kw)
|
|
75
|
+
|
|
76
|
+
def put(self, path: str, **kw: Any) -> requests.Response:
|
|
77
|
+
return self._request("PUT", path, **kw)
|
|
78
|
+
|
|
79
|
+
def patch(self, path: str, **kw: Any) -> requests.Response:
|
|
80
|
+
return self._request("PATCH", path, **kw)
|
|
81
|
+
|
|
82
|
+
def delete(self, path: str, **kw: Any) -> requests.Response:
|
|
83
|
+
return self._request("DELETE", path, **kw)
|
|
84
|
+
|
|
85
|
+
def _emit(self, event: str, **fields: Any) -> None:
|
|
86
|
+
if self._observer is not None:
|
|
87
|
+
self._observer({"event": event, **fields})
|
|
88
|
+
|
|
89
|
+
def _sleep(self, attempt: int, delay: float) -> None:
|
|
90
|
+
# Skip the sleep after the last attempt — nothing else is going to run.
|
|
91
|
+
if attempt < self._retry.max_attempts - 1 and delay > 0:
|
|
92
|
+
time.sleep(delay)
|
|
93
|
+
|
|
94
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
|
95
|
+
url = urljoin(self._base_url, path.lstrip("/"))
|
|
96
|
+
kwargs.setdefault("timeout", self._timeout)
|
|
97
|
+
kwargs.setdefault("verify", self._verify_ssl)
|
|
98
|
+
|
|
99
|
+
refresh_tried = False
|
|
100
|
+
last_response: requests.Response | None = None
|
|
101
|
+
last_reason: str = ""
|
|
102
|
+
|
|
103
|
+
for attempt in range(self._retry.max_attempts):
|
|
104
|
+
if self._throttle is not None:
|
|
105
|
+
self._throttle()
|
|
106
|
+
|
|
107
|
+
request_kwargs = dict(kwargs)
|
|
108
|
+
if self._auth is not None:
|
|
109
|
+
self._auth(request_kwargs)
|
|
110
|
+
|
|
111
|
+
self._emit("request", method=method, url=url, attempt=attempt)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
response = self._session.request(method, url, **request_kwargs)
|
|
115
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
116
|
+
last_reason = f"network:{type(exc).__name__}"
|
|
117
|
+
self._emit("retry", method=method, url=url, attempt=attempt, reason=last_reason)
|
|
118
|
+
self._sleep(attempt, self._retry.delay_for(attempt))
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
self._emit(
|
|
122
|
+
"response", method=method, url=url, status=response.status_code, attempt=attempt
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
status = response.status_code
|
|
126
|
+
|
|
127
|
+
if status == 401 and self._refresh is not None and not refresh_tried:
|
|
128
|
+
refresh_tried = True
|
|
129
|
+
if self._refresh():
|
|
130
|
+
self._emit("auth_refresh", method=method, url=url, attempt=attempt)
|
|
131
|
+
continue
|
|
132
|
+
raise AuthError(
|
|
133
|
+
f"authentication failed for {method} {url}",
|
|
134
|
+
status=status,
|
|
135
|
+
response=response,
|
|
136
|
+
attempts=attempt + 1,
|
|
137
|
+
refresh_attempted=True,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if status in (401, 403):
|
|
141
|
+
raise AuthError(
|
|
142
|
+
f"authentication failed for {method} {url} ({status})",
|
|
143
|
+
status=status,
|
|
144
|
+
response=response,
|
|
145
|
+
attempts=attempt + 1,
|
|
146
|
+
refresh_attempted=refresh_tried,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if status == 429:
|
|
150
|
+
last_response = response
|
|
151
|
+
last_reason = "rate_limit:429"
|
|
152
|
+
retry_after = _parse_retry_after(response)
|
|
153
|
+
self._emit("retry", method=method, url=url, attempt=attempt, reason=last_reason)
|
|
154
|
+
delay = (
|
|
155
|
+
min(retry_after, self._retry.backoff_cap)
|
|
156
|
+
if retry_after is not None
|
|
157
|
+
else self._retry.delay_for(attempt)
|
|
158
|
+
)
|
|
159
|
+
self._sleep(attempt, delay)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if status in _RETRYABLE_STATUS:
|
|
163
|
+
last_response = response
|
|
164
|
+
last_reason = f"5xx:{status}"
|
|
165
|
+
self._emit("retry", method=method, url=url, attempt=attempt, reason=last_reason)
|
|
166
|
+
self._sleep(attempt, self._retry.delay_for(attempt))
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if not response.ok:
|
|
170
|
+
raise HttpError(
|
|
171
|
+
f"unexpected {status} from {method} {url}",
|
|
172
|
+
status=status,
|
|
173
|
+
response=response,
|
|
174
|
+
attempts=attempt + 1,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
attempts = self._retry.max_attempts
|
|
180
|
+
if last_response is not None and last_response.status_code == 429:
|
|
181
|
+
raise RateLimitError(
|
|
182
|
+
f"rate limit not cleared after {attempts} attempts",
|
|
183
|
+
status=429,
|
|
184
|
+
response=last_response,
|
|
185
|
+
attempts=attempts,
|
|
186
|
+
retry_after=_parse_retry_after(last_response),
|
|
187
|
+
)
|
|
188
|
+
raise TransientError(
|
|
189
|
+
f"transient failure persisted after {attempts} attempts ({last_reason})",
|
|
190
|
+
status=last_response.status_code if last_response is not None else None,
|
|
191
|
+
response=last_response,
|
|
192
|
+
attempts=attempts,
|
|
193
|
+
last_reason=last_reason,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_retry_after(response: requests.Response) -> float | None:
|
|
198
|
+
value = response.headers.get("Retry-After")
|
|
199
|
+
if not value:
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
return float(value)
|
|
203
|
+
except ValueError:
|
|
204
|
+
return None
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Typed exception hierarchy raised by :class:`beavercore.Client`.
|
|
2
|
+
|
|
3
|
+
Every exception carries the raw :class:`requests.Response` (when a response was
|
|
4
|
+
received), the HTTP status, and the total attempts made. Subclasses add fields
|
|
5
|
+
specific to why they were raised — ``retry_after`` on 429, ``last_reason`` on
|
|
6
|
+
transient failures, ``refresh_attempted`` on 401/403.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HttpError(Exception):
|
|
15
|
+
"""Base for every error raised by :class:`Client`."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
*,
|
|
21
|
+
status: int | None = None,
|
|
22
|
+
response: requests.Response | None = None,
|
|
23
|
+
attempts: int = 1,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status = status
|
|
27
|
+
self.response = response
|
|
28
|
+
self.attempts = attempts
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def status_code(self) -> int | None:
|
|
32
|
+
return self.status
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthError(HttpError):
|
|
36
|
+
"""401/403. ``refresh_attempted`` records whether the refresh hook ran."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, *, refresh_attempted: bool = False, **kwargs: Any):
|
|
39
|
+
super().__init__(message, **kwargs)
|
|
40
|
+
self.refresh_attempted = refresh_attempted
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(HttpError):
|
|
44
|
+
"""429 that outlasted the retry budget. ``retry_after`` from the last response."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any):
|
|
47
|
+
super().__init__(message, **kwargs)
|
|
48
|
+
self.retry_after = retry_after
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TransientError(HttpError):
|
|
52
|
+
"""5xx or network fault that outlasted the retry budget.
|
|
53
|
+
|
|
54
|
+
``last_reason`` is a short tag like ``"5xx:503"`` or ``"network:Timeout"``.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str, *, last_reason: str = "", **kwargs: Any):
|
|
58
|
+
super().__init__(message, **kwargs)
|
|
59
|
+
self.last_reason = last_reason
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Retry policy for :class:`beavercore.Client` — exponential backoff with jitter."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class RetryPolicy:
|
|
9
|
+
"""Immutable retry configuration.
|
|
10
|
+
|
|
11
|
+
:param max_attempts: total attempts including the first.
|
|
12
|
+
:param backoff_base: seconds — multiplier for ``2 ** attempt``.
|
|
13
|
+
:param backoff_cap: seconds — upper bound on any single sleep.
|
|
14
|
+
:param jitter: 0 = deterministic backoff, 1 = up to a full ``backoff_base``
|
|
15
|
+
of extra jitter on top of the exponential term.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
max_attempts: int = 3
|
|
19
|
+
backoff_base: float = 0.5
|
|
20
|
+
backoff_cap: float = 30.0
|
|
21
|
+
jitter: float = 0.5
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.max_attempts < 1:
|
|
25
|
+
raise ValueError("max_attempts must be >= 1")
|
|
26
|
+
if self.backoff_base < 0 or self.backoff_cap < 0 or self.jitter < 0:
|
|
27
|
+
raise ValueError("backoff_base, backoff_cap, jitter must be >= 0")
|
|
28
|
+
|
|
29
|
+
def delay_for(self, attempt: int) -> float:
|
|
30
|
+
base = min(self.backoff_base * (2**attempt), self.backoff_cap)
|
|
31
|
+
return base + random.uniform(0, self.jitter * self.backoff_base)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""GitHub REST API client — a worked example built on beavercore.
|
|
2
|
+
|
|
3
|
+
The whole file is business logic. Retries, exponential backoff, 429 handling,
|
|
4
|
+
connection pooling, and error classification are inherited from ``Client``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from beavercore import Client, RetryPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def github_client(
|
|
13
|
+
token: str,
|
|
14
|
+
*,
|
|
15
|
+
base_url: str = "https://api.github.com",
|
|
16
|
+
api_version: str = "2022-11-28",
|
|
17
|
+
) -> Client:
|
|
18
|
+
if not token:
|
|
19
|
+
raise ValueError("token is required")
|
|
20
|
+
|
|
21
|
+
def apply_auth(request_kwargs: dict) -> None:
|
|
22
|
+
headers = request_kwargs.setdefault("headers", {})
|
|
23
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
24
|
+
headers.setdefault("Accept", "application/vnd.github+json")
|
|
25
|
+
headers.setdefault("X-GitHub-Api-Version", api_version)
|
|
26
|
+
|
|
27
|
+
return Client(
|
|
28
|
+
base_url=base_url,
|
|
29
|
+
auth=apply_auth,
|
|
30
|
+
retry=RetryPolicy(max_attempts=4),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_authenticated_user(client: Client) -> dict:
|
|
35
|
+
return client.get("/user").json()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_user(client: Client, username: str) -> dict:
|
|
39
|
+
return client.get(f"/users/{username}").json()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_repos(
|
|
43
|
+
client: Client,
|
|
44
|
+
username: str,
|
|
45
|
+
*,
|
|
46
|
+
per_page: int = 30,
|
|
47
|
+
page: int = 1,
|
|
48
|
+
) -> list[dict]:
|
|
49
|
+
return client.get(
|
|
50
|
+
f"/users/{username}/repos",
|
|
51
|
+
params={"per_page": per_page, "page": page},
|
|
52
|
+
).json()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_repo(client: Client, owner: str, repo: str) -> dict:
|
|
56
|
+
return client.get(f"/repos/{owner}/{repo}").json()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def rate_limit(client: Client) -> dict:
|
|
60
|
+
return client.get("/rate_limit").json()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
try:
|
|
65
|
+
token = os.environ["GITHUB_TOKEN"]
|
|
66
|
+
except KeyError:
|
|
67
|
+
raise SystemExit("GITHUB_TOKEN environment variable is required") from None
|
|
68
|
+
|
|
69
|
+
with github_client(token) as gh:
|
|
70
|
+
me = get_authenticated_user(gh)
|
|
71
|
+
print(f"logged in as {me['login']}")
|
|
72
|
+
for repo in list_repos(gh, me["login"], per_page=10):
|
|
73
|
+
print(f" - {repo['full_name']}")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "beavercore"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An opinionated HTTP client framework on top of requests — retries, backoff, 429 handling, session refresh, typed exceptions, observability hooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Kalyan Ram Chimmili" }]
|
|
13
|
+
keywords = ["http", "client", "retry", "rate-limit", "requests", "framework"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"requests>=2.31,<3",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=7.0,<10",
|
|
33
|
+
"responses>=0.24,<1",
|
|
34
|
+
"ruff>=0.4,<1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/kalyanramchimmili/beaverCore"
|
|
39
|
+
Repository = "https://github.com/kalyanramchimmili/beaverCore"
|
|
40
|
+
Issues = "https://github.com/kalyanramchimmili/beaverCore/issues"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["beavercore"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["beavercore/", "tests/", "example.py", "README.md", "LICENSE"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
|
|
54
|
+
ignore = ["E501"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
addopts = "-ra --strict-markers"
|
|
59
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import requests
|
|
3
|
+
import responses
|
|
4
|
+
|
|
5
|
+
from beavercore import (
|
|
6
|
+
AuthError,
|
|
7
|
+
Client,
|
|
8
|
+
HttpError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
RetryPolicy,
|
|
11
|
+
TransientError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
BASE = "https://api.example.com"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def fast() -> RetryPolicy:
|
|
19
|
+
return RetryPolicy(max_attempts=3, backoff_base=0.0, backoff_cap=0.0, jitter=0.0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _url(path: str) -> str:
|
|
23
|
+
return f"{BASE}/{path.lstrip('/')}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ─── constructor validation ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_base_url_required():
|
|
30
|
+
with pytest.raises(ValueError):
|
|
31
|
+
Client(base_url="")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_retry_policy_validates_max_attempts():
|
|
35
|
+
with pytest.raises(ValueError):
|
|
36
|
+
RetryPolicy(max_attempts=0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_retry_policy_delay_grows_and_caps():
|
|
40
|
+
p = RetryPolicy(max_attempts=10, backoff_base=1.0, backoff_cap=4.0, jitter=0.0)
|
|
41
|
+
assert p.delay_for(0) == 1.0
|
|
42
|
+
assert p.delay_for(1) == 2.0
|
|
43
|
+
assert p.delay_for(2) == 4.0
|
|
44
|
+
assert p.delay_for(9) == 4.0 # capped
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─── happy path ──────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@responses.activate
|
|
51
|
+
def test_get_200(fast):
|
|
52
|
+
responses.get(_url("/things"), json={"ok": True})
|
|
53
|
+
with Client(BASE, retry=fast) as c:
|
|
54
|
+
r = c.get("/things")
|
|
55
|
+
assert r.status_code == 200
|
|
56
|
+
assert r.json() == {"ok": True}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@responses.activate
|
|
60
|
+
def test_auth_callable_mutates_kwargs(fast):
|
|
61
|
+
responses.get(_url("/x"), json={})
|
|
62
|
+
|
|
63
|
+
def auth(kw: dict) -> None:
|
|
64
|
+
kw.setdefault("headers", {})["Authorization"] = "Bearer xyz"
|
|
65
|
+
|
|
66
|
+
with Client(BASE, retry=fast, auth=auth) as c:
|
|
67
|
+
c.get("/x")
|
|
68
|
+
|
|
69
|
+
assert responses.calls[0].request.headers["Authorization"] == "Bearer xyz"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ─── 5xx retry / exhaustion ──────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@responses.activate
|
|
76
|
+
def test_5xx_retried_then_succeeds(fast):
|
|
77
|
+
responses.get(_url("/x"), status=503)
|
|
78
|
+
responses.get(_url("/x"), status=502)
|
|
79
|
+
responses.get(_url("/x"), json={"ok": True})
|
|
80
|
+
with Client(BASE, retry=fast) as c:
|
|
81
|
+
r = c.get("/x")
|
|
82
|
+
assert r.status_code == 200
|
|
83
|
+
assert len(responses.calls) == 3
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@responses.activate
|
|
87
|
+
def test_5xx_exhausts_raises_transient(fast):
|
|
88
|
+
for _ in range(3):
|
|
89
|
+
responses.get(_url("/x"), status=500)
|
|
90
|
+
with Client(BASE, retry=fast) as c, pytest.raises(TransientError) as ei:
|
|
91
|
+
c.get("/x")
|
|
92
|
+
assert ei.value.attempts == 3
|
|
93
|
+
assert ei.value.last_reason == "5xx:500"
|
|
94
|
+
assert ei.value.status == 500
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ─── 429 rate limit ──────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@responses.activate
|
|
101
|
+
def test_429_retry_after_then_succeeds(fast):
|
|
102
|
+
responses.get(_url("/x"), status=429, headers={"Retry-After": "0"})
|
|
103
|
+
responses.get(_url("/x"), json={"ok": True})
|
|
104
|
+
with Client(BASE, retry=fast) as c:
|
|
105
|
+
r = c.get("/x")
|
|
106
|
+
assert r.status_code == 200
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@responses.activate
|
|
110
|
+
def test_429_exhausts_raises_rate_limit(fast):
|
|
111
|
+
for _ in range(3):
|
|
112
|
+
responses.get(_url("/x"), status=429, headers={"Retry-After": "0"})
|
|
113
|
+
with Client(BASE, retry=fast) as c, pytest.raises(RateLimitError) as ei:
|
|
114
|
+
c.get("/x")
|
|
115
|
+
assert ei.value.attempts == 3
|
|
116
|
+
assert ei.value.retry_after == 0.0
|
|
117
|
+
assert ei.value.status == 429
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ─── 401 auth refresh ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@responses.activate
|
|
124
|
+
def test_401_refresh_true_retries_once(fast):
|
|
125
|
+
responses.get(_url("/x"), status=401)
|
|
126
|
+
responses.get(_url("/x"), json={"ok": True})
|
|
127
|
+
calls = {"n": 0}
|
|
128
|
+
|
|
129
|
+
def refresh() -> bool:
|
|
130
|
+
calls["n"] += 1
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
with Client(BASE, retry=fast, refresh=refresh) as c:
|
|
134
|
+
r = c.get("/x")
|
|
135
|
+
assert r.status_code == 200
|
|
136
|
+
assert calls["n"] == 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@responses.activate
|
|
140
|
+
def test_401_refresh_false_raises_auth(fast):
|
|
141
|
+
responses.get(_url("/x"), status=401)
|
|
142
|
+
with Client(BASE, retry=fast, refresh=lambda: False) as c, pytest.raises(AuthError) as ei:
|
|
143
|
+
c.get("/x")
|
|
144
|
+
assert ei.value.refresh_attempted is True
|
|
145
|
+
assert ei.value.status == 401
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@responses.activate
|
|
149
|
+
def test_401_without_refresh_hook_raises_auth(fast):
|
|
150
|
+
responses.get(_url("/x"), status=401)
|
|
151
|
+
with Client(BASE, retry=fast) as c, pytest.raises(AuthError) as ei:
|
|
152
|
+
c.get("/x")
|
|
153
|
+
assert ei.value.refresh_attempted is False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@responses.activate
|
|
157
|
+
def test_403_raises_auth(fast):
|
|
158
|
+
responses.get(_url("/x"), status=403)
|
|
159
|
+
with Client(BASE, retry=fast) as c, pytest.raises(AuthError) as ei:
|
|
160
|
+
c.get("/x")
|
|
161
|
+
assert ei.value.status == 403
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@responses.activate
|
|
165
|
+
def test_401_refresh_only_runs_once_even_if_still_401(fast):
|
|
166
|
+
for _ in range(3):
|
|
167
|
+
responses.get(_url("/x"), status=401)
|
|
168
|
+
calls = {"n": 0}
|
|
169
|
+
|
|
170
|
+
def refresh() -> bool:
|
|
171
|
+
calls["n"] += 1
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
with Client(BASE, retry=fast, refresh=refresh) as c, pytest.raises(AuthError):
|
|
175
|
+
c.get("/x")
|
|
176
|
+
assert calls["n"] == 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ─── non-retryable 4xx ───────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@responses.activate
|
|
183
|
+
def test_404_raises_httperror_and_stops(fast):
|
|
184
|
+
responses.get(_url("/x"), status=404)
|
|
185
|
+
with Client(BASE, retry=fast) as c, pytest.raises(HttpError) as ei:
|
|
186
|
+
c.get("/x")
|
|
187
|
+
assert ei.value.status == 404
|
|
188
|
+
assert not isinstance(ei.value, (AuthError, RateLimitError, TransientError))
|
|
189
|
+
assert len(responses.calls) == 1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@responses.activate
|
|
193
|
+
def test_418_raises_httperror(fast):
|
|
194
|
+
responses.get(_url("/x"), status=418)
|
|
195
|
+
with Client(BASE, retry=fast) as c, pytest.raises(HttpError) as ei:
|
|
196
|
+
c.get("/x")
|
|
197
|
+
assert ei.value.status == 418
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ─── network faults ──────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@responses.activate
|
|
204
|
+
def test_connection_error_retried_then_exhausted(fast):
|
|
205
|
+
for _ in range(3):
|
|
206
|
+
responses.get(_url("/x"), body=requests.ConnectionError("boom"))
|
|
207
|
+
with Client(BASE, retry=fast) as c, pytest.raises(TransientError) as ei:
|
|
208
|
+
c.get("/x")
|
|
209
|
+
assert "network:" in ei.value.last_reason
|
|
210
|
+
assert ei.value.attempts == 3
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ─── observer ────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@responses.activate
|
|
217
|
+
def test_observer_receives_lifecycle_events(fast):
|
|
218
|
+
responses.get(_url("/x"), status=500)
|
|
219
|
+
responses.get(_url("/x"), json={"ok": True})
|
|
220
|
+
|
|
221
|
+
events: list[dict] = []
|
|
222
|
+
with Client(BASE, retry=fast, observer=events.append) as c:
|
|
223
|
+
c.get("/x")
|
|
224
|
+
|
|
225
|
+
kinds = [e["event"] for e in events]
|
|
226
|
+
assert kinds.count("request") == 2
|
|
227
|
+
assert kinds.count("response") == 2
|
|
228
|
+
assert kinds.count("retry") == 1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ─── throttle ────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@responses.activate
|
|
235
|
+
def test_throttle_called_before_every_attempt(fast):
|
|
236
|
+
responses.get(_url("/x"), status=500)
|
|
237
|
+
responses.get(_url("/x"), status=500)
|
|
238
|
+
responses.get(_url("/x"), json={"ok": True})
|
|
239
|
+
|
|
240
|
+
calls = {"n": 0}
|
|
241
|
+
|
|
242
|
+
def throttle() -> None:
|
|
243
|
+
calls["n"] += 1
|
|
244
|
+
|
|
245
|
+
with Client(BASE, retry=fast, throttle=throttle) as c:
|
|
246
|
+
c.get("/x")
|
|
247
|
+
|
|
248
|
+
assert calls["n"] == 3
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ─── session lifecycle ───────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_context_manager_closes_session():
|
|
255
|
+
with Client(BASE) as c:
|
|
256
|
+
session = c._session
|
|
257
|
+
# After close(), further requests would fail — we just verify close was reached.
|
|
258
|
+
assert session is not None
|