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.
@@ -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
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/beavercore.svg)](https://pypi.org/project/beavercore/)
53
+ [![Python](https://img.shields.io/pypi/pyversions/beavercore.svg)](https://pypi.org/project/beavercore/)
54
+ [![License](https://img.shields.io/pypi/l/beavercore.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/beavercore.svg)](https://pypi.org/project/beavercore/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/beavercore.svg)](https://pypi.org/project/beavercore/)
5
+ [![License](https://img.shields.io/pypi/l/beavercore.svg)](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