beaverCore
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.
pip install beavercore
The idea
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:
- Turning transient failures (5xx, connection resets, timeouts) into retries with backoff.
- Respecting
Retry-Afterwhen the upstream throttles them. - Refreshing an expired session token once, transparently, on a 401.
- Distinguishing "auth broke" from "server sad" from "the request itself is wrong" without callers having to inspect status codes.
- Pooling the underlying TCP connections instead of tearing them down every call.
- Wiring the whole thing into logs/metrics without turning every method into a callback tree.
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.
Quick start
from beavercore import Client, RetryPolicy
def auth(request_kwargs: dict) -> None:
request_kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {TOKEN}"
with Client(
"https://api.example.com",
auth=auth,
retry=RetryPolicy(max_attempts=4),
) as client:
things = client.get("/things").json()
Extension is via callables — no subclassing required. If you need to refresh a session token on a 401:
def refresh() -> bool:
global TOKEN
TOKEN = login()
return True # tells beavercore to retry the original request once
Client("https://api.example.com", auth=auth, refresh=refresh)
refresh runs at most once per request. If the retry also 401s, you get AuthError.
Exception hierarchy
HttpError base — carries .status, .response, .attempts
├── AuthError 401/403 (extra: .refresh_attempted)
├── RateLimitError 429 after retries exhausted (extra: .retry_after)
└── TransientError 5xx or network fault, retries exhausted (extra: .last_reason)
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.
Design decisions
The choices worth explaining because they're not obvious from the code.
Built on requests, not transport-agnostic
beavercore is a policy layer — retries, backoff, error classification, session refresh, observability. The transport is requests. 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.
Compose with callables, don't subclass
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:
- One class, one instance per upstream. No
GitHubClient(BaseClient)boilerplate per API. - Auth is state, not behavior. Making it a callable makes token rotation, environment-driven auth, and testing trivial.
- Composition scales. Wrap the callable, don't subclass the class.
Raise, don't return
Most Python HTTP wrappers return {"success": bool, "response": ..., "error": ...} dicts. beavercore raises typed exceptions instead. Reasons:
- Composes with
try/except. Callers can catchAuthErrorin one place instead of checkingresult["success"]on every call. - Doesn't collide with success payloads. If the API you're calling returns
{"success": false, ...}as data, dict-based error contracts get confusing. - Matches the ecosystem.
requests.HTTPError,httpx.HTTPStatusError,openai.APIError— every mature Python HTTP library raises.
Session refresh runs once, not forever
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:
- Bounded blast radius. A misconfigured refresh function can't turn a single failed call into an infinite refresh loop.
- Refresh failures surface immediately. If the refresh itself is broken (bad refresh token, revoked session), you see it now, not after N retries.
- Refresh is expensive. Most upstreams charge for refresh calls (rate-limit budget, database write, OAuth roundtrip). Don't spam it.
Retries only on genuinely retryable failures
Retryable: connection error, timeout, 500/502/503/504, 429. Not retryable: 4xx (except 401, which becomes a refresh path). Reasons:
- 4xx is your bug.
400 Bad Request,403 Forbidden,422 Unprocessable Entity— retrying won't fix them. Fail fast, surface loud. - 5xx and network faults are upstream's bug. Retrying with jitter is the right response.
- 429 is a special case. Technically "your fault" for exceeding a quota, but the upstream's
Retry-Afterheader tells you exactly how to fix it.
Bring-your-own rate limiter
Client accepts a throttle: Callable[[], None] argument. It's called before every attempt. That's the whole contract. Reasons:
- 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.
- Optional by default. Most APIs don't advertise a client-side limit — you only add one when the upstream tells you or 429s you.
- You already have one. Every real project has a rate-limit primitive (Redis, in-memory semaphore, third-party lib). Pass it as a callable.
One observer, not three hooks
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:
- One signal path, not three. Log aggregation, tracing, and metrics all speak dict.
- Extensible. Adding a new event kind doesn't change the API.
- Zero cost when unused.
observerdefaults toNone; the fast path has oneis not Nonecheck.
Constructor options
| arg | default | notes |
|---|---|---|
base_url |
required | Trailing slash optional. |
auth |
None |
(request_kwargs) -> None. Mutate to attach credentials. |
refresh |
None |
() -> bool. Return True to retry once after a 401. |
retry |
RetryPolicy() |
See below. |
throttle |
None |
() -> None. Called before every attempt. |
observer |
None |
(event_dict) -> None. Fires for request, response, retry, auth_refresh. |
timeout |
30 |
Seconds. Applied to every request. |
verify_ssl |
True |
Set False for self-signed internal endpoints. |
session |
None |
Provide a preconfigured requests.Session for custom adapters. |
RetryPolicy
RetryPolicy(
max_attempts=3, # total attempts including the first
backoff_base=0.5, # seconds — multiplier for 2^attempt
backoff_cap=30.0, # seconds — upper bound on any single sleep
jitter=0.5, # 0 = no jitter, 1 = full jitter of backoff_base
)
Immutable dataclass. delay_for(attempt) returns the sleep duration for a given attempt index.
Observability
def observer(event: dict) -> None:
if event["event"] == "retry":
logger.warning(
"retrying %s %s (attempt %d): %s",
event["method"], event["url"], event["attempt"] + 1, event["reason"],
)
client = Client("https://api.example.com", auth=auth, observer=observer)
One hook, one signal path — enough to wire beavercore into any log aggregator, Prometheus histogram, or OpenTelemetry span.
Worked example
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:
export GITHUB_TOKEN=ghp_your_token
python example.py
Repository layout
beaverCore/
├── pyproject.toml # package + dev tools + all config
├── README.md # this file (also the PyPI description)
├── LICENSE
├── beavercore/
│ ├── __init__.py # public API re-exports
│ ├── client.py # Client
│ ├── retry.py # RetryPolicy
│ └── exceptions.py # HttpError, AuthError, RateLimitError, TransientError
├── tests/
│ └── test_client.py
├── example.py # runnable GitHub API demo
└── .github/workflows/
└── publish.yml # manual: Actions tab → Run workflow → PyPI
Development
pip install -e ".[dev]"
pytest
ruff check .
Publishing
Published to PyPI via GitHub Actions using trusted publishing. 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.
Sibling projects
- beaverWeb — A micro web framework for building the services beaverCore calls.
License
MIT
Release files for beavercore 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| beavercore-0.1.0.tar.gz | 11.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| beavercore-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 22.6 kB
Release files / beavercore-0.1.0.tar.gz
| Download URL | beavercore-0.1.0.tar.gz |
|---|---|
| Size | 11.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
545b1335063268114a373789d1175ad2160bfb3bee2715725904b03ac2ed59c6
|
|
BLAKE2b-256 checksum How to use checksums |
8c80876dd3a9715bc76b83c8a3f8b71c2b8a497add18d2e189a9c057fc81115f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency logRelease files / beavercore-0.1.0-py3-none-any.whl
| Download URL | beavercore-0.1.0-py3-none-any.whl |
|---|---|
| Size | 11.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3f09c6461f9d8b5d1087f60a3736114723204e348253a1089beeed2356800a78
|
|
BLAKE2b-256 checksum How to use checksums |
6258c4b2e73240dc7a1e3a9cb20ce42753156030f12dece096126b4d1ac4706c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency log