Catalyst Python SDK
A feature flag client for the Catalyst platform that reads the current snapshot as it evaluates and decides locally.
Each check is a conditional request, so an unchanged environment costs a 304
with no body, and a toggle you flip in the dashboard is picked up on the next
check. The decision itself never leaves the process. The client talks to the
hosted API by default, so a working setup needs only a key and a project id.
from catalyst_sdk import CatalystClient
client = CatalystClient(
sdk_key="cp_prod_a1b2c3d4e5f6g7h8i9j0k1", # API Keys tab in the dashboard
project_id="7c9e6679-7425-40de-944b-e07fc1f90ae7",
env="prod",
)
if client.is_enabled("new-checkout", user_id="user_123",
attributes={"email": "alice@acme.com"}):
...
Install
pip install sdk-catalyst
The distribution is named sdk-catalyst (the catalyst-sdk name is taken on PyPI by an
unrelated project), but the import name is unchanged:
from catalyst_sdk import CatalystClient
From a checkout:
uv sync # or: pip install -e ".[dev]"
Only runtime dependency is httpx. Requires Python 3.11+.
Configuration
| Argument | Required | Notes |
|---|---|---|
sdk_key |
yes | An SDK key from the dashboard's API Keys tab, sent as X-SDK-Key. |
project_id |
yes | The bootstrap endpoint is strictly project scoped. |
host |
no | Base URL of the Catalyst API. Omit it for the hosted API. |
env |
no | Snapshot to load. Defaults to dev. |
timeout |
no | Per-request HTTP timeout, seconds. Default 5.0. |
refresh_on_evaluate |
no | Read the snapshot as part of each check. Default True. |
failure_backoff |
no | Seconds to stop retrying after a failed read. Default 5.0. |
offline |
no | Never touch the network. Implies refresh_on_evaluate=False. Useful for tests. |
raise_on_error |
no | Let read failures raise out of evaluation instead of degrading. |
project_id is worth calling out: /api/v1/bootstrap takes it as a query
parameter, so the SDK cannot infer it from the key.
Choosing the API host
| Source | Precedence |
|---|---|
host= argument |
highest |
CATALYST_HOST environment variable |
middle |
hosted API (catalyst_sdk.DEFAULT_HOST) |
default |
# Staging, a self-hosted instance, or a tunnel to your laptop.
client = CatalystClient(..., host="https://catalyst-api-uakz.onrender.com")
Freshness
By default every check reads, so there is no propagation delay to tune. Two knobs matter when that is not what you want:
# Evaluate from memory only; drive freshness yourself.
client = CatalystClient(..., refresh_on_evaluate=False)
client.start_auto_refresh(interval=30)
# A dead API should not add its timeout to every check.
client = CatalystClient(..., failure_backoff=15.0)
- Concurrent checks collapse into a single read, so a thread pool does not stampede the API.
- After a failure, reads are held off for
failure_backoffseconds, so an unreachable API costs one timeout per window rather than one per request. get_all()reads once regardless of how many flags it holds.
How it works
is_enabled("new-checkout", user_id="u1")
│
├──▶ GET /api/v1/bootstrap
│ If-None-Match: <last etag>
│ 304 → snapshot unchanged, no body
│ 200 → replace snapshot
│
└──▶ local decision against the snapshot → bool
- No startup fetch. Constructing a client performs no I/O, so it cannot fail on a cold or unreachable API. The first check is what reads.
- Conditional read. Every check sends
If-None-Match, so an unchanged environment costs a304with no body. The ETag derives from the project's environment version, which every snapshot-affecting mutation bumps. - Atomic swap. A single attribute assignment replaces the snapshot, so evaluation never sees a half-updated view.
- Cheap on the server. The API answers from a Redis snapshot keyed by environment version, so the read does not re-query every flag, state and rule. Redis being unavailable only makes it slower.
- Fails closed, never throws. A failed read serves
false. Serving a stale targeting decision is worse than not serving the feature, so there is no cached fallback anywhere in the SDK. An explicitrefresh()still raisesAuthorizationError, because a rejected key will not fix itself.
Evaluation precedence
The SDK mirrors the server exactly, in this order:
- Emergency kill switch → everyone gets
false, nothing else is looked at - Enable to all users → everyone gets
true, no targeting at all - Targeting rules, ascending
priority, first match wins → puts the user in the population. If rules exist and none matched, the user is filtered out and getsfalse. - Percentage rollout → a deterministic Murmur3 bucket over
flag_key:user_idsplits the eligible population, so0%serves nobody and100%serves everybody. A matched user outside the bucket gets the opposite of the rule's value, so the rules and the percentage partition the matched group exactly.
The bucket is a fixed number per user, so raising a percentage only ever adds users; it can never remove one. That is what makes a gradual rollout safe.
Reasons are reported on EvaluationResult.reason: KILL_SWITCH_ACTIVE,
ENABLE_ALL_USERS, RULE_AND_ROLLOUT, RULE_OUTSIDE_ROLLOUT,
PERCENTAGE_ROLLOUT, PERCENTAGE_OUTSIDE_ROLLOUT, DEFAULT_VALUE (rules
filtered the user out), FLAG_NOT_FOUND, NO_SNAPSHOT.
Supported operators: equals, not_equals, in, not_in, contains,
starts_with, ends_with, greater_than, greater_than_or_equal,
less_than, less_than_or_equal, exists, not_exists. Short aliases
(eq, neq, gt, gte, lt, lte, notExists) are accepted and
normalized.
A missing context attribute makes a condition false, so the rule safely skips instead of raising.
API
is_enabled(flag_key, user_id="", attributes=None) -> bool
The hot path: one conditional read, then a local decision.
evaluate(flag_key, ...) -> EvaluationResult
Same evaluation, but returns the full decision:
result = client.evaluate("new-checkout", user_id="user_123",
attributes={"email": "alice@acme.com"})
result.value # True
result.reason # 'RULE_MATCH' | 'PERCENTAGE_ROLLOUT' | 'KILL_SWITCH_ACTIVE'
# | 'DEFAULT_VALUE' | 'FLAG_NOT_FOUND' | 'NO_SNAPSHOT'
result.rule_id # the matched rule, when a rule fired
result.flag_version # snapshot version the decision came from
get_all(user_id="", attributes=None) -> dict[str, bool]
Evaluates the whole snapshot in one pass, for rendering a UI.
refresh() -> bool
Conditionally re-fetches. Returns True if a new snapshot was applied,
False if the server replied 304 Not Modified.
start_auto_refresh(interval=30.0, on_error=None) -> Thread
Daemon thread that calls refresh() on an interval. on_error receives every
refresh failure, including ones that were safely absorbed. Only needed with
refresh_on_evaluate=False; otherwise it is redundant with the per-check read.
client.start_auto_refresh(interval=15, on_error=lambda e: log.warning(e))
...
client.stop_auto_refresh()
Health and introspection
client.is_ready # bool - a snapshot is loaded
client.version # int - environment cache version
client.flag_keys() # list[str] (reads memory; read after a check or refresh)
client.stats # dict - refresh counters, etag, host, last error
client.last_error # str | None
Lifecycle
CatalystClient is a context manager and closes its HTTP pool:
with CatalystClient(...) as client:
...
Failing closed
There is no disk cache. If the SDK cannot reach Catalyst it serves false:
a flag check inside someone else's request must not become a 500, and it must
not serve a targeting decision the API can no longer confirm.
A 304 Not Modified is a successful read — the in-memory snapshot stays
valid and keeps serving. Only a genuine failure flips to false, and the next
successful read clears it.
client = CatalystClient(..., offline=True) # no network at all; serves false
Error handling
| Exception | Meaning |
|---|---|
ConfigurationError |
Missing sdk_key / project_id, or an empty host. Raised at construction. |
AuthorizationError |
Key rejected or revoked. Propagates from refresh(); absorbed during evaluation. |
BootstrapError |
Any other transport or protocol failure. Logged, and evaluation resolves to false. Set raise_on_error=True to propagate. |
Evaluation never raises. An unknown flag key, a missing snapshot, and a failed
read all resolve to false — a flag check inside someone else's request must
not become a 500, and must not turn a feature on by accident. Set
raise_on_error=True when you would rather find out.
Demo
examples/fastapi_demo/app.py is a runnable FastAPI service that gates a
checkout endpoint and explains its decisions.
export CATALYST_SDK_KEY="cp_prod_..."
export CATALYST_PROJECT_ID="<project uuid>"
export CATALYST_ENV="prod"
# Only when running against your own API:
# export CATALYST_HOST="http://localhost:8000"
python examples/fastapi_demo/app.py
curl localhost:9000/api/checkout?user_id=user_123 -H 'x-user-email: alice@acme.com'
curl localhost:9000/api/inspect?flag=new-checkout\&user_id=user_123
curl localhost:9000/health
Tests
pytest
Two layers guard correctness:
-
The SDK's own suite covers hashing, the operator matrix, precedence, ETag handling, refresh, auto-refresh, and failing closed.
-
backend/tests/test_sdk_parity.pyis a differential test that runs the server's evaluator and the SDK's evaluator over the same fuzzed inputs and requires identical values, reasons, and rule ids. It also checks Murmur3 against the realmmh3library. Run it with the backend suite:cd backend && uv run pytest tests/test_sdk_parity.py
Releasing
Releases are automated. .github/workflows/publish-sdk.yml runs on every push to main that
touches this package, and publishes to PyPI if the version in pyproject.toml is new.
To cut a release:
- Bump
versioninpyproject.toml - Update
CHANGELOGnotes in the commit message - Merge to
main
The workflow runs the SDK suite and the server parity suite before publishing, so a broken version never reaches PyPI. If the version already exists on PyPI the publish is skipped, which means a merge that only edits docs will not fail. Re-running a failed release is possible from the Actions tab via Run workflow.
Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in this repository.
License
MIT
Metadata
Release files for sdk-catalyst 0.3.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 | Provenance | Actions |
|---|---|---|---|---|
| sdk_catalyst-0.3.0.tar.gz | 46.1 kB | With provenance | Details for sdk_catalyst-0.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| sdk_catalyst-0.3.0-py3-none-any.whl | Python 3 | none | any | Details for sdk_catalyst-0.3.0-py3-none-any.whl |
Total release size: 68.0 kB
Release files / sdk_catalyst-0.3.0.tar.gz
| Download URL | sdk_catalyst-0.3.0.tar.gz |
|---|---|
| Size | 46.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2ce7e817b469b6978a78897a32268fa7a049873a536c06d3936e9cc73e1ac0cc
|
|
BLAKE2b-256 checksum How to use checksums |
733911141e97156b8f9b7c89232ee5fe6a19c7011f12b0c1d6f1c71ba252e126
|
| 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 Oct 2, 2026.
Transparency logRelease files / sdk_catalyst-0.3.0-py3-none-any.whl
| Download URL | sdk_catalyst-0.3.0-py3-none-any.whl |
|---|---|
| Size | 21.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
476e018f0ecc970ce0743d201c77e12efe7d1f4de26799b71bf104fae0474633
|
|
BLAKE2b-256 checksum How to use checksums |
c706523cdc6fd6df86cad02f3c8ab07d280ffd947f5b5ed93378db91d19495cd
|
| 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 Oct 2, 2026.
Transparency logSecurity for sdk-catalyst 0.3.0
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.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for sdk_catalyst-0.3.0.tar.gz
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 Oct 2, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.