Skip to content
BoogeyCodesPublic

About

A small feature-flag service with deterministic percentage rollouts, built on FastAPI (Python)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8 Commits

Folders and files

Repository files navigation

flagpole

CI Python 3.11+ FastAPI License: MIT

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)"}

Why

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.

How a flag is evaluated

Rules are applied in this order; the first that matches wins:

  1. Master switch — if the flag is enabled: false, it is off for everyone.
  2. Disabled list — users in disabled_for are always off.
  3. Enabled list — users in enabled_for are always on.
  4. Rollout — otherwise the user's stable bucket (0–99) is compared to rollout_percentage. 100 is everyone, 0 is no one, and an anonymous request (no user_id) is on only at 100.

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.

API

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.

Running

pip install -e ".[dev]"
flagpole                      # serves on http://127.0.0.1:8000, docs at /docs
# or: uvicorn flagpole.app:app --reload

Testing

pytest

Twenty 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.

Project layout

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

Design notes

  • 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 FlagStore away; the interface is small so a PostgreSQL or Redis implementation would drop in without touching the API or the rules.

Where it would go next

  • A persistent store (PostgreSQL or Redis) behind the same FlagStore interface.
  • 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.

License

MIT

About

A small feature-flag service with deterministic percentage rollouts, built on FastAPI (Python)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages