A small feature-flag service built with FastAPI. Define flags, control who they're on for, and roll them out to a deterministic percentage of users — the same user always gets the same answer, so a gradual rollout never flickers.
# turn a feature on for 40% of users
curl -X POST localhost:8000/api/flags \
-H 'Content-Type: application/json' \
-d '{"key":"new-checkout","rollout_percentage":40}'
curl 'localhost:8000/api/flags/new-checkout/evaluate?user_id=ann'
# {"key":"new-checkout","user_id":"ann","enabled":true,"reason":"in rollout (bucket 37 < 40)"}Feature flags let you ship code dark and turn it on gradually. The part teams get wrong is the percentage rollout: if "is this user in the 40%?" is answered with a random number or a process-salted hash, a user flips in and out of the feature on every request or after every restart. flagpole answers it with a stable hash of the flag key and user id, so a user's membership in a rollout is fixed, and raising the percentage only ever adds users, never removes them. That monotonicity is a property the test suite checks directly.
It is intentionally small — an in-memory store and a clean REST API — so the evaluation logic is the visible part.
Rules are applied in this order; the first that matches wins:
- Master switch — if the flag is
enabled: false, it is off for everyone. - Disabled list — users in
disabled_forare always off. - Enabled list — users in
enabled_forare always on. - Rollout — otherwise the user's stable bucket (
0–99) is compared torollout_percentage.100is everyone,0is no one, and an anonymous request (nouser_id) is on only at100.
The bucket is sha256(key + ":" + user_id) mod 100. A cryptographic digest is used precisely because
Python's built-in hash() is salted per process and would not be stable across restarts.
GET /health liveness
GET /api/flags list all flags
POST /api/flags create a flag
GET /api/flags/{key} fetch one
PUT /api/flags/{key} replace one
DELETE /api/flags/{key} delete one
GET /api/flags/{key}/evaluate?user_id= evaluate for a user (user_id optional)
GET /docs interactive Swagger UI
A flag:
{
"key": "new-checkout",
"description": "gradual rollout of the redesigned checkout",
"enabled": true,
"rollout_percentage": 40,
"enabled_for": ["qa-team-lead"],
"disabled_for": ["customer-who-opted-out"]
}Keys are lowercase slugs (^[a-z0-9][a-z0-9_-]{1,63}$); rollout_percentage is validated to 0–100
by Pydantic, so bad input returns 422 automatically.
pip install -e ".[dev]"
flagpole # serves on http://127.0.0.1:8000, docs at /docs
# or: uvicorn flagpole.app:app --reloadpytestTwenty tests. The evaluation logic is unit-tested in isolation — rule precedence, determinism,
monotonicity in the percentage, and that a 30% rollout lands roughly 30% of 2,000 users on. The API
is tested end to end with FastAPI's TestClient: CRUD, duplicate and not-found handling, key and
percentage validation, and the evaluate endpoint reflecting lists and rollout.
src/flagpole
├── app.py FastAPI app and routes; store injected for testability
├── evaluation.py the pure decision logic and the stable bucketing hash
├── models.py Pydantic models with validation
├── store.py in-memory flag store (swap for a database later)
└── main.py uvicorn entry point
tests/ pytest: evaluation unit tests + API tests
- The store is injected.
create_app(store)takes a store, and routes depend on it through FastAPI's dependency system, so each test runs against a fresh store with no shared state. - Evaluation is pure.
evaluate(flag, user_id)has no I/O, which is why its properties can be tested exhaustively and cheaply. - In-memory on purpose. Persistence is a
FlagStoreaway; the interface is small so a PostgreSQL or Redis implementation would drop in without touching the API or the rules.
- A persistent store (PostgreSQL or Redis) behind the same
FlagStoreinterface. - Audit history: who changed a flag and when.
- Multiple environments (dev / staging / prod) per flag.
- Bulk evaluation for a user across all flags in one request.
MIT