2026-08-23 10:52:55 +01:00
|
|
|
"""Feature flags: what can be switched, and what is switched right now.
|
|
|
|
|
|
|
|
|
|
Ship-dark, not a kill switch. Flags let work merge and deploy without becoming
|
|
|
|
|
visible; they are expected to flip about monthly, by a person, deliberately.
|
|
|
|
|
Nothing here does percentage rollouts or user targeting — the site has no user
|
|
|
|
|
identity to target.
|
|
|
|
|
|
|
|
|
|
Unleash holds the state. It does not hold the list. REGISTRY below is that
|
|
|
|
|
list, and it exists for three reasons: the SDK evaluates an unknown flag to
|
|
|
|
|
False, so without a registry that is an *undeclared* False, indistinguishable
|
|
|
|
|
from a typo; /api/flags needs a key set to return when Unleash is unreachable;
|
|
|
|
|
and a flag in the UI but not in the registry is orphaned and should be visibly
|
|
|
|
|
so rather than quietly authoritative.
|
|
|
|
|
|
|
|
|
|
Every flag defaults to False. There is no per-flag default, because a flag that
|
|
|
|
|
defaults on is a kill switch, and this is not one.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import date
|
|
|
|
|
|
|
|
|
|
from .config import settings
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# A flag is temporary scaffolding. See test_a_flag_older_than_the_limit.
|
|
|
|
|
MAX_FLAG_AGE_DAYS = 90
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Flag:
|
|
|
|
|
# One string: the registry key, the Unleash flag name, and the JSON key in
|
|
|
|
|
# /api/flags. snake_case, matching the API's existing convention. No case
|
|
|
|
|
# transformation anywhere, so there is no mapping layer to get wrong.
|
|
|
|
|
name: str
|
|
|
|
|
description: str # one line: what turning this on reveals
|
|
|
|
|
added: date # for the staleness tripwire
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
REGISTRY: dict[str, Flag] = {
|
|
|
|
|
f.name: f for f in (
|
|
|
|
|
Flag(
|
|
|
|
|
name="admission_distance",
|
|
|
|
|
description=(
|
|
|
|
|
"The last-distance-offered figure on the Admissions tile and "
|
|
|
|
|
"the 'How far away are you?' section on school pages."
|
|
|
|
|
),
|
|
|
|
|
added=date(2026, 8, 23),
|
|
|
|
|
),
|
2026-08-26 20:38:52 +01:00
|
|
|
Flag(
|
|
|
|
|
name="school_autosuggest",
|
|
|
|
|
description=(
|
|
|
|
|
"School name suggestions as you type in the main search box."
|
|
|
|
|
),
|
|
|
|
|
added=date(2026, 8, 26),
|
|
|
|
|
),
|
2026-08-23 10:52:55 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_client = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def init() -> None:
|
|
|
|
|
"""Start the Unleash client, or log why flags are all off.
|
|
|
|
|
|
|
|
|
|
Called once from the app lifespan. Never raises: a flag system that can
|
|
|
|
|
stop the API from booting is worse than one that is switched off.
|
|
|
|
|
"""
|
|
|
|
|
global _client
|
|
|
|
|
if not settings.unleash_url or not settings.unleash_api_token:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Unleash is not configured (UNLEASH_URL / UNLEASH_API_TOKEN); "
|
|
|
|
|
"every feature flag evaluates to False.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from UnleashClient import UnleashClient
|
|
|
|
|
|
|
|
|
|
_client = UnleashClient(
|
|
|
|
|
url=settings.unleash_url,
|
|
|
|
|
app_name=settings.unleash_app_name,
|
|
|
|
|
custom_headers={"Authorization": settings.unleash_api_token},
|
|
|
|
|
cache_directory=settings.unleash_cache_directory,
|
|
|
|
|
refresh_interval=15,
|
|
|
|
|
)
|
|
|
|
|
_client.initialize_client()
|
|
|
|
|
logger.info("Unleash client initialised against %s", settings.unleash_url)
|
|
|
|
|
except Exception:
|
|
|
|
|
# Fail closed and keep serving. The SDK also evaluates everything False
|
|
|
|
|
# until its first successful sync, so this is the same direction.
|
|
|
|
|
_client = None
|
|
|
|
|
logger.exception("Unleash client failed to start; flags are all False.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_enabled(name: str) -> bool:
|
|
|
|
|
"""Whether `name` is on. False for anything unknown, unreachable or broken."""
|
|
|
|
|
if name not in REGISTRY:
|
|
|
|
|
logger.error(
|
|
|
|
|
"undeclared feature flag %r was evaluated; returning False. "
|
|
|
|
|
"Add it to backend/flags.py REGISTRY or fix the name.", name)
|
|
|
|
|
return False
|
|
|
|
|
if _client is None:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
return bool(_client.is_enabled(
|
|
|
|
|
name, fallback_function=lambda feature_name, context: False))
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("flag %r failed to evaluate; returning False", name)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def all_flags() -> dict[str, bool]:
|
|
|
|
|
"""Every declared flag and its current value. Serves /api/flags."""
|
|
|
|
|
return {name: is_enabled(name) for name in REGISTRY}
|