Weekend Build Log: Expiring Feature Flags That Cut Scope at Runtime
A scope cut that lives in a planning document survives roughly four hours into a weekend build. A cut that lives in the request path survives the whole demo. This build log wires an expiry timestamp into every feature flag, so an unfinished feature stops answering requests on its own — no 1 a.m. redeploy, no verbal promise to remember to disable it.
The demo is deliberately small: a text utility with three AI-backed features behind one free server instance. The constraint is the interesting part. Two days of calendar time, one process, and a model-call budget that should not be spent on a branch that is not finished.
The artifact below has four moving parts:
- A frozen
Featurerecord that carriesexpires_atand an estimatedmodel_callscost. - A FastAPI dependency that returns
503with a fallback message once the clock passes. - A test that proves the expiry path without waiting for wall-clock time.
- A pre-deploy guard that fails when a flag outlives the demo window.
Why planning cuts decay
A cut made on Saturday morning is a decision. A cut made on Sunday night is a negotiation with a tired person who wants the demo to look complete. The gap between those two states is where half-built features get flipped back on and quietly break the demo.
Expiry moves the decision out of the tired person's hands. The feature is not "off for now"; it is "off after 21:00 UTC on Sunday," and that fact is in source control.
Rule one: every feature carries an expiry
The registry stays boring on purpose. Frozen dataclasses, explicit UTC timestamps, and a cost estimate that the planner can read later.
# flags.py
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
@dataclass(frozen=True)
class Feature:
name: str
expires_at: datetime # UTC, hard stop
fallback: Optional[str] = None
model_calls: int = 1 # rough fan-out cost per request
def open_now(self, now: Optional[datetime] = None) -> bool:
return (now or datetime.now(timezone.utc)) < self.expires_at
DEMO_END = datetime(2026, 10, 25, 21, 0, tzinfo=timezone.utc)
FEATURES = {
f.name: f
for f in [
Feature('summary', DEMO_END, model_calls=1),
Feature('compare', DEMO_END, model_calls=3),
Feature('rewrite', datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc),
fallback='rewrite is off for this build', model_calls=1),
]
}
Two details matter more than they look. model_calls is an estimate, not a measurement, and it is only used to order what gets cut first. And open_now takes an injectable clock, which is what makes the test in rule three possible.
Rule two: expiry is enforced in the request path
A flag read only at startup is a config file with extra steps. The check has to run per request, so a container that started at noon still refuses work at midnight.
# api.py
from fastapi import APIRouter, Depends, HTTPException
from flags import FEATURES
router = APIRouter()
def require(name: str):
def dependency():
feature = FEATURES[name]
if not feature.open_now():
raise HTTPException(
status_code=503,
detail={'feature': name, 'fallback': feature.fallback},
)
return feature
return dependency
@router.post('/api/summary')
def summary(_: object = Depends(require('summary'))):
...
503 is the honest status here. The route exists, the flag says no, and the client gets a fallback string it can render instead of a stack trace. Keeping the route mounted also means the frontend does not need a second code path for "feature missing."
Rule three: prove it with a test that never waits
The useful assertion is not "the flag is closed." It is "the flag is closed and the model was never called." A closed flag that still bills a request is the failure mode worth testing for.
# test_flags.py
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from flags import FEATURES, Feature
import main
def test_expired_flag_returns_503_and_skips_the_model(monkeypatch):
calls = []
monkeypatch.setattr(main, 'call_model', lambda *a, **k: calls.append(1) or 'text')
past = datetime(2026, 10, 20, 9, 0, tzinfo=timezone.utc)
monkeypatch.setitem(FEATURES, 'summary', Feature('summary', past))
client = TestClient(main.app)
response = client.post('/api/summary', json={'text': 'hello'})
assert response.status_code == 503
assert response.json()['detail']['feature'] == 'summary'
assert calls == []
Because require() reads FEATURES[name] at call time rather than import time, monkeypatch.setitem is enough. No sleeping, no frozen-clock dependency, no flaky test on a slow laptop.
Rule four: refuse to deploy flags that outlive the demo
Expiry only helps if it is checked before the code ships. A twenty-line script in the deploy path is cheaper than remembering.
# check_flags.py
import sys
from datetime import timedelta
from flags import FEATURES, DEMO_END
GRACE = timedelta(minutes=30)
bad = [f.name for f in FEATURES.values() if f.expires_at > DEMO_END + GRACE]
if bad:
names = ', '.join(bad)
print(f'FAIL: flags outlive the demo window: {names}')
sys.exit(1)
print(f'OK: {len(FEATURES)} flags expire inside the window')
Run the whole loop locally in about a minute:
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn pytest httpx
python check_flags.py
uvicorn main:app --port 8000 &
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8000/api/summary \
-H 'content-type: application/json' -d '{"text":"hello"}'
A 200 means the flag is open, 503 means the cut already happened, and check_flags.py failing means the cut was never scheduled.
The cut policy
Ordering the cuts is the part people skip. The table below is the rule used for this build; the order is what matters, not the specifics.
| Tier | Feature shape | Action at cutoff |
|---|---|---|
| 1 | Fans out to N model calls per request | Expire first, hard 503, no fallback copy |
| 2 | Single model call, partial UI | Expire mid-weekend, show fallback text |
| 3 | No model call, pure formatting | Keep, no expiry |
| 4 | Anything needing a schema change | Never scheduled |
Tier 1 goes first because it is the only tier where a half-working feature can drain the request budget in minutes. Tier 3 has no marginal cost, so cutting it buys nothing.
Where the free server and free model access change the math
MonkeyCode offers free model access and a free server option, which is exactly the shape this harness assumes: one small process, outbound model calls, no control plane. The flags exist because model calls are the metered part. A route that fans out three calls per request is a different risk class from a route that formats text, and the registry should say so.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Two cautions before planning around any free tier. Free quotas, model lists, and instance terms change without notice, so confirm the current details on the project page rather than trusting a number from a blog post — including this one. And a free tier is not a reliability guarantee; the expiry design assumes the process can disappear at any moment, which is a useful assumption to hold even on paid infrastructure. If the demo shape fits, the free path is a reasonable place to run it before spending anything.
What got skipped
-
Streaming responses. The cut flag returns a complete
503; a half-streamed answer is a worse user experience than a clean refusal. - Per-user targeting. No percentage rollouts, no allowlists. One clock, one decision.
- Flag persistence. Flags live in source, not a database. Changing one is a commit.
- Retry and backoff on model calls. Out of scope for a two-day build, and the flag makes it unnecessary for the risky routes.
-
A metrics dashboard. The
503count in the server log is the dashboard.
Limitations and who should not do this
Expiry in source code is not a feature-flag system. It has no audit log, no gradual rollout, no kill switch faster than a redeploy.
- Teams with long-lived features should use a real flag service (Unleash, LaunchDarkly, or a database-backed equivalent) rather than timestamps in a dataclass.
- Products where the feature must survive the weekend should not encode a hard stop at all; an expiry that has to be edited after launch is just a scheduled outage.
-
Anything multi-instance with cached config will see skewed behaviour, because each process reads its own copy of
FEATURES. - Mid-request expiry is not handled. A request that starts at 20:59:59 completes; only the next one is refused.
-
Cost control is per-feature, not per-request. The
model_callsfield orders cuts; it does not cap spend.
Closing note
The reason this pattern survived the weekend is that it made the boring decision once, in code, and then stopped asking. If a similar demo is coming up, start with check_flags.py and write the expiry before the feature.
Top comments (0)