🚀 HakiAPI
Build production-grade Python API SDKs — not boilerplate.
Authentication · OAuth 2.0 · Retries · Predictive Rate-Limit Governor · Circuit Breaker · Pagination · Typed Exceptions · Sync + Async
Stop rewriting authentication, retries, rate-limit handling, and pagination for every API client you build.
📖 Read the full documentation →
Docs • What's New • Installation • Quick Start • Features • Resilience • Core Concepts • Async Client • Bundled Clients • Create Your Own Client • Examples • Architecture • Troubleshooting • Roadmap
Table of Contents
- What's New in v2.1.x
- Why HakiAPI?
- ✨ Features
- Installation
- Quick Start
- Resilience: Built In, Not Bolted On
- Core Concepts
- Bundled Clients
- Create Your Own Client
- Examples
- Architecture & Project Structure
- Design Principles
- Testing
- Troubleshooting
- Roadmap
- Contributing
- License
What's New in v2.1.x
Current release: v2.1.6 (pip install -U hakiapi).
| New in | Feature | What you get |
|---|---|---|
| v2.1.x | 🧭 Predictive rate-limit governor (core/governor.py) |
PredictiveGovernor paces requests proactively from X-RateLimit-* / RateLimit-* headers. Wired into both BaseAPIClient and AsyncBaseAPIClient by default (enable_governor=False to opt out; share one instance across clients to coordinate pacing). |
| v2.1.x | 🧯 Circuit breaker built into every client (core/circuit_breaker.py) |
CircuitBreaker (CLOSED → OPEN → HALF_OPEN) now runs inside every _request() — fails fast with CircuitOpenError(retry_after=...) during cooldown, then auto-probes recovery. Still usable standalone as @breaker. |
| v2.1.x | ⚡ Top-level async export + async extra |
from hakiapi import AsyncBaseAPIClient (also from hakiapi.core import AsyncBaseAPIClient). Install with pip install hakiapi[async]; importing without httpx raises an ImportError with that hint. |
| v2.1.x | 📊 GitHub GraphQL engine + profile aggregation | execute_graphql() (raises HakiAPIError on body-level "errors"), get_user_contributions() (365-day calendar + lifetime PR/issue activity), fetch_full_profile_data(), check_readme_exists() / check_top_repos_readmes(). |
| v2.1.5 | 🔧 Governor rename (with shim) | hakiapi.core.governer (typo) → hakiapi.core.governor. Old path still works via DeprecationWarning shim; new code should use hakiapi.core.governor. |
| v2.1.6 | 🛡️ Quality pipeline (ci.yml) |
Moderate Ruff (E,F,I,UP,B,SIM, 88, py310) + ruff format, pytest matrix 3.10–3.14, coverage gate ≥85% (370 passed, 86.94%), mypy, bandit + pip-audit. Local auto-fix via ruff check --fix + ruff format; CI enforces with --check fail. |
Upgrading from ≤ v2.1.4? Only change needed is the governor import if you referenced the old typo'd path — everything else is backward compatible.
Why HakiAPI?
Every API client grows the same infrastructure, in the same order. You start with a simple HTTP call. Then you add authentication. Then retries. Then you get rate-limited at 2 AM and add backoff. Then pagination. Then timeout and exception handling. Then a downstream outage takes your app down with it, and you wish you'd added a circuit breaker. A month later, you've rebuilt the same plumbing you already wrote for the last five projects.
HakiAPI extracts all of that into one reusable core (BaseAPIClient / AsyncBaseAPIClient), so every client you build on top of it inherits the same battle-tested behavior automatically. Instead of writing infrastructure, you write endpoint logic.
Raw requests vs. HakiAPI
Raw requests |
HakiAPI | |
|---|---|---|
| OAuth 2.0 Flow | Hand-roll the consent URL, spin up a redirect server, parse the callback yourself | GoogleOAuthFlow builds the consent URL, opens the browser, catches the redirect on localhost, verifies CSRF state, and exchanges the code for you |
| Token Persistence | Read/write a JSON file yourself and hope nothing corrupts it mid-write | FileTokenStore writes atomically (temp file + os.replace) with 0600 permissions |
| Retry Logic | Wire up your own urllib3.Retry + HTTPAdapter |
Built into every sync session with exponential backoff on 429/500/502/503/504; natively re-implemented for httpx in the async client |
| Rate-Limit Pacing | React to 429s after they happen — sleep, retry, hope |
PredictiveGovernor reads X-RateLimit-* / RateLimit-* response headers and sleeps proactively before the next request so the 429 never happens |
| Static Auth | Reimplement Bearer/HMAC/API-key headers per project | 5 reusable AuthBase strategies, drop-in |
| Error Handling | Manually branch on response.status_code everywhere |
Raised as a typed, catchable exception hierarchy carrying status_code and the original response |
| Pagination | Write a custom while loop per API's pagination style |
paginate() auto-detects Link-header, data/meta.next_token, messages/nextPageToken, and items/nextPageToken styles, yielding lazily |
| Async I/O | Swap in aiohttp/httpx yourself and reimplement retries, timeouts, and status handling on top of it |
AsyncBaseAPIClient is an httpx-backed mirror of BaseAPIClient — same retry engine, same governor, same breaker, same typed exceptions, async/await throughout |
| Cascading Failures | A struggling downstream service keeps getting hammered by retries until your own app falls over with it | CircuitBreaker is wired into every client by default — trips OPEN after N consecutive failures, fails fast with CircuitOpenError during cooldown, then auto-probes recovery with a single trial call |
✨ Features
| Feature | Details |
|---|---|
| 🔐 Interactive OAuth 2.0 Flow | GoogleOAuthFlow drives Google's Authorization Code flow end-to-end: builds the consent URL, opens the system browser, boots a one-shot local HTTPServer to catch the redirect, validates the CSRF state token, and exchanges the code for tokens. |
| 🔁 Manual Token Refresh | refresh_access_token() exchanges a stored refresh_token for a new access_token without user interaction, and wipes the token store automatically if Google reports the grant as revoked. |
| 🗄️ Atomic Token Vault | FileTokenStore persists tokens to a local JSON file by writing to a temp file and swapping it in with os.replace(), so a crash mid-write can never leave a corrupted token file. The file is chmod 0600. |
| 🔐 Multiple Auth Strategies | BearerTokenAuth, HeaderApiKeyAuth, QueryApiKeyAuth, HmacAuth (SHA-256 request signing), and OAuth2Auth for wiring a GoogleOAuthFlow directly into a requests.Session. |
| 🔁 Automatic Retries | create_retry_adapter() mounts an HTTPAdapter with exponential backoff on 429/500/502/503/504 onto every sync BaseAPIClient session (raise_on_status=False, so typed exceptions own the final failure). The async client re-implements the same policy natively on httpx (exponential backoff + jitter, Retry-After clamping at 300s). |
| 🧭 Predictive Rate-Limit Governor | PredictiveGovernor learns each host's quota from response headers (GitHub-style X-RateLimit-* and IETF-draft RateLimit-*) and paces requests proactively — time.sleep (sync) / await asyncio.sleep (async) — before you burn the last call and eat a 429. Thread-safe, per-host registry, tunable safety_margin. On by default; disable with enable_governor=False. |
| 🧯 Circuit Breaker (built-in) | Every BaseAPIClient and AsyncBaseAPIClient ships with a thread-safe CircuitBreaker implementing CLOSED → OPEN → HALF_OPEN. Fails fast with CircuitOpenError (carrying retry_after) once failure_threshold consecutive failures are hit, then allows a single trial request after recovery_timeout to probe recovery. Also usable as a standalone @breaker decorator on any callable. Disable with enable_circuit_breaker=False. |
| 📄 Smart Pagination | paginate() auto-detects GitHub-style Link headers, Twitter-style meta.next_token, Gmail-style messages + nextPageToken, and Calendar-style items + nextPageToken — all as one lazy generator. |
| ⚠️ Typed Exceptions | RateLimitError (with retry_after), AuthenticationError (401/403), ClientError (4xx), ServerError (5xx), RequestTimeoutError, CircuitOpenError — all inherit from HakiAPIError, which carries status_code and the original response. |
| ⚡ Async Client | AsyncBaseAPIClient is a fully async/await, httpx-powered counterpart to BaseAPIClient — same exponential backoff on 429/500/502/503/504, same governor + breaker wiring, same typed exception hierarchy, Retry-After clamping, SSRF-safe URL/endpoint validation, custom headers, and a max-response-size guard. |
| 📦 Ready-to-use Clients | GitHubClient (REST + GraphQL + full-profile aggregation + README checks), GmailClient, GoogleCalendarClient — out of the box. |
Installation
pip install hakiapi
Requires Python 3.10+. Core dependencies are requests>=2.32.0 and urllib3>=1.26.0.
AsyncBaseAPIClient is built on httpx, which isn't installed by default — add it if you want the async client:
pip install hakiapi[async]
For development (tests, lint, types, security):
pip install -e ".[dev,async]"
dev includes pytest, pytest-asyncio, pytest-cov, ruff, mypy, bandit[toml], pip-audit, httpx.
📖 Full API reference and guides: hakiapi-docs.hakiapi.workers.dev
Quick Start
1. Interactive OAuth 2.0 (Google Calendar)
GoogleOAuthFlow.get_token() checks the TokenStore first. If a valid, non-expired token is already saved, it's returned immediately. Otherwise it opens your browser, runs the full consent flow, and persists the result:
import os
from dotenv import load_dotenv
from hakiapi.clients.google_calendar import GoogleCalendarClient
from hakiapi.core.oauth.google import GoogleOAuthFlow
from hakiapi.core.oauth.token_store import FileTokenStore
# Load variables from your .env file into os.environ
load_dotenv()
# 1. Set up the flow and the token vault
oauth_flow = GoogleOAuthFlow(
client_id=os.environ["GOOGLE_CLIENT_ID"],
client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
store=FileTokenStore("my_secure_token.json"),
redirect_port=8765, # must match an authorized redirect URI in Google Cloud Console
)
# 2. get_token() returns the cached token if it's still valid,
# otherwise it opens the browser and runs the full consent flow.
# Pass force=True to re-run consent even when a valid token exists.
token = oauth_flow.get_token()
# 3. Initialize your client with the raw access token
with GoogleCalendarClient(token=token.access_token) as calendar:
for event in calendar.events.upcoming(max_results=3):
print(event.get("summary"))
Note:
get_token()does not silently refresh an expired token — it re-runs the interactive consent flow when the stored token is missing or expired. If you want silent, non-interactive refreshes using a savedrefresh_token, callrefresh_access_token()fromhakiapi.core.oauth.refreshexplicitly (see OAuth 2.0 below).
2. Automatic Pagination (GitHub)
Forget page numbers, while loops, and manually checking for a next page. paginate() follows Link headers automatically and yields lazily:
from hakiapi.clients.github import GitHubClient
with GitHubClient() as github:
# Lazily walks every page of the user's public repos
for repo in github.get_all_user_repos("torvalds"):
print(repo["name"])
3. Async Requests (httpx-based)
AsyncBaseAPIClient mirrors BaseAPIClient method-for-method, just with async/await — including the governor and circuit breaker:
import asyncio
from hakiapi import AsyncBaseAPIClient
async def main():
async with AsyncBaseAPIClient(base_url="https://api.github.com") as client:
user = await client.get("/users/torvalds")
print(user["name"])
asyncio.run(main())
4. Tuning (or disabling) resilience
The governor and circuit breaker are on by default because production readiness should be the default. Override per client when you need to:
from hakiapi import BaseAPIClient
from hakiapi.core.circuit_breaker import CircuitBreaker
from hakiapi.core.governor import PredictiveGovernor
# Custom tuning: trip sooner, keep a wider rate-limit safety margin
client = BaseAPIClient(
base_url="https://api.example.com",
governor=PredictiveGovernor(safety_margin=5),
circuit_breaker=CircuitBreaker(failure_threshold=3, recovery_timeout=15.0),
)
# Or opt out entirely (e.g. for local mocks / tests)
bare = BaseAPIClient(
base_url="http://localhost:8000",
enable_governor=False,
enable_circuit_breaker=False,
)
5. Self-authenticating clients (OAuth2Auth) + silent refresh
OAuth2Auth wraps any flow object exposing get_token() and injects a fresh Authorization: Bearer header on every request — no manual token plumbing in your endpoint methods:
from hakiapi import BaseAPIClient
from hakiapi.core.auth import OAuth2Auth
class MyGoogleClient(BaseAPIClient):
def __init__(self, oauth_flow, **kwargs):
super().__init__(
base_url="https://www.googleapis.com/calendar/v3",
auth=OAuth2Auth(oauth_flow), # calls flow.get_token() per request
**kwargs,
)
Expired but refreshable? Refresh silently without opening a browser — and if Google reports the grant as revoked, the store is wiped automatically so the next get_token() falls back to the interactive flow:
import os
from hakiapi.core.oauth.refresh import refresh_access_token
stored = oauth_flow.store.get_token()
if stored is not None and stored.is_expired and stored.refresh_token:
token = refresh_access_token(
stored,
client_id=os.environ["GOOGLE_CLIENT_ID"],
client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
store=oauth_flow.store,
)
else:
token = oauth_flow.get_token()
get_token()itself never refreshes silently — it returns the cached token when valid, otherwise re-runs interactive consent (passforce=Trueto re-run consent unconditionally). Wirerefresh_access_token()in yourself when you want non-interactive renewal.
Resilience: Built In, Not Bolted On
Retries help you survive a failure. The governor helps you avoid one, and the breaker helps you stop amplifying one. Every request through any HakiAPI client flows through this pipeline — no extra code required:
flowchart LR
A[Your call<br/>client.get] --> B{Predictive<br/>Governor}
B -->|quota low? sleep first| C{Circuit<br/>Breaker}
C -->|OPEN? fail fast| X[CircuitOpenError<br/>+ retry_after]
C -->|CLOSED / HALF_OPEN| D[HTTP request<br/>+ retries]
D --> E[Update governor<br/>from rate-limit headers]
E --> F{Status?}
F -->|2xx / 4xx: healthy| G[breaker._on_success]
F -->|5xx / timeout /<br/>network error| H[breaker._on_failure]
G --> I[Return JSON / text<br/>or raise typed error]
H --> I
Three layers, three jobs:
| Layer | Job | Where it lives |
|---|---|---|
| Retry engine | Survive transient blips (429/500/502/503/504) with exponential backoff |
core/retry.py (sync, via HTTPAdapter) · native backoff + jitter in AsyncBaseAPIClient |
PredictiveGovernor |
Prevent 429s by sleeping proactively when headers say quota is nearly gone |
core/governor.py, wired into _request() pre- and post-flight |
CircuitBreaker |
Stop hammering a dead downstream: fail fast, then probe recovery with one trial call | core/circuit_breaker.py, wired into _request() pre- and post-flight; also works as @breaker decorator |
The circuit breaker state machine:
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN: failure_threshold<br/>consecutive failures
OPEN --> HALF_OPEN: recovery_timeout<br/>elapsed (lazy, no timer thread)
HALF_OPEN --> CLOSED: trial request succeeds
HALF_OPEN --> OPEN: trial request fails
CLOSED --> CLOSED: success resets<br/>failure counter
Migration note: the governor module was renamed from
hakiapi.core.governer(typo) tohakiapi.core.governorin v2.1.5. The old import path still works via a deprecation shim (DeprecationWarning), but new code should import fromhakiapi.core.governor.
Core Concepts
Exception Handling
Every exception raised by BaseAPIClient._request() (and its async twin) inherits from HakiAPIError, carrying status_code and the original response object:
from hakiapi.core.exceptions import AuthenticationError, RateLimitError, ServerError
try:
github.get_user("torvalds")
except RateLimitError as e:
print(f"Rate limited — retry after {e.retry_after}s")
except AuthenticationError:
print("Invalid credentials.")
except ServerError:
print("GitHub is currently unavailable.")
| Exception | Raised when | Extra attributes |
|---|---|---|
RateLimitError |
HTTP 429 |
retry_after — parsed from the Retry-After header, if present (async clamps to ≤300s) |
AuthenticationError |
HTTP 401 / 403 |
auth_method |
ClientError |
Any other 4xx |
— |
ServerError |
Any 5xx |
— |
RequestTimeoutError |
The request times out at the network level (no HTTP response was ever received) | timeout_duration |
CircuitOpenError |
Call blocked because the breaker is OPEN (no HTTP request was made) | retry_after — seconds left in cooldown, clamped to >= 0 |
OAuthFlowError |
Interactive consent / code exchange / silent refresh fails (hakiapi.core.oauth.google) |
Inherits HakiAPIError (message, status_code, response) |
One except HakiAPIError block works for both sync and async clients — they raise from the exact same hakiapi.core.exceptions module (CircuitOpenError lives in hakiapi.core.circuit_breaker, OAuthFlowError in hakiapi.core.oauth.google, both subclassing HakiAPIError).
Authentication Strategies (core/auth.py)
All strategies implement requests.auth.AuthBase, so they drop straight into BaseAPIClient(auth=...):
from hakiapi.core.auth import BearerTokenAuth, HeaderApiKeyAuth, QueryApiKeyAuth, HmacAuth, OAuth2Auth
BearerTokenAuth(token)— setsAuthorization: Bearer <token>.HeaderApiKeyAuth(header_name, api_key)— injects the key under a custom header.QueryApiKeyAuth(param_name, api_key)— appends the key as a query parameter, preserving any existing query string (duplicate keys kept).HmacAuth(api_key, secret_key, ...)— signs each request with HMAC-SHA256 overMETHOD\nPATH\nTIMESTAMP\nBODY(newline-delimited to prevent field-collision signature forgery), sending the key, timestamp, and signature as headers. Customizable header names viaapi_key_header/signature_header/timestamp_header. RaisesTypeErrorfor streaming bodies, which aren't supported, andValueErrorif the request has no HTTP method to sign.OAuth2Auth(flow)— wraps any object exposingget_token()(likeGoogleOAuthFlow) and injects a freshAuthorization: Bearerheader on every request. See Quick Start §5 for a fullBaseAPIClient(auth=OAuth2Auth(flow))example.
Retry Engine (core/retry.py + async native retries)
create_retry_adapter() builds an HTTPAdapter backed by urllib3.util.Retry:
- 3 retries by default, with an exponential
backoff_factorof1.0. - Retries on
429, 500, 502, 503, 504by default (configurable viastatus_forcelist; HTTP methods viaallowed_methods). raise_on_status=False—urllib3never raises on its own; HakiAPI's typed exceptions handle the final failure.- Mounted on both
http://andhttps://for every syncBaseAPIClientsession automatically.
The async client can't mount a urllib3 adapter onto httpx, so it re-implements the same policy inside _request(): exponential backoff (backoff_factor * 2**attempt plus jitter) on 429/500/502/503/504 plus httpx.TimeoutException / httpx.RequestError, up to max_retries (default 3, backoff_factor default 0.5). A valid Retry-After header on a 429 sleeps exactly that long (clamped to 300s) instead of the backoff curve.
Predictive Governor (core/governor.py)
The governor answers one question before every request: "does the last response say we're almost out of quota?" If yes, it sleeps just long enough for the quota window to reset — turning a would-be 429 into a slightly slower 200.
from hakiapi import BaseAPIClient
from hakiapi.core.governor import PredictiveGovernor
# Keep 5 calls of headroom instead of the default 1
client = BaseAPIClient(
base_url="https://api.github.com",
governor=PredictiveGovernor(safety_margin=5),
)
# The governor learns automatically — no per-request code needed.
# After each response it reads the rate-limit headers;
# before each request it sleeps only if the budget is exhausted.
How it works:
| Step | Detail |
|---|---|
| Per-host registry | Quota state is keyed by base_url, so one client never confuses GitHub's quota with Gmail's. Unknown hosts cost 0.0s (no sleep). |
| Header parsing | Supports GitHub-style (X-RateLimit-Remaining + X-RateLimit-Reset as epoch seconds) and IETF-draft (RateLimit-Remaining + RateLimit-Reset as delta-seconds), matched case-insensitively. Unrecognized or malformed headers leave state untouched. |
safety_margin |
Sleep kicks in when remaining <= safety_margin (default 1). Raise it for spiky workloads; lower to 0 to live dangerously. |
| Expiry handling | If the stored reset_timestamp has already passed, the governor resets the budget to infinity and returns 0.0 — no stale blocks. |
| Pacing | Sync sleeps with time.sleep(wait); async sleeps with await asyncio.sleep(wait) so the event loop stays free. |
| Thread-safety | All registry reads/writes are guarded by a single threading.Lock. |
| Opt-out | BaseAPIClient(..., enable_governor=False) (same for async), or inject a shared PredictiveGovernor across clients to coordinate pacing. |
Circuit Breaker (core/circuit_breaker.py)
CircuitBreaker protects downstream services from cascading failures. Since v2.1.x it is wired into every client automatically — and it still works as a standalone decorator for anything else:
from hakiapi.core.circuit_breaker import CircuitBreaker, CircuitOpenError
from hakiapi.core.exceptions import HakiAPIError
breaker = CircuitBreaker(
failure_threshold=5, # consecutive failures before the circuit opens
recovery_timeout=30.0, # seconds to wait before allowing a trial request
expected_exceptions=(HakiAPIError,), # exception types that count as failures
)
@breaker
def get_user(user_id: str):
return github.get_user(user_id)
try:
get_user("torvalds")
except CircuitOpenError as e:
print(f"Downstream is unhealthy — retry in {e.retry_after:.1f}s")
Built-in wiring (both sync and async _request()):
- Pre-flight check — if the breaker
state == OPEN, raiseCircuitOpenError(retry_after=cooldown_left)without touching the network. - Failure tracking — timeouts, network errors, and
5xxresponses call_on_failure(). (Async treats4xx/2xxas "server is alive" →_on_success(); sync resets on any non-5xxthat reaches response handling.) - Tuning / opt-out — pass
circuit_breaker=CircuitBreaker(...)orenable_circuit_breaker=Falseto either client constructor.
State machine reference:
| State | Behavior |
|---|---|
CLOSED |
Normal operation. Requests pass through; a successful call resets the failure counter. |
OPEN |
Failing fast. Every call is blocked instantly with CircuitOpenError (no call is made to the wrapped function) until recovery_timeout seconds have elapsed. |
HALF_OPEN |
Testing recovery. Once the timeout elapses, a single trial request is allowed through — success closes the circuit and resets the counter, failure reopens it immediately and restarts the cooldown. |
- Thread-safe. All state transitions and counters are guarded by a single
threading.Lock, so oneCircuitBreakerinstance can safely protect a callable shared across threads. - Lazy state evaluation. The
OPEN → HALF_OPENtransition happens the momentstateis read (or a wrapped call is made) afterrecovery_timeouthas elapsed — there's no background timer or polling thread. CircuitOpenErrorinherits fromHakiAPIErrorand carriesretry_after(seconds remaining in the cooldown, clamped to>= 0), so callers can back off intelligently instead of guessing.- Configurable failure scope.
expected_exceptionscontrols which exception types trip the breaker — defaults toHakiAPIError, so unrelated exceptions (e.g. aValueErrorfrom bad input) pass straight through without affecting circuit state. - Self-clamping configuration.
failure_thresholdis floored at1andrecovery_timeoutat0.1seconds, so a misconfigured0or negative value can't accidentally create a circuit that never opens or reopens instantly forever.
Async Client (core/async_base_client.py)
AsyncBaseAPIClient is a ground-up, httpx-backed implementation for asyncio codebases — not a thin wrapper around the sync client:
from hakiapi import AsyncBaseAPIClient
async with AsyncBaseAPIClient(
base_url="https://api.example.com",
timeout=10.0,
max_retries=3,
backoff_factor=0.5,
max_response_bytes=10 * 1024 * 1024,
headers={"X-Team": "haki"},
enable_governor=True, # default True
enable_circuit_breaker=True, # default True
) as client:
data = await client.get("/resource")
created = await client.post("/resource", json={"name": "haki"})
- Same resilience stack. Governor pacing (
await asyncio.sleep), breaker pre-flight + tracking, and native retry/backoff all run inside the async_request()loop. Retry-After-aware. A429with a validRetry-Afterheader sleeps for exactly that long (clamped to 300s) instead of the backoff curve; HTTP-date formats are ignored safely.- Same typed exception hierarchy. Imported from the same
hakiapi.core.exceptionsmodule — oneexceptblock covers both clients. - SSRF-safe.
base_urlmust behttp(s)with a real host (validated at construction); everyendpointmust be a relative path — absolute or protocol-relative values raiseValueError. Redirects are never followed automatically (follow_redirects=False). - Response-size guard. Bodies larger than
max_response_bytesraiseHakiAPIErrorbefore being handed back, checked againstContent-Lengthwhen present. - Strict methods.
GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONSaccepted; anything else raisesValueError. (get/post/put/delete/patchhelpers cover the common five; the rest go through_request().) - Lifecycle.
async with ...callsclose()(which awaitshttpx.AsyncClient.aclose()) on exit;close()is idempotent.repr()is safe (AsyncBaseAPIClient(base_url=...)). get,post,put,delete,patchallawaitthrough the same_request()pipeline and acceptraw_response=Truefor the rawhttpx.Response.
Note:
AsyncBaseAPIClientis re-exported from the top-levelhakiapipackage (from hakiapi import AsyncBaseAPIClient) and fromhakiapi.core. It requires theasyncextra (pip install hakiapi[async]); importing it withouthttpxinstalled raises anImportErrorwith that hint.
Smart Pagination (core/paginator.py)
paginate(client, endpoint, max_pages=None, **kwargs) is a generator that keeps requesting pages until it runs out, detecting the item list and the "next page" signal from the response shape:
| Response shape | Items key | Next-page signal |
|---|---|---|
| Raw JSON list | the list itself | Link response header (rel="next") |
{"data": [...], "meta": {...}} (Twitter/X-style) |
data |
meta.next_token |
{"messages": [...], "nextPageToken": ...} (Gmail-style) |
messages |
nextPageToken |
{"items": [...], "nextPageToken": ...} (Calendar-style) |
items |
nextPageToken |
{"resultSizeEstimate": 0} (Gmail empty result) |
— | stops cleanly, no error |
Any other shape raises ValueError("Unexpected pagination response: ..."). Pass max_pages to cap how many pages are fetched. GitHub Link-header pagination follows the next URL's path + query automatically.
Use it directly in your own clients — no subclassing required:
from hakiapi import BaseAPIClient
from hakiapi.core.paginator import paginate
with BaseAPIClient(base_url="https://api.github.com") as client:
# Works with any paginated endpoint paginate() understands
for repo in paginate(client, "users/torvalds/repos", max_pages=3):
print(repo["full_name"])
OAuth 2.0 (core/oauth/)
The OAuth engine is split into three independent pieces:
token_store.py—OAuthToken(a dataclass withaccess_token,refresh_token,expires_at,scopes, and anis_expiredproperty with a 30-second leeway buffer) and theTokenStoreabstract base class (get_token/save_token/delete_token).FileTokenStoreis the concrete implementation: it serializes tokens to JSON, writes atomically via a temp file +os.replace(), creates parent dirs as needed, rejects empty files (None) and invalid JSON (ValueError), and sets0600permissions on the file.google.py—GoogleOAuthFlowdrives the full interactive Authorization Code flow: builds the consent URL (access_type=offline,prompt=consentto force a refresh token on every run), opens it withwebbrowser.open(), boots a one-shothttp.server.HTTPServeronlocalhost:<redirect_port>to catch the redirect, validates the CSRFstateparameter, and exchanges the authorization code for tokens via a direct POST to Google's token endpoint. RaisesOAuthFlowErroron denial, timeout, port conflicts, astatemismatch, or a failed exchange.get_token(force=False)returns the cached token when valid;force=Truere-runs consent unconditionally.refresh.py—refresh_access_token(token, client_id, client_secret, store)is a standalone function that exchanges a savedrefresh_tokenfor a newaccess_tokenwithout opening a browser. If Google rejects the refresh (revoked/invalid grant), it callsstore.delete_token()so the nextget_token()call cleanly falls back to the interactive flow. Requires the stored token to carry arefresh_token(raisesValueErrorotherwise); preserves the old refresh token and scopes when Google doesn't return new ones.
from hakiapi.core.oauth.refresh import refresh_access_token
current = flow.store.get_token()
if current and current.refresh_token:
try:
current = refresh_access_token(
current,
client_id="...",
client_secret="...",
store=flow.store, # updated in place on success, wiped on revocation
)
except Exception:
current = flow.get_token() # fall back to interactive consent
These three pieces are intentionally decoupled — GoogleOAuthFlow.get_token() only checks expiry and re-runs the interactive flow if needed; wiring in silent refreshes via refresh_access_token() is left to the caller (or to OAuth2Auth, once you build that logic into your own flow object).
OAuthFlowError (raised for denial, timeout, port conflicts, state mismatch, or failed exchange/refresh) inherits from HakiAPIError, so a single except HakiAPIError covers both transport and OAuth failures.
Bundled Clients
All clients are importable from the top level — from hakiapi import GitHubClient, GmailClient, GoogleCalendarClient, BaseAPIClient, AsyncBaseAPIClient — or from their module paths (hakiapi.clients.github, hakiapi.clients.gmail, hakiapi.clients.google_calendar). hakiapi.__version__ reports the installed version.
GitHubClient — REST + GraphQL + profile aggregation
from hakiapi import GitHubClient
with GitHubClient(token="ghp_...") as gh: # token is optional for public endpoints
gh.get_user("torvalds")
gh.search_users("location:hyderabad")
gh.get_all_search_users("python") # auto-paginated generator
gh.get_user_repos("torvalds") # single page
gh.get_all_user_repos("torvalds") # auto-paginated generator
gh.get_repo_languages("torvalds", "linux")
gh.get_aggregate_user_languages("torvalds") # sums languages across every repo
gh.get_user_authored_activity("torvalds") # recent authored PRs + issues
gh.check_readme_exists("torvalds", "linux") # True / False via HEAD
gh.check_top_repos_readmes("torvalds", repos) # top-N owned repos -> bool map
gh.execute_graphql(query, variables={...}) # raises HakiAPIError on GraphQL-level errors
gh.get_user_contributions("torvalds", from_date="2025-01-01T00:00:00Z")
gh.fetch_full_profile_data("torvalds") # one-call profile aggregation
get_aggregate_user_languages() walks every repository returned by get_all_user_repos() and silently skips any repo whose language lookup raises HakiAPIError, so one broken/empty repo doesn't fail the whole aggregation.
GraphQL Engine
GitHubClient isn't purely REST — it also ships a GraphQL execution layer on top of the same BaseAPIClient infrastructure, so GraphQL calls get the same retries, governor pacing, breaker protection, timeout handling, and auth as everything else.
from hakiapi.clients.github import GitHubClient
with GitHubClient(token="ghp_...") as gh:
data = gh.execute_graphql(
"""
query($login: String!) {
user(login: $login) {
name
bio
}
}
""",
variables={"login": "torvalds"},
)
print(data["user"]["name"])
execute_graphql(query, variables=None, **kwargs)— the low-level engine. ItPOSTs{"query": ..., "variables": ...}to GitHub's/graphqlendpoint and unwraps the response. GraphQL is notorious for returning HTTP200 OKeven when the query itself failed, with the real error buried in the response body —execute_graphqlchecks for an"errors"key in the payload and raises aHakiAPIErrorjoining every message it finds, instead of letting a broken query silently returnNone. On success it returns just the"data"portion of the payload.get_user_contributions(username, from_date=None, to_date=None, **kwargs)— a ready-made query built on top ofexecute_graphql. It fetches a 365-day contribution calendar (total + weeklycontributionDaysbreakdown) and lifetime PR/issue counts with recent nodes, optionally scoped to a date range.from_date/to_datemust be ISO 8601 strings (e.g."2025-01-01T00:00:00Z"). Returns{}when the user isn't found. Shape:{ "recent_contributions_365_days": { "total_contributions": 1200, "weeks": [{"contributionDays": [{"date": "...", "contributionCount": 3}]}], }, "lifetime_activity": { "pull_requests": {"total_count": 42, "recent_items": [...]}, "issues": {"total_count": 17, "recent_items": [...]}, }, }
Profile aggregation & README checks
check_readme_exists(owner, repo_name, **kwargs)—HEADs GitHub's canonicalrepos/{owner}/{repo}/readmeendpoint (5s default timeout). ReturnsTrueon200,Falseon404or anyHakiAPIError— it never raises for a missing README.check_top_repos_readmes(owner, repos, top_n=5, **kwargs)— filters out forks, sorts owned repos bystargazers_countdescending, checks the top N, and returns{repo_name: has_readme}.fetch_full_profile_data(username, **kwargs)— one call that aggregates everything: latest-100 repos (sorted byupdated), a primary-language breakdown, the contribution + lifetime activity payload above, top-5 README statuses, and each repo object annotated with ahas_readmeflag (True/False, orNonewhen outside the top N). Ideal for profile pages and dashboards.
GmailClient — resource-based routing
from hakiapi import GmailClient
with GmailClient(token=access_token) as gmail:
gmail.profile.get() # users/{id}/profile
gmail.labels.list() # users/{id}/labels
gmail.messages.get(message_id) # a single message
gmail.messages.list(max_pages=2) # auto-paginated generator
gmail.messages.search("is:unread") # auto-paginated generator with a query
gmail.messages.send({"raw": base64_rfc2822_string})
messages.send() requires the payload dict to carry a raw key with a base64url-encoded RFC 2822 string.
GoogleCalendarClient — resource-based routing
from hakiapi import GoogleCalendarClient
with GoogleCalendarClient(token=access_token) as cal:
cal.calendars.list(max_pages=1)
cal.events.get(event_id)
cal.events.list(calendar_id="primary")
cal.events.today() # midnight-to-midnight UTC, recurring events expanded
cal.events.upcoming(max_results=5) # next N events from now, single page
cal.events.create({"summary": "...", "start": {...}, "end": {...}})
cal.events.delete(event_id)
today() and upcoming() both auto-fill timeMin/timeMax, set singleEvents=True to expand recurring events, and sort by startTime — you only pass the calendar ID.
Create Your Own Client
Subclass BaseAPIClient, point it at a base URL, and define your endpoints as plain methods. Authentication, retries, governor pacing, breaker protection, timeout handling, and typed exceptions are inherited automatically:
from hakiapi import BaseAPIClient
class WeatherClient(BaseAPIClient):
def __init__(self, **kwargs):
super().__init__(base_url="https://api.open-meteo.com/v1", **kwargs)
def get_weather(self, latitude: float, longitude: float):
return self.get(
"forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current_weather": True,
},
)
if __name__ == "__main__":
# Hyderabad, Telangana, India
with WeatherClient() as client:
weather = client.get_weather(latitude=17.385, longitude=78.4867)
print(weather["current_weather"])
BaseAPIClient exposes get, post, put, patch, and delete, all routed through _request(), which handles the retry-mounted session, governor pacing, breaker checks/tracking, timeout errors, status-code-to-exception mapping, and JSON/text response parsing (falling back to response.text if the body isn't valid JSON). Pass raw_response=True to get the raw requests.Response instead — this is what paginate() uses internally to read the Link header.
Per-request options (both sync and async)
Every verb accepts the same passthrough kwargs — they go straight to requests / httpx:
| Option | Example | Notes |
|---|---|---|
params |
client.get("search/users", params={"q": "torvalds"}) |
Query string; paginate() manages page cursors for you when you use it |
json / data |
client.post("resource", json={...}) |
Request body |
headers |
client.get("resource", headers={"X-Trace": "1"}) |
Merged over session defaults per request |
timeout |
client.get("resource", timeout=5.0) |
Overrides the client's default timeout (10s) for that call only; timeouts raise RequestTimeoutError carrying timeout_duration |
raw_response |
client.get("resource", raw_response=True) |
Returns the raw requests.Response / httpx.Response (needed for Link headers, streaming, etc.) |
auth caveat |
BaseAPIClient(..., auth=(user, pw)) |
Besides the 5 AuthBase strategies, sync BaseAPIClient also accepts a (username, password) tuple (HTTP Basic via requests); the async client passes auth through to httpx instead |
Coordinating clients: shared governor + breaker inspection
from hakiapi import BaseAPIClient
from hakiapi.core.circuit_breaker import CircuitState
from hakiapi.core.governor import PredictiveGovernor
# One governor shared across clients paces them as a group
# (state is keyed by base_url, so same-host clients coordinate).
shared_governor = PredictiveGovernor(safety_margin=2)
a = BaseAPIClient(base_url="https://api.github.com", governor=shared_governor)
b = BaseAPIClient(base_url="https://api.github.com", governor=shared_governor)
# Inspect breaker health any time (lazy OPEN → HALF_OPEN transition included)
if a.circuit_breaker and a.circuit_breaker.state == CircuitState.OPEN:
print("GitHub downstream is cooling down — expect CircuitOpenError fast-fails.")
Need async? Subclass AsyncBaseAPIClient instead — same pattern, async def methods, await self.get(...):
from hakiapi import AsyncBaseAPIClient
class AsyncWeatherClient(AsyncBaseAPIClient):
def __init__(self, **kwargs):
super().__init__(base_url="https://api.open-meteo.com/v1", **kwargs)
async def get_weather(self, latitude: float, longitude: float):
return await self.get(
"forecast",
params={"latitude": latitude, "longitude": longitude, "current_weather": True},
)
Examples
Runnable scripts live in examples/ (plus example_oauth.py at the repo root for the end-to-end Google consent flow):
| Script | Shows |
|---|---|
examples/example_github.py |
GitHubClient REST + search + auto-pagination + language aggregation + execute_graphql + contributions (GITHUB_TOKEN) |
examples/example_gmail.py |
GmailClient profile / labels / messages.search + messages.get with max_pages safety valve (GMAIL_OAUTH_TOKEN) |
examples/example_google_calender.py |
GoogleCalendarClient calendar list + events.today() / events.upcoming() / events.get() (GOOGLE_CALENDAR_TOKEN) |
example_oauth.py |
GoogleOAuthFlow.get_token() → GoogleCalendarClient(token=...) → events.upcoming(max_results=5) (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) |
export GITHUB_TOKEN="ghp_..."
python examples/example_github.py
Architecture & Project Structure
hakiapi/
├── core/
│ ├── oauth/
│ │ ├── google.py # GoogleOAuthFlow — interactive Authorization Code flow
│ │ ├── refresh.py # refresh_access_token() — silent refresh via refresh_token
│ │ └── token_store.py # OAuthToken, TokenStore (ABC), FileTokenStore
│ ├── auth.py # BearerTokenAuth, HeaderApiKeyAuth, QueryApiKeyAuth, HmacAuth, OAuth2Auth
│ ├── retry.py # create_retry_adapter() — exponential-backoff HTTPAdapter factory
│ ├── governor.py # PredictiveGovernor — proactive rate-limit pacing (GitHub + IETF headers)
│ │ # (hakiapi.core.governer remains as a deprecated shim)
│ ├── circuit_breaker.py # CircuitBreaker — CLOSED/OPEN/HALF_OPEN fail-fast decorator + built-in wiring
│ ├── paginator.py # paginate() — Link-header + token-based pagination
│ ├── base_client.py # BaseAPIClient — session, retries, governor, breaker, exception mapping
│ ├── async_base_client.py # AsyncBaseAPIClient — httpx-based async counterpart (same guarantees)
│ └── exceptions.py # HakiAPIError hierarchy (+ CircuitOpenError lives in circuit_breaker.py)
│
└── clients/
├── github.py # GitHubClient — REST + GraphQL + profile aggregation + README checks
├── gmail.py # GmailClient — profile / labels / messages resources
└── google_calendar.py # GoogleCalendarClient — calendars / events resources
Every request's journey through the core:
flowchart TB
subgraph Client ["Your client (GitHub / Gmail / Calendar / custom)"]
E[endpoints]
end
E --> R["_request() in base_client / async_base_client"]
R --> G["governor.get_wait_time → sleep if needed"]
G --> C["breaker.state check → fail fast if OPEN"]
C --> H["HTTP (requests / httpx) + retries"]
H --> U["governor.update_from_headers"]
U --> B["breaker._on_success / _on_failure"]
B --> X["typed exception or parsed body"]
Design Principles
- Infrastructure should be written once.
- API clients should remain lightweight.
- Explicit is better than magical.
- Strong typing improves maintainability.
- Production readiness should be the default, not an afterthought.
- Developer experience matters as much as correctness.
Testing
pip install -e ".[dev,async]"
ruff check hakiapi tests
ruff format --check hakiapi tests
pytest
mypy hakiapi
bandit -r hakiapi -q
pip-audit
- ✅ 370 tests passing, 86.94% coverage (gate ≥85%) — verified locally on Python 3.14; CI runs matrix
3.10–3.14via.github/workflows/ci.yml - ✅ Lint: moderate Ruff (
E,F,I,UP,B,SIM, line-length 88, py310) +ruff format; auto-fix locally withruff check --fix+ruff format, CI enforces--checkfail - ✅ Types:
mypy hakiapiclean (asyncio_mode=strictfor tests) - ✅ Security:
banditclean (2nosecfalse positives:B311retry jitter inasync_base_client.py,B105public OAuth URL inoauth/google.py);pip-auditin CI - ✅ Core framework covered:
auth(33),retry(25),circuit_breaker(19),governor(7),paginator(22),base_client(72),async_base_client(35),exceptions(21) - ✅
AsyncBaseAPIClientcovered end-to-end viahttpx.MockTransport— success paths, retry/backoff,Retry-Afterhandling, timeouts, SSRF/endpoint validation, response-size limits, governor + breaker integration, and context-manager lifecycle - ✅
CircuitBreakercovered end-to-end — standalone state transitions (CLOSED → OPEN → HALF_OPEN), threshold clamping,retry_aftercalculation, success-resets-counter behavior, unexpected-exception passthrough (withtime.monotonicmocked), plus integration tests proving both sync and async clients track failures, fast-fail when OPEN, and reset on success - ✅
PredictiveGovernorcovered — no-state passthrough, safety-margin gating, wait-time math, stale-reset recovery, GitHub + IETF header parsing (case-insensitive), and unknown-header tolerance - ✅ Full OAuth 2.0 engine covered:
google.pyinteractive flow (12),refresh.pysilent refresh (5), fully mocked - ✅
FileTokenStoreatomic-write behavior covered (37) - ✅
GitHubClient(37),GmailClient(13),GoogleCalendarClient(32) covered
Note: async tests require
pytest-asyncio(included in thedevextra). If you runpytestwith a bare environment and seeasync def functions are not natively supported, install the dev extra first.
Troubleshooting
| Symptom | Cause → Fix |
|---|---|
ImportError: AsyncBaseAPIClient requires the 'async' extra |
httpx isn't installed → pip install hakiapi[async] |
async def functions are not natively supported under pytest |
Missing pytest-asyncio → pip install hakiapi[dev] |
OAuthFlowError: Couldn't start the local server on port 8765 |
Port already in use → pass a free redirect_port= and register http://localhost:<port>/ as an authorized redirect URI in Google Cloud Console |
OAuthFlowError: ... 'state' ... didn't match |
Forged or stale redirect → abort is intentional; re-run get_token(force=True) for a fresh state |
| Token keeps expiring mid-session | get_token() doesn't auto-refresh → call refresh_access_token() when token.is_expired (30s leeway) and a refresh_token exists |
RateLimitError despite the governor |
Governor only learns from X-RateLimit-* / RateLimit-* headers; APIs without those headers can't be paced proactively — raise safety_margin or catch RateLimitError.retry_after and sleep |
CircuitOpenError on every call |
Breaker is in cooldown after failure_threshold consecutive 5xx/timeouts → back off for e.retry_after seconds; inspect client.circuit_breaker.state; disable per-client with enable_circuit_breaker=False for local mocks |
ValueError: endpoint must be a relative path (async) |
Absolute/protocol-relative endpoint → pass a relative path ("users/me", not "https://..."); sync client joins base_url + endpoint without this guard |
ValueError: Unsupported base_url scheme (async) |
base_url must be http(s) with a host; sync client only strips trailing / |
HakiAPIError: Response body ... exceeds max_response_bytes (async) |
Lower the payload or raise max_response_bytes= (default 10 MiB); sync client has no size guard |
DeprecationWarning: hakiapi.core.governer is deprecated |
Typo'd import → switch to from hakiapi.core.governor import PredictiveGovernor |
Roadmap
Completed
- Base API framework (
BaseAPIClient) - Authentication strategies (Bearer, Header API Key, Query API Key, HMAC, OAuth2)
- Retry engine with exponential backoff
- Automatic pagination (Link header,
data/meta,messages/items+ token styles) - Typed exception hierarchy
- Interactive Google OAuth 2.0 flow (local redirect interceptor, CSRF-protected)
- Atomic
FileTokenStoreand standalone silent-refresh routine -
GitHubClient,GmailClient,GoogleCalendarClient - GitHub profile aggregation (
fetch_full_profile_data, README checks, contribution calendar) - Async client (
AsyncBaseAPIClient,httpx-based) - Circuit breaker (
CircuitBreaker) — standalone decorator and built into both clients by default - Predictive rate-limit governor (
PredictiveGovernor,core/governor.py) wired into both clients by default - Top-level
hakiapiexport forAsyncBaseAPIClient(pip install hakiapi[async]) - CI quality gates (Ruff, format, pytest+coverage, mypy, bandit/pip-audit, py3.10–3.14 matrix)
Planned
- Stripe client
- Twitter/X client
- Wire automatic silent refresh into
GoogleOAuthFlow.get_token() - Async versions of
GitHubClient/GmailClient/GoogleCalendarClienton top ofAsyncBaseAPIClient - Plugin system
- Remove deprecated
hakiapi.core.governershim (usehakiapi.core.governor)
Contributing
Contributions are welcome — bug fixes, documentation, tests, or new clients. Please open an issue before proposing major changes so we can discuss the approach first.
- Fork and create a feature branch.
- Auto-fix style:
ruff check --fix hakiapi tests && ruff format hakiapi tests. - Verify:
pytest(coverage gate ≥85%),mypy hakiapi,bandit -r hakiapi -q. - Open a PR — CI (
lint, pytest matrix3.10–3.14,mypy,security) must stay green.
License
MIT License — see LICENSE for details.
⭐ If HakiAPI saved you from rewriting the same API client for the tenth time, consider giving it a star.
It helps more developers discover the project and motivates future development.
📖 Documentation · 🐛 Report an Issue · 🤝 Contribute
Built with ❤️ by Gugilla Aakash
Metadata
Release files for hakiapi 2.1.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hakiapi-2.1.6.tar.gz | 100.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hakiapi-2.1.6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 147.3 kB
Release files / hakiapi-2.1.6.tar.gz
| Download URL | hakiapi-2.1.6.tar.gz |
|---|---|
| Size | 100.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
035780211249cc57f522c7ea4aeff14158c484d1edfb367829c5eb55715a897a
|
|
BLAKE2b-256 checksum How to use checksums |
4d41679d765aab30dca5b7a5325879b4cc95ce2b8e4e9d915ec2ca983e3106a3
|
| 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 21, 2026.
Transparency logRelease files / hakiapi-2.1.6-py3-none-any.whl
| Download URL | hakiapi-2.1.6-py3-none-any.whl |
|---|---|
| Size | 46.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1b1f6a9e79783ef3a738d1875498712f28de8ad308c901d7efeb7db8d4b6b51c
|
|
BLAKE2b-256 checksum How to use checksums |
b5002a9d520719f169f6ffa6a8ee6c7eae9c82d0d943bfdf48f55272e10c1ce3
|
| 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 21, 2026.
Transparency log