Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Feature Flags

A feature flag system built from scratch — evaluation engine, REST API, admin UI, and SDKs for JavaScript and Python.

Python TypeScript-friendly License Tests

What this is

A feature flag system lets you ship code and release it as two separate decisions. The code for a new checkout flow can sit in production, dark, behind a flag, while it's still being tested — then get turned on for 5% of users, then 50%, then everyone, without a redeploy. The same mechanism lets you target a specific segment (beta testers, enterprise accounts, a single country) or kill a feature instantly if it misbehaves, all from a UI or API call instead of a git revert.

This project implements that system end to end: a SQLite-backed data model, a deterministic evaluation engine, a REST API, a small admin UI to manage flags by hand, and SDKs so an application can check a flag with one line of code. It's built as a from-scratch counterpart to hosted products like LaunchDarkly or Unleash — the same core ideas (master switch, percentage rollout, attribute targeting, segments), implemented directly so every part of the evaluation path is inspectable.

Architecture

┌───────────────────────────────────────────┐
│                 Admin UI                   │
│           (vanilla JS SPA, dark mode)      │
└─────────────────────┬───────────────────────┘
                       │ REST API (fetch)
┌─────────────────────▼───────────────────────┐
│               FastAPI Server                 │
│  ┌───────────────┐   ┌──────────────────────┐│
│  │   Flag/Rule    │   │  Evaluation Engine   ││
│  │  API Routers   │   │   (FlagEvaluator)    ││
│  └───────┬───────┘   └──────────┬────────────┘│
│          └──────────┬───────────┘             │
│  ┌───────────────────▼───────────────────────┐│
│  │            Repository Layer                ││
│  │     FlagRepository  RuleRepository          ││
│  │              UserRepository                 ││
│  └───────────────────┬───────────────────────┘│
│  ┌───────────────────▼───────────────────────┐│
│  │        SQLAlchemy 2.0 ORM + SQLite          ││
│  └─────────────────────────────────────────────┘│
└───────────────────────────────────────────────┘
              ▲                       ▲
              │ HTTP                  │ HTTP
     ┌────────┴────────┐     ┌────────┴────────┐
     │   JS SDK          │     │  Python SDK      │
     │ (Node / browser)  │     │ (any Python app)  │
     └────────────────────┘     └────────────────────┘

Every layer has one job. The repository layer is the only thing that touches SQLAlchemy — it doesn't know evaluation logic exists. The evaluation engine takes a database session and a user context and returns a decision — it doesn't know about HTTP. The API routers translate HTTP into repository/engine calls and back into JSON — they don't contain business logic. The SDKs talk to the API the same way any external client would; they have no special access to the database. That separation is what makes each layer testable in isolation (see Design decisions).

How the evaluation engine works

FlagEvaluator.evaluate(flag_key, environment_name, user) walks a fixed decision path and returns as soon as one step produces an answer:

                    ┌─────────────────────────────┐
                    │  Does FlagEnvironment exist?  │
                    └───────────────┬───────────────┘
                          no │             │ yes
                    ┌────────▼──────┐      │
                    │   NOT_FOUND   │      │
                    └───────────────┘      │
                                    ┌───────▼────────┐
                                    │  enabled==False? │
                                    └───────┬────────┘
                                     yes│           │no
                              ┌─────────▼───┐       │
                              │  FLAG_OFF   │       │
                              └─────────────┘       │
                                            ┌────────▼─────────┐
                                            │ for each rule,    │
                                            │ priority ASC:     │
                                            │  segment or       │
                                            │  attribute match? │
                                            └────────┬─────────┘
                                              match│         │no match
                                        ┌───────────▼──┐      │
                                        │  RULE_MATCH  │      │
                                        └──────────────┘      │
                                                     ┌─────────▼─────────┐
                                                     │ bucket(user,flag) │
                                                     │  < rollout_pct?   │
                                                     └─────────┬─────────┘
                                                       yes│           │no
                                          ┌───────────────▼──┐  ┌─────▼────────┐
                                          │ PERCENTAGE_ROLLOUT│  │ DEFAULT_OFF  │
                                          └────────────────────┘  └───────────────┘
  1. Flag/environment lookup. If the flag doesn't exist, or was never configured for the given environment, the result is NOT_FOUND. This is distinct from FLAG_OFF — a typo'd flag key looks different from a flag that's intentionally disabled.
  2. Master switch. If FlagEnvironment.enabled is False, the result is FLAG_OFF regardless of any rules or rollout percentage — the switch always wins.
  3. Rules, in priority order. Each enabled rule is either segment-based (the user must satisfy every condition in the segment) or attribute-based (a single attribute operator value check, e.g. country equals "CL"). The first rule that matches returns RULE_MATCH immediately — rules are not combined with AND/OR across each other, only within a segment.
  4. Percentage rollout. If no rule matched, sha256(f"{flag_key}:{user_id}") maps the user into a bucket from 0–99. If that bucket is below rollout_percentage, the result is PERCENTAGE_ROLLOUT. The same user always lands in the same bucket for a given flag, so rollout membership is stable across requests without storing anything per-user.
  5. Default. Otherwise, DEFAULT_OFF.

Quick start

1. Start the server

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
uvicorn app.api.main:app --reload

The server seeds three environments (production, staging, development) on startup if they don't already exist.

2. Open the Admin UI

http://localhost:8000

Create a flag, flip it on for an environment, add a targeting rule, and try it out in the Evaluate playground — no separate build step, the UI is plain HTML/CSS/JS served as static files.

3. Use the JavaScript SDK

const { FeatureFlagsClient } = require('@jpurtu/feature-flags-sdk');

const client = new FeatureFlagsClient({
  baseUrl: 'http://localhost:8000',
  environment: 'production',
});

const enabled = await client.isEnabled('dark-mode', { user_id: 'user-123', plan: 'pro' });

4. Use the Python SDK

from feature_flags_sdk import FeatureFlagsClient

with FeatureFlagsClient(base_url="http://localhost:8000", environment="production") as client:
    enabled = client.is_enabled("dark-mode", {"user_id": "user-123", "plan": "pro"})

Feature flag concepts

Master switch. Every flag has an independent on/off state per environment (FlagEnvironment.enabled). A flag can be on in development, off in staging, and off in production at the same time — the same flag key, three separate states.

Percentage rollout. Setting rollout_percentage to 10 exposes the flag to roughly 10% of users, chosen by hashing flag_key:user_id with SHA256 and taking the result mod 100. Because the hash is deterministic, a given user either is or isn't in the rollout consistently — no per-user state needs to be stored, and raising the percentage from 10 to 20 only adds new users, it never removes existing ones.

Attribute targeting. A rule like country equals "CL" or plan in_list "pro,enterprise" checks a single attribute off the user context against a value, using one of ten operators (equals, not_equals, contains, not_contains, in_list, not_in_list, greater_than, less_than, starts_with, ends_with).

Segment targeting. A segment is a named, reusable set of attribute conditions (e.g. "beta testers" = plan equals enterprise AND country in_list "US,CA"). A rule can point at a segment instead of a single attribute; the user must satisfy every condition in the segment for the rule to match. Segments are defined once and can back rules on multiple flags.

Rule priority. Rules on a flag/environment are evaluated in priority order (lower first), and the first one that matches wins — evaluation stops there. A disabled rule (enabled=False) is skipped entirely, as if it didn't exist.

API reference

All routes are mounted under /api. Full interactive docs (generated by FastAPI) are available at http://localhost:8000/docs once the server is running.

Method Path Description
POST /api/flags Create a flag
GET /api/flags List all flags
GET /api/flags/{flag_key} Get a flag by key
PATCH /api/flags/{flag_key} Update a flag's name/description
DELETE /api/flags/{flag_key} Delete a flag
GET /api/flags/{flag_key}/environments/{env_name} Get a flag's state in an environment
PATCH /api/flags/{flag_key}/environments/{env_name} Update enabled/rollout_percentage
POST /api/environments Create an environment
GET /api/environments List all environments
POST /api/flags/{flag_key}/environments/{env_name}/rules Create a targeting rule
GET /api/flags/{flag_key}/environments/{env_name}/rules List rules, ordered by priority
PATCH /api/rules/{rule_id} Update a rule
DELETE /api/rules/{rule_id} Delete a rule
POST /api/segments Create a segment
GET /api/segments List all segments
POST /api/segments/{segment_id}/rules Add a condition to a segment
GET /api/segments/{segment_id}/rules List a segment's conditions
POST /api/evaluate Evaluate one flag for a user (used by SDKs)
POST /api/evaluate/batch Evaluate multiple flags for a user

SDK reference

JavaScript (sdks/javascript)

Method Returns On error
isEnabled(flagKey, user?) Promise<boolean> false
getFlags(flagKeys, user?) Promise<Record<string, boolean>> false per flag
evaluate(flagKey, user?) Promise<EvaluationResult | null> null
evaluateBatch(flagKeys, user?) Promise<Record<string, EvaluationResult | null>> null per flag

Constructor options: baseUrl, environment, defaultUser, timeoutMs (default 5000), cacheTtlMs (default off), onError.

Python (sdks/python)

Method Returns On error
is_enabled(flag_key, user=None) bool False
get_flags(flag_keys, user=None) dict[str, bool] False per flag
evaluate(flag_key, user=None) dict | None None
evaluate_batch(flag_keys, user=None) dict[str, dict | None] None per flag
close() None

Constructor options: base_url, environment, default_user, timeout (default 5.0), retries (default 1), cache_ttl (default off), on_error.

Both SDKs merge defaultUser/default_user under the per-call user argument, retry once on a network-level failure (never on a 4xx/5xx response), and never raise/reject to the caller — see each SDK's own README (linked above) for the full contract.

Evaluation reasons

Reason Meaning
NOT_FOUND The flag or environment doesn't exist
FLAG_OFF The flag exists but is disabled in this environment
RULE_MATCH A targeting rule (attribute or segment) matched the user
PERCENTAGE_ROLLOUT The user's deterministic bucket fell within the rollout percentage
DEFAULT_OFF The flag is on, but no rule matched and the user fell outside the rollout

Design decisions

SQLite. The data volume here — flags, rules, segments — is small and read-heavy, and SQLite gives zero-config portability: the whole system runs from alembic upgrade head with no external service to stand up. The repository layer is the only place that knows about SQLAlchemy, so swapping in Postgres later is a connection-string and migration-dialect change, not a rewrite.

A synchronous, stateless evaluation engine. FlagEvaluator.evaluate() takes a Session and a SDKUser and returns an EvaluationResult — no caching, no background state, no async. That makes it trivial to unit test (call it, assert the result) and easy to reason about (the five-step algorithm above is the whole implementation), at the cost of a database round-trip per evaluation. For this project's scale that trade is worth it; a production system with very high QPS would likely add a read-through cache in front of it without changing the algorithm itself.

SHA256 for rollout bucketing. Hashing flag_key:user_id gives a deterministic, uniformly distributed bucket without persisting which users are "in" a rollout. Raising the percentage is monotonic — everyone previously included stays included — which is the property that makes gradual rollouts safe to reason about.

Zero dependencies in both SDKs. The JavaScript SDK uses native fetch/AbortController; the Python SDK uses urllib.request. A flag-evaluation client is exactly the kind of thing that ends up embedded in every service in an organization — it shouldn't be the thing that causes a dependency conflict or a supply-chain concern three layers down.

Repositories separate from the evaluation engine. FlagRepository/RuleRepository/UserRepository know how to read and write rows; FlagEvaluator knows the targeting algorithm and calls the repositories to get the data it needs. Neither depends on the API layer. This is what let the engine (Phase 2) be built and fully tested before the REST API (Phase 3) existed at all.

Running tests

pytest tests/ -v         # 95 tests: repositories, evaluation engine, REST API

Project structure

feature-flags/
├── app/
│   ├── database.py          # engine, SessionLocal, Base, get_db
│   ├── models/               # Flag, Environment, FlagEnvironment, Rule, Segment, SegmentRule, SDKUser
│   ├── repositories/         # FlagRepository, RuleRepository, UserRepository
│   ├── engine/                # FlagEvaluator, operators, SHA256 hasher
│   └── api/
│       ├── main.py           # FastAPI app, CORS, static mount, startup seeding
│       ├── dependencies.py   # get_db, get_evaluator
│       ├── routers/           # flags, environments, rules, evaluate
│       └── schemas/           # Pydantic request/response models
├── alembic/                   # migrations (001_initial_schema creates all tables)
├── static/                    # admin UI: index.html, css/, js/ (vanilla, no build step)
├── sdks/
│   ├── javascript/            # @jpurtu/feature-flags-sdk
│   └── python/                 # feature_flags_sdk
├── tests/                     # 95 tests across repositories, engine, and API
├── data/                       # gitignored — feature_flags.db lives here
├── alembic.ini
└── requirements.txt

License

MIT

About

Feature flag system built from scratch — evaluation engine, REST API, Admin UI, and SDKs for JavaScript and Python.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages