Feature Flags¶
zeython.feature_flags gives you named boolean toggles: static
(.env-driven) flags for a plain on/off switch, and deterministic
percentage rollouts for gradually shipping something to a fraction of
users -- no database or Redis required for either. Mirrors the
boolean/rollout building blocks of Laravel Pennant.
Setup¶
# main.py
from zeython import Application, FeatureServiceProvider
from app.Providers.app_feature_service_provider import AppFeatureServiceProvider
app = Application()
app.register(AppFeatureServiceProvider(app))
Flags are defined in your own subclass of FeatureServiceProvider --
super().boot() first, the same pattern
EventServiceProvider uses:
# app/Providers/app_feature_service_provider.py
from zeython import FeatureManager, FeatureServiceProvider
class AppFeatureServiceProvider(FeatureServiceProvider):
def boot(self) -> None:
super().boot()
manager = self.container.make(FeatureManager)
manager.boolean("new_checkout")
manager.percentage("beta_dashboard", rollout=10)
A generated project already has this wired up -- a beta_dashboard
rollout checked from AuthController.me, see app/Providers/app_feature_service_provider.py.
Checking a flag¶
from zeython.feature_flags import feature
async def show(self, request):
if await feature(request, "beta_dashboard", context=current_user):
return JSONResponse(new_dashboard_payload())
return JSONResponse(classic_dashboard_payload())
Outside of a request -- a job, a scheduled task -- resolve a
FeatureManager directly instead:
from zeython.feature_flags import FeatureManager
manager = app.container.make(FeatureManager)
await manager.active("beta_dashboard", context=user)
context is whatever the flag's resolver needs -- typically the current
user, None for a flag that shouldn't vary per-request. Checking an
undefined flag name (a typo, a flag checked before its provider
registered it) resolves False and logs a warning rather than raising --
a flag check should always be safe to add.
Static, .env-driven flags¶
Resolves the same for every context -- there's no per-user variation.
Flip it in a deployment's environment (and restart) without touching
code. zeython features lists every defined flag and its current
resolution:
Percentage rollouts¶
manager.percentage("beta_dashboard", rollout=10) # ~10% of contexts
manager.percentage("kill_switch", rollout=100, on=False) # ~always off
Deterministic: the same context always lands on the same side, via a
stable hash of (flag name, context) -- no database write needed to keep
a user's answer from flickering between requests. Buckets by
context.id if present, else str(context); pass a stable string
directly (a request ID, a tenant slug) for a flag with no natural object
to check against.
This is a rollout, not a persisted assignment -- widening rollout from
10 to 25 keeps everyone already in the 10% (the hash doesn't change) and
adds more; narrowing it back removes exactly the users the wider number
added. There's no support for manually pinning one user in or out beyond
that; use define() with a custom resolver backed by a real store if you
need durable per-user overrides an admin can toggle by hand.
Your own resolver¶
For anything beyond a static toggle or a hash-based rollout -- reading a
row from your own database, calling a third-party flag service --
define() takes any callable:
manager.define(
"vip_only",
lambda user: getattr(user, "is_vip", False),
)
# Or async, if the check itself needs to be:
async def resolver(user):
row = await FeatureOverride.first_where(user_id=user.id, flag="vip_only")
return row.enabled if row else False
manager.define("vip_only", resolver)
Feature flags vs. multi-tenancy vs. authorization¶
Three different questions, easy to conflate:
- Authorization (
Gate/authorize()) answers "is this action allowed for this user, ever" -- a permission check, usually permanent. - Multi-tenancy (
current_tenant_id()) answers "which tenant's data should this query see." - Feature flags answer "is this capability switched on right now" -- usually temporary (a flag graduates to always-on code, or gets deleted, once a rollout finishes), and orthogonal to whether the user is otherwise allowed to do something at all.