Async-first Python 3.13 framework bundling an HTTP client with OAuth 2.0, Redis caching, Flagr feature flags, and OpenTelemetry observability into one internal dependency.
Project description
code-framework
code-framework is a Python 3.13, async-first framework that bundles five building blocks
commonly needed by backend services into one internal dependency:
code_framework/http— an async HTTP client (HttpClient, wrappinghttpx.AsyncClient) with an OAuth 2.0 client-credentials helper (OAuthClient), thin request helper functions, and ASGI middleware for binding a per-request client to acontextvarscontext.code_framework/cache— an async Redis cache client (CacheClient) plus a@cachecache-aside decorator with distributed-lock thundering-herd protection and TTL helpers.code_framework/flags— an async Flagr feature-flag client (FlagrClient) with optional cache-aside evaluation.code_framework/observability— structured JSON logging plus OpenTelemetry distributed tracing, wired up with one call (setup_logger).code_framework/exceptions— a standardized HTTP status-code exception hierarchy (FrameworkHttpError+ subclasses) shared by the request helpers andOAuthClient.
All I/O is async/await. TLS 1.2+ is enforced by default everywhere a network connection is
opened. The package is published to PyPI via GitHub Actions and is meant to be consumed as a
dependency, not vendored. Proprietary — all rights reserved (see LICENSE); published on
PyPI for internal distribution convenience, not as an open-source release.
Installation
Base install only requires httpx — redis and the OpenTelemetry stack are optional extras,
since not every consumer needs caching or tracing:
[project]
dependencies = [
"code-framework",
]
Pull in only what you use via extras:
uv add code-framework # http + exceptions only
uv add "code-framework[cache]" # + CacheClient (redis)
uv add "code-framework[observability]" # + setup_logger/ObservabilityFilter (OTel SDK)
uv add "code-framework[all]" # everything
or with pip:
pip install code-framework
pip install "code-framework[cache,observability]"
code_framework.cache.CacheClient and code_framework.observability.setup_logger /
ObservabilityFilter are resolved lazily (PEP 562) — importing code_framework.http or
code_framework.flags never requires redis or the OTel packages to be installed; only
touching those two specific symbols does, and only then do you need the matching extra.
Requires Python 3.13+.
Quickstart: HttpClient
HttpClient (code_framework/http/client.py) is always used as an async context manager — accessing
.session outside the async with block raises RuntimeError.
from code_framework.http import HttpClient, get
async def main() -> None:
async with HttpClient(base_url="https://api.example.com", timeout=30.0) as client:
response = await get(client, "/users/1", raise_for_status=True)
print(response.json())
Constructor signature (code_framework/http/client.py):
HttpClient(
base_url: str = "",
timeout: float = 30.0,
response_extractor: Callable[[httpx.Response], dict[str, Any]] | None = None,
root_extractor: Callable[[httpx.Response], dict[str, Any]] | None = None,
)
base_url/timeoutare passed straight through to the underlyinghttpx.AsyncClient(follow_redirects=Trueis always on).root_extractorandresponse_extractorboth run on every response via anhttpxevent hook and feed data into the outbound observability context (see Observability below). They're merged in order —root_extractorfirst, thenresponse_extractoroverlays on top (same key wins forresponse_extractor). Useroot_extractorfor fields common to every call through this client (wire it up once, e.g. inapp.pyalongsideHttpClientMiddleware); useresponse_extractoronly to add fields specific to one talker's calls — e.g. a request ID pulled from a response header that only this particular API returns.- TLS: an
ssl.SSLContextwithminimum_version = ssl.TLSVersion.TLSv1_2is created internally and passed asverify=.
Request helpers (code_framework/http/requests.py)
Each helper takes any object satisfying ClientProtocol (i.e. anything exposing a .session
property — HttpClient included) plus a URL, and forwards *args/**kwargs to the matching
httpx.AsyncClient method:
from code_framework.http import get, post, put, patch, delete, head, options
All seven accept raise_for_status: bool = False as a keyword-only convenience — pass
raise_for_status=True to call response.raise_for_status() before returning. Every call also
emits an http request log line (method, url, status, duration_ms) when outbound HTTP logging
is active (see HttpClientMiddleware below) — no setup needed beyond that middleware.
Log level tracks the response status, even without raise_for_status. A plain
get(client, url) call (the default, no exception involved) logs at INFO for a 2xx/3xx
response, WARNING for a 4xx, and ERROR for a 5xx — via log_level_for_status()
(code_framework/exceptions/mapping.py). This means a talker that never opted into raise_for_status=True
still gets severity-appropriate logs for a "normal" 404/409/etc. response. Status codes outside
the explicitly mapped set (see below) always log at ERROR, on the assumption that an
unrecognized status is worth a closer look regardless of its numeric range.
Error handling (code_framework/exceptions)
Request helpers translate failures into a typed exception hierarchy instead of letting raw
httpx exceptions leak out:
from code_framework.exceptions import FrameworkHttpError, NotFoundError, TransportError
try:
response = await get(client, "/users/1", raise_for_status=True)
except NotFoundError:
...
except FrameworkHttpError as exc: # any other mapped 4xx/5xx
logger.warning("upstream call failed", extra={"key": exc.key, "status": exc.status_code})
raise_for_status=True+ a 4xx/5xx response →response.raise_for_status()raiseshttpx.HTTPStatusError, which is caught and re-raised (raise ... from exc, original preserved as__cause__) as the matchingFrameworkHttpErrorsubclass viaexception_for_status()(code_framework/exceptions/mapping.py):BadRequestError(400),UnauthorizedError(401),ForbiddenError(403),NotFoundError(404),ConflictError(409),UnprocessableEntityError(422),TooManyRequestsError(429),InternalServerError(500),BadGatewayError(502),ServiceUnavailableError(503),GatewayTimeoutError(504), orUnknownHttpErrorfor any other status code.- Network/timeout failures (
httpx.HTTPErrorother thanHTTPStatusError) always raiseTransportError— regardless ofraise_for_status, since there is no response to decide against. This is a behavior change from a plainhttpxclient: a talker that never setraise_for_status=Truestill needs to handleTransportError(or the common baseFrameworkHttpError) around connection failures/timeouts. - Every mapped/transport failure also emits an
http request failedlog line (namespaceshttpanderror, witherror.keymatching the exception'skey) whenever outbound HTTP logging is active — the same gating as the success-path log. The level follows the exception's ownlog_levelclass attribute:WARNINGfor the 4xx classes above,ERRORfor the 5xx classes,TransportError, andUnknownHttpError(the last one isERROReven when the underlying status code is a 4xx the framework doesn't explicitly recognize — an unmapped code is treated as worth escalating, not assumed to be a routine client error). - Each exception carries
status_code,key,message(default_messageunless you construct it yourself with a custom one), andlog_level(logging.WARNING/logging.ERROR); any extra keyword arguments passed to the constructor land in.troubleshooting.
Binding a client to the request context
code_framework/http/context.py exposes get_request_client, set_request_client, and
reset_request_client, backed by a ContextVar. HttpClientMiddleware
(code_framework/http/middleware.py) is an ASGI middleware that opens one client per request and binds it
automatically:
from code_framework.http import HttpClientMiddleware, get, get_request_client
def common_fields(response: httpx.Response) -> dict[str, Any]:
return {"request_id": response.headers.get("x-request-id", "")}
app.add_middleware(
HttpClientMiddleware,
base_url="https://api.example.com",
root_extractor=common_fields, # wired up once, applied to every call through this middleware
)
async def handler():
client = get_request_client() # the HttpClient opened for this request
return await get(client, "/users/1")
Pass client_factory instead of base_url/timeout to bind any other async-context-manager
client (a composite client, an OAuth-wrapping client, etc.) — see the docstring in
code_framework/http/middleware.py for the exact pattern. root_extractor is ignored when client_factory
is supplied (same rule as base_url/timeout) — reuse the same function inside your factory's
own HttpClient(...) call, and add a response_extractor alongside it for that talker's
call-specific fields, if needed.
OAuth 2.0 client-credentials flow
OAuthClient (code_framework/http/oauth.py) implements the client_credentials grant with in-memory
token caching (_TokenCache, a private @dataclass with a 30-second expiry buffer):
from code_framework.http import OAuthClient
oauth = OAuthClient(
token_url="https://auth.example.com/oauth/token",
client_id="my-client-id",
client_secret="my-client-secret",
scope="read:orders", # optional
)
headers = await oauth.auth_headers() # {"Authorization": "Bearer <token>"}
get_token()returns the cached token if still valid, otherwise POSTs totoken_urlwithgrant_type=client_credentials(application/x-www-form-urlencoded) and caches the result.auth_headers()is a convenience wrapper returning a ready-to-merge header dict.- Like
HttpClient, the internal token request uses anssl.SSLContextwithminimum_version = ssl.TLSVersion.TLSv1_2. - Errors are mapped to
code_framework.exceptions, same as the request helpers — a 4xx/5xx from the token endpoint raises the matchingFrameworkHttpErrorsubclass (e.g.UnauthorizedErroron 401), network/timeout failures raiseTransportError, and a malformed token response (non-JSON body, or JSON missingaccess_token) raisesUnknownHttpError. All chained (from exc) to the original error. OAuthClientnever logs, by design — unlikerequests.py, noERRORlog is emitted on failure here. The token request body carriesclient_secretand the response carriesaccess_token; to keep those out of any log pipeline entirely, this client raises the standardized exception and nothing else. If you need troubleshooting visibility, catch the raised exception at the call site and log only what you've confirmed is safe (e.g.exc.key/exc.status_code— never the raw response body).
Combine it with HttpClient by merging headers per call, or via a small composite client
injected through HttpClientMiddleware's client_factory.
Caching (code_framework/cache)
CacheClient (code_framework/cache/client.py) is an async Redis client, also used as an async context
manager, backed by a persistent connection pool:
from code_framework.cache import CacheClient
async with CacheClient(
host="localhost",
port=6379,
password="",
default_ttl=300,
max_connections=10,
use_ssl=True, # default; set False only for local dev without TLS
) as cache:
await cache.set("my-key", {"hello": "world"}, ttl=60)
value = await cache.get("my-key") # raw JSON string, or None on miss/error
exists = await cache.exists("my-key")
await cache.delete("my-key")
Notes on behavior (see docstrings in code_framework/cache/client.py for full detail):
set/appendJSON-serialize the value;getreturns the raw JSON string — deserialize it yourself (the@cachedecorator does this for you).- Failures are silent by design:
getreturnsNone,set/append/deletebecome no-ops,existsreturnsFalse. A cache miss is not an error. - TLS is on by default (
use_ssl=True), enforcingssl.TLSVersion.TLSv1_2minimum. cache.lock(key, timeout=5.0, blocking_timeout=2.0)returns a Redis distributed lock, usable asasync with cache.lock("my-key"):.
The @cache decorator
code_framework/cache/decorator.py provides cache-aside wrapping for async handlers, with
double-checked locking to avoid a thundering herd on cache miss:
from code_framework.cache import cache, build_cache_key
@cache(client=cache_client, key=build_cache_key("products", "detail", product_id), ttl=60)
async def get_product(product_id: int) -> dict:
...
ttl accepts an int, a zero-arg callable evaluated at each cache miss (e.g.
ttl_until_midnight), or None to use the client's default_ttl. Any client or
deserialization error falls back to calling the wrapped handler directly.
TTL and locking helpers (code_framework/cache/helpers.py)
from code_framework.cache import build_cache_key, resolve_ttl, ttl_until, ttl_until_midnight, ttl_until_next, safe_lock
build_cache_key(*parts)— joins parts with:(e.g.build_cache_key("detail", user_id)).resolve_ttl(value)— converts anintortimedeltato a clamped, non-negative seconds value.ttl_until(target_time, tz=UTC)/ttl_until_midnight(tz=UTC)/ttl_until_next(targets, tz=UTC)— seconds until a daily clock time (or the nearest of several), for clock-based expirations.safe_lock(client, key, timeout=5.0, blocking_timeout=2.0)— async context manager that yieldsTrue/Falsefor lock acquisition without ever raising; use it when you need explicit locking outside of@cache.
Protocols (code_framework/cache/protocols.py)
CacheClientProtocol (get/set/append/delete/exists/lock) and CacheObserverProtocol
(on_hit/on_miss/on_error) let you type-hint against the interface and substitute test doubles
or wrappers without inheriting from CacheClient.
Feature flags (code_framework/flags)
FlagrClient (code_framework/flags/client.py) evaluates feature flags against a Flagr server, also as
an async context manager:
from code_framework.flags import FlagrClient
async with FlagrClient(base_url="https://flagr.example.com") as flags:
enabled = await flags.is_enabled("pvp_new_mode", entity_id=user_id, entity_context={"rank": "5"})
variant = await flags.get_variant("checkout_experiment", entity_id=user_id)
is_enabledreturnsFalse,get_variantreturnsNone, on any HTTP/network/parse error — flags degrade gracefully rather than breaking the request.entity_contextpasses arbitrary attributes (e.g. rank, region) matched against segment constraints configured in the Flagr UI.- Optional transparent caching: pass
cache=<CacheClientProtocol>andcache_ttl=(same forms as@cache'sttl) to avoid a Flagr round trip on every evaluation. Only do this when the same flag/entity/context combination repeats — see the docstring incode_framework/flags/client.pyfor when caching helps vs. when the app should cache the result itself instead.
FeatureFlagProtocol (code_framework/flags/protocols.py) exposes is_enabled/get_variant for typing
against the interface instead of the concrete FlagrClient.
Observability
code_framework/observability provides structured JSON logging and OpenTelemetry tracing, wired up with a
single call at startup:
from code_framework.observability import setup_logger
setup_logger("orders-api") # local fallback: spans to console, trace_id/span_id in every log
setup_logger(service_name, otlp_endpoint=None, handler=None, level=logging.INFO, headers=None)
(code_framework/observability/setup.py):
-
Creates an OTel
TracerProviderwith aResourcetaggingservice_name, and instrumentshttpxautomatically viaHTTPXClientInstrumentor— every outboundHttpClientcall gets a span. -
Default is a local, infra-free fallback:
otlp_endpoint=None(the default) exports spans to aConsoleSpanExporter, sotrace_id/span_idshow up in logs with no collector running — useful for local dev and tests. -
Pass
otlp_endpointto export to a real destination (a self-hosted OTel Collector, a Datadog Agent's OTLP intake, a vendor's SaaS OTLP endpoint, ...) — spans go to{otlp_endpoint}/v1/tracesviaOTLPSpanExporter. This function never reads environment variables itself; reados.getenv(...)at the call site if you want that driven by config:import os from code_framework.observability import setup_logger setup_logger("orders-api", otlp_endpoint=os.getenv("OTLP_ENDPOINT"))
-
headersare forwarded to every OTLP export request — e.g.{"DD-API-KEY": "..."}for a vendor SaaS OTLP intake that requires an API key. Ignored whenotlp_endpointisNone. -
Installs
ObservabilityFilter(code_framework/observability/filter.py) andObservabilityFormatter(code_framework/observability/formatter.py) on the givenhandler(or aStreamHandler(sys.stderr)by default) and attaches it to the root logger atlevel.
After calling setup_logger, every logging.getLogger(...).info(...) call emits structured
JSON with timestamp, level, key (logger name), message, framework_version, trace_id,
span_id, and — when populated — namespaced fields http, cache, flag, error, context.
Per-request context
ObservabilityMiddleware (code_framework/observability/middleware.py) is an ASGI middleware that
attaches a structured context to each HTTP request and emits one log per request on completion
(method, path, user-agent, status, duration_ms, plus anything set during the request). The level
mirrors the same log_level_for_status() rule used by the outbound request helpers: INFO for a
2xx/3xx response this service returns, WARNING for a 4xx, ERROR for a 5xx — so a handled
"not found" response logs differently from a normal one without anyone needing to raise or catch
anything. If the wrapped app lets an exception propagate uncaught, the log is always ERROR with
exc_info=True (populating error.traceback) regardless of status code, and the exception
continues propagating after the log is emitted:
from code_framework.observability import ObservabilityMiddleware
app.add_middleware(
ObservabilityMiddleware,
context_extractor=lambda headers: {"tenant": headers.get("x-tenant-id", "")},
)
Application code can enrich the current request's context with
set_context(message="...", extra={...}) (code_framework/observability/context.py, re-exported from
code_framework.observability), which sets the eventual log's message and merges extra into the
context namespace. Framework components (CacheClient, FlagrClient, HttpClient's outbound
logging) populate their own namespaces (cache, flag, http) automatically via the internal
sign_context helper — you don't need to call it yourself.
Security notes
TLS 1.2+ (ssl.TLSVersion.TLSv1_2 minimum) is enforced by default in every component that opens
a network connection:
HttpClient(code_framework/http/client.py)OAuthClient(code_framework/http/oauth.py)CacheClient(code_framework/cache/client.py, whenuse_ssl=True, the default — passuse_ssl=Falseonly for local development against a non-TLS Redis instance)
Do not weaken these settings when extending the framework.
Development workflow
uv sync --all-extras # install runtime + dev dependencies
make analyse # ruff format, ruff check --fix, bandit, semgrep, pip-audit
make test # uv run pytest (asyncio_mode = "auto", tests live in tests/)
make build # uv build
Individual tools (see pyproject.toml for exact config — line length 120, double quotes,
ruff rules E, W, F, I, B, C4, UP, S, N, RUF with S101/E501 ignored, bandit at medium
severity/confidence with B101 skipped):
uv run ruff format code_framework
uv run ruff check code_framework --fix
uv run bandit -c pyproject.toml -r code_framework
uv run semgrep scan --config=auto code_framework
uv run pip-audit --ignore-vuln GHSA-5239-wwwm-4pmq
uv run pytest
This repository also defines Claude Code skills/agents (.claude/skills/, .claude/agents/)
that automate this workflow for contributors using Claude Code — see CLAUDE.md for details.
CI/CD and releases
GitHub Actions (.github/workflows/):
ci.yml— on every push/PR toprdordev:make analysethenmake test.beta.yml— analyse, test, then build +uv publishto PyPI under thebetaenvironment. Two ways to trigger it:- Push a tag matching
v*b*(e.g.v1.0.0b1) directly — the exact version is whatever you tagged. - Run it manually (Actions tab → "Beta Release" → "Run workflow") and pick
patch/minor/majorfrom thebumpdropdown. The workflow computes the next version itself: it bumps the chosen segment off the latest stable tag (0.0.0if none exists yet — so aminorbump on this repo's first-ever release producesv0.1.0b1), then finds the highest existing beta already cut for that target and continues thebcounter (or starts atb1if none exists), creates that tag, pushes it, and runs the same analyse/test/publish pipeline against it.
- Push a tag matching
release.yml— same two-trigger shape asbeta.yml, but for stable releases: push a tag matchingv[0-9]+.[0-9]+.[0-9]+directly, or run it manually with abumpchoice. The computed tag has nobsuffix, and the workflow refuses to run (fails fast) if that exact version was already tagged before — it will never silently overwrite a published release.- Both
beta.ymlandrelease.ymldelegate their actual analyse → test → build → publish steps to a shared reusable workflow (_release-pipeline.yml), so there's one place that defines what "run the pipeline" means regardless of which trigger kicked it off.
Publishing uses OIDC trusted publishing (id-token: write) — no PyPI API tokens are stored as
secrets. The published version itself is never hand-edited — [tool.hatch.version] source = "vcs" derives it from whichever git tag triggered the run.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file code_framework-1.0.0b1.tar.gz.
File metadata
- Download URL: code_framework-1.0.0b1.tar.gz
- Upload date:
- Size: 104.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8f5e719eb958e7a005011266dd0b25ce22c2139517e5540e5265eb28d07cc368
|
|
| MD5 |
c55b5d565738e22188ae8ba6153ff0d9
|
|
| BLAKE2b-256 |
618f0412e1c797b45fd6f51b9bb54fc4d1b35af73eb5a2858e99962799d92322
|
File details
Details for the file code_framework-1.0.0b1-py3-none-any.whl.
File metadata
- Download URL: code_framework-1.0.0b1-py3-none-any.whl
- Upload date:
- Size: 41.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5f9aadbc1f5925016fc3dc3e759bce9be83b1a2786df1013252ac45adc5e060c
|
|
| MD5 |
ed42244efaefb4aeb9bdff6b93db76af
|
|
| BLAKE2b-256 |
804d6970c6e2571d13d0a5e467da7124c3ce1a0b0afd4f8b6db992bf5b6a4184
|