PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m15s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m39s
Both features ship dark. Neither is reachable in an environment where its flag is off, and every flag in this system starts off, so a deploy of this commit makes both disappear until someone turns them on deliberately. Two independent flags rather than one, which makes blog-on-about-off a reachable state. That state is the whole reason the change is larger than four notFound() calls: the blog leans on the About page for its author identity. The Person entity is anchored at /about#tudor, and that URL 404s while about_page is dark, so a post published in that state would claim an author resolving to nothing. Worse than having no named author. Both bylines fall back to unlinked text and the BlogPosting attributes to the publisher instead, so every combination of the two flags renders something correct. Gated: /about, /blog, /blog/[slug], the RSS feed, both footer links, and the matching content-sitemap entries. A sitemap must never advertise a URL that 404s. With both dark it emits a valid empty urlset rather than a 404, because robots.txt names it unconditionally. Not gated: /admin. Posts have to be writable before the blog is worth switching on, so flagging the panel would make the flag unflippable. getFlags takes a revalidate rather than always using the 300s constant. Reading a flag pins the calling route to the lowest revalidate among its fetches, and the footer links live in the root layout, so a naive gate there would have dropped every school and place page from a weekly cache to a 5-minute one. The layout passes 604800, the floor those routes already declare, and the build confirms all four SSG route families still prerender. The cost is one-way latency: pages follow a flip in minutes, footer links within a week. The e2e journeys follow the existing paired shape from the admission_distance flag: a lit journey and a dark one for each flag, reading state from whether /about and /blog respond rather than from /api/flags, which another journey asserts is not publicly reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
"""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),
|
|
),
|
|
Flag(
|
|
name="school_autosuggest",
|
|
description=(
|
|
"School name suggestions as you type in the main search box."
|
|
),
|
|
added=date(2026, 8, 26),
|
|
),
|
|
Flag(
|
|
name="about_page",
|
|
description=(
|
|
"The /about page, its footer link, its sitemap entry, and the "
|
|
"named-author byline on every blog post."
|
|
),
|
|
added=date(2026, 9, 8),
|
|
),
|
|
Flag(
|
|
name="blog",
|
|
description=(
|
|
"The /blog index, post pages, the RSS feed, their footer link "
|
|
"and their sitemap entries. Not /admin: posts must be "
|
|
"writable before the blog is readable."
|
|
),
|
|
added=date(2026, 9, 8),
|
|
),
|
|
)
|
|
}
|
|
|
|
|
|
_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}
|