Build your own feature flags manager in Flask/Python

It may be an odd thing to be so enthused about a software development pattern but one of my absolute favorites is using feature flags to gate new features. I can't imagine building any reasonable size application with any kind of usage without them.

Feature flags are essentially "just" a nice way to do if/else checks, to choose what code paths are enabled for a user, but even a tiny bit of tooling to manage them can completely turbocharge your development velocity. Which is great! Hopefully that means your users get more useful product that much more quickly.

The first great thing about feature flags is that you can do incremental development of new features without having to wrangle separate feature branches. You can commit to main as often as you please, as long as you hide your partially complete work behind a feature flag. This is especially useful if you are collaborating with someone.

if feature_flag('NEW_FEATURE', 'ENABLED'):
    # show new feature

When you are ready to release a new feature, design, or variant, you can also do that selectively, adding people into the variant group for the feature flag to start seeing it. With feature flags, rollout of a new feature can be decoupled from deployment of the code for it, which is a lot safer, and it's much more easily reversible if something does go wrong.

if feature_flag('MY_EXPERIMENT', 'VARIANT_A'):
    # show variant a
elif feature_flag('MY_EXPERIMENT', 'VARIANT_B'):
    # show variant b
else:
    # show control

Buy vs. build

People much smarter and more experienced than I have written extensively about the buy vs. build decision, e.g. see this excellent analysis from Camille Fournier, author of The Manager's Path. I won't rehash the whole topic here but suffice to say, more often than engineers are typically inclined to choose it, the right decision is to buy. This is true at least in the context of engineering teams working on production systems of any scale.

As of the time of this writing, mid-2021, LaunchDarkly is the leading SaaS solution for feature flags. Based on the size of their business, lots of developers seem to find value in a nice system to manage their feature flags. At $10/user it's certainly a lot cheaper to use an off-the-shelf solution than to spend your engineering hours (billed at a much higher rate!) on building your own.

That being said, a basic version of feature flags is relatively easy to implement, and if you're working on a new project, you may not want the hassle of integrating with an external service or to be paying a monthly rate per developer to be able to check some booleans. If this is you, or if you're just curious to see an implementation of a feature flags manager, read on.

Build your own feature flags manager

I've always known the abstraction for a feature flags manager as "gatekeeper", having worked at Facebook and with numerous ex-Facebookers, as this is the internal service name for their feature flag and experimentation framework. It's a catchy name and has the benefit of being conveniently abbreviated as gk in code. I'll use it here as well.

In addition to gatekeeper itself, you also need the gatekeeper configs that specify what the feature flags are and the logic for putting users or sessions into different variants. Though the most professionalized feature flag solutions will use config files separate from code, for our home-grown, first version of gatekeeper, putting our configs in code is easiest and it works well enough.

The last bit is initializing and registering gatekeeper and making it for developers to use - super critical for a developer tool! To that end of making things as easy to use as possible, one more addition to the core abstraction that I've found unusually helpful is supporting browser overrides, which can be set with an admin dashboard.

So, to summarize, you'll need:

  1. The feature flags manager (gatekeeper)
  2. The feature flag configs (gatekeeper configs)
  3. To wire things up neatly to make it all easy to use

The below code is written in Python, for a web app using the Flask framework. Hopefully it is straightforward enough to read here, and to extend to other languages and frameworks!

(Note that I've included the code to support browser overrides, though not the admin dashboard code for setting and reading those overrides, which is separate.)

gatekeeper.py

from gatekeeper_config import FF_CONFIG_MAP


class Gatekeeper(object):
    def __init__(self, user_id=None,
                 app=None,
                 request=None,
                 session=None,
                 config_map=None):

        self.user_id = user_id
        self.app = app
        self.request = request
        self.session = session

        self.config_map = config_map if config_map else FF_CONFIG_MAP

    def ff(self, *args, **kwargs):
        '''Shorthand wrapper for `feature_flag`.'''
        return self.feature_flag(*args, **kwargs)

    def feature_flag(self, flag, variant):
        return self.get_feature_flag_variant(flag) == variant

    def ff_variant(self, *args, **kwargs):
        '''Shorthand wrapper for `get_feature_flag_variant`.'''
        return self.get_feature_flag_variant(*args, **kwargs)

    def get_feature_flag_variant(self, flag, user_id_override):
        config = self.config_map.get(flag)
        if not config:
            return None

        variant = config.get_variant(
            user_id=user_id_override or self.user_id,
            app=self.app,
            request=self.request,
            session=self.session)
        return variant.name

    def get_config_map(self):
        return self.config_map

    def get_browser_override_variants(self, request):
        return json.loads(request.cookies.get('gatekeeper', '{}'))

    def set_browser_override_variant(self, request, flag, variant):
        config = self.config_map.get(flag)
        if not config:
            return None

        return config.set_browser_override_variant(request, variant)


def initialize_gatekeeper(user_id=None, app=None, config_map=None):
    from flask import current_app
    from flask import request
    from flask import session
    from flask_login import current_user

    if not app:
        app = current_app

    if user_id is None and current_user and not current_user.is_anonymous:
        user_id = current_user.id

    gk = Gatekeeper(
        user_id=user_id,
        app=app,
        request=request,
        session=session,
        config_map=config_map)
    return gk

gatekeeper_config.py

from abc import ABC
from abc import abstractmethod
from enum import Enum
import json

import flask


class FeatureFlagConfig(ABC):
    FLAG_NAME: str
    VARIANTS_ENUM_STR: str

    def __init__(self, overrides=None):
        self.variants_enum = Enum(
            f'{self.__class__.__name__}Variants', self.VARIANTS_ENUM_STR)
        self.overrides = overrides

    def get_variants(self):
        return list(self.variants_enum.__members__.keys())

    @abstractmethod
    def _get_variant(
            self,
            user_id: Optional[int] = None,
            app: Optional[Flask] = None,
            request: Optional[Request] = None,
            session: Optional[Any] = None):
        pass

    def get_variant(self, user_id: Optional[int] = None, app: Optional[Flask] = None,
                    request: Optional[Request] = None, session: Optional[Any] = None):
        override_variant = self.get_override_variant(
            user_id=user_id, app=app, request=request, session=session)
        if override_variant:
            return override_variant
        else:
            return self._get_variant(
                user_id=user_id, app=app, request=request, session=session)

    def get_override_variant(
            self, user_id=None, app=None, request=None, session=None):
        if request:
            browser_override_variant = self.get_browser_override_variant(request)
            if browser_override_variant:
                return browser_override_variant
        if self.overrides:
            for variant, user_ids in self.overrides.items():
                if user_id in user_ids:
                    return self.variants_enum[variant]
        return None

    def set_browser_override_variant(self, request, variant):
        browser_override_variants = self.get_browser_override_variants(request)
        if variant == '':
            browser_override_variants.pop(self.FLAG_NAME, None)
        else:
            browser_override_variants[self.FLAG_NAME] = variant

        response = flask.make_response()
        response.set_cookie(
            'gatekeeper',
            json.dumps(browser_override_variants))
        return response

    def get_browser_override_variants(self, request):
        return json.loads(request.cookies.get('gatekeeper', '{}'))

    def get_browser_override_variant(self, request):
        browser_override_variants = self.get_browser_override_variants(request)
        browser_override_variant = browser_override_variants.get(self.FLAG_NAME)
        if browser_override_variant:
            return self.variants_enum[browser_override_variant]
        else:
            return None


class MyNewFeatureFFConfig(FeatureFlagConfig):
    FLAG_NAME = 'MY_NEW_FEATURE'
    VARIANTS_ENUM_STR = 'VISIBLE NOT_VISIBLE'
    DESCRIPTION = 'Gate visibility of my new feature during development'

    def _get_variant(self, user_id=None, app=None, request=None, session=None):
        if app and app.config.get('IS_DEV'):
            return self.variants_enum.VISIBLE
        elif user_id and user_id in [1, 2, 3]:  # Team user ids
            return self.variants_enum.VISIBLE
        else:
            return self.variants_enum.NOT_VISIBLE


FF_CONFIG_MAP: FFConfigMap = {
    'MY_NEW_FEATURE': MyNewFeatureFFConfig()
}

In app.py

# All the usual app setup stuff here... 

app = create_app()

@app.before_request
def register_gatekeeper():
    # Put imports here to avoid circular import issues.
    from flask import request

    import gatekeeper

    request.gk = gatekeeper.initialize_gatekeeper(app=app)

Using gatekeeper

With all of this setup, now it's super straightforward to query gatekeeper for a feature flag and variant.

For example, in Python web handler code, it might look like:

if request.gk.ff('MY_NEW_FEATURE', 'VISIBLE'): 
    # Show new feature

Or in Jinja template code, it might look like:

{% if request.gk.ff('MY_NEW_FEATURE', 'VISIBLE') %}
    {# Show new feature #}
{% endif %}

Final thoughts

If you take out the code to support overrides, and in particular browser overrides, you'll see there's actually quite little necessary to make a functional feature flags manager. I've included the override support because I find it so critical, more so than you might expect, for developer ease of use.

If you aren't already using feature flags, I hope this post convinces you to adopt them in your development and release workflow -- and if you aren't quite keen to set up another SaaS solution yet, perhaps the code shared here can help get you started with your own implementation of a feature flags manager. Happy coding!

19