{"slug": "weekend-build-log-expiring-feature-flags-that-cut-scope-at-runtime", "title": "Weekend Build Log: Expiring Feature Flags That Cut Scope at Runtime", "summary": "A developer built a feature-flag system that embeds an expiry timestamp in each flag, so unfinished AI-backed features automatically return HTTP 503 with a fallback message once their deadline passes, rather than relying on a manual redeploy to disable them. The implementation uses frozen dataclasses with explicit UTC timestamps and a per-request dependency check with an injectable clock, and the accompanying test asserts that an expired flag returns 503 while never invoking the model. The approach moves scope-cut decisions into source control ahead of a weekend demo.", "body_md": "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.\n\nThe 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.\n\nThe artifact below has four moving parts:\n\n`Feature` record that carries `expires_at` and an estimated `model_calls` cost.`503` with a fallback message once the clock passes.\nA 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.\n\nExpiry 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.\n\nThe registry stays boring on purpose. Frozen dataclasses, explicit UTC timestamps, and a cost estimate that the planner can read later.\n\n``` python\n# flags.py\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom typing import Optional\n\n@dataclass(frozen=True)\nclass Feature:\n    name: str\n    expires_at: datetime          # UTC, hard stop\n    fallback: Optional[str] = None\n    model_calls: int = 1          # rough fan-out cost per request\n\n    def open_now(self, now: Optional[datetime] = None) -> bool:\n        return (now or datetime.now(timezone.utc)) < self.expires_at\n\nDEMO_END = datetime(2026, 10, 25, 21, 0, tzinfo=timezone.utc)\n\nFEATURES = {\n    f.name: f\n    for f in [\n        Feature('summary', DEMO_END, model_calls=1),\n        Feature('compare', DEMO_END, model_calls=3),\n        Feature('rewrite', datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc),\n                fallback='rewrite is off for this build', model_calls=1),\n    ]\n}\n```\n\nTwo 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.\n\nA 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.\n\n``` python\n# api.py\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom flags import FEATURES\n\nrouter = APIRouter()\n\ndef require(name: str):\n    def dependency():\n        feature = FEATURES[name]\n        if not feature.open_now():\n            raise HTTPException(\n                status_code=503,\n                detail={'feature': name, 'fallback': feature.fallback},\n            )\n        return feature\n    return dependency\n\n@router.post('/api/summary')\ndef summary(_: object = Depends(require('summary'))):\n    ...\n```\n\n`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.\"\n\nThe 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.\n\n``` python\n# test_flags.py\nfrom datetime import datetime, timezone\nfrom fastapi.testclient import TestClient\nfrom flags import FEATURES, Feature\nimport main\n\ndef test_expired_flag_returns_503_and_skips_the_model(monkeypatch):\n    calls = []\n    monkeypatch.setattr(main, 'call_model', lambda *a, **k: calls.append(1) or 'text')\n    past = datetime(2026, 10, 20, 9, 0, tzinfo=timezone.utc)\n    monkeypatch.setitem(FEATURES, 'summary', Feature('summary', past))\n\n    client = TestClient(main.app)\n    response = client.post('/api/summary', json={'text': 'hello'})\n\n    assert response.status_code == 503\n    assert response.json()['detail']['feature'] == 'summary'\n    assert calls == []\n```\n\nBecause `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.\n\nExpiry only helps if it is checked before the code ships. A twenty-line script in the deploy path is cheaper than remembering.\n\n``` python\n# check_flags.py\nimport sys\nfrom datetime import timedelta\nfrom flags import FEATURES, DEMO_END\n\nGRACE = timedelta(minutes=30)\nbad = [f.name for f in FEATURES.values() if f.expires_at > DEMO_END + GRACE]\n\nif bad:\n    names = ', '.join(bad)\n    print(f'FAIL: flags outlive the demo window: {names}')\n    sys.exit(1)\n\nprint(f'OK: {len(FEATURES)} flags expire inside the window')\n```\n\nRun the whole loop locally in about a minute:\n\n```\npython -m venv .venv && source .venv/bin/activate\npip install fastapi uvicorn pytest httpx\npython check_flags.py\nuvicorn main:app --port 8000 &\ncurl -s -o /dev/null -w '%{http_code}\\n' -X POST localhost:8000/api/summary \\\n  -H 'content-type: application/json' -d '{\"text\":\"hello\"}'\n```\n\nA `200` means the flag is open, `503` means the cut already happened, and `check_flags.py` failing means the cut was never scheduled.\n\nOrdering 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.\n\n| Tier | Feature shape | Action at cutoff | \n|---|---|---|\n| 1 | Fans out to N model calls per request | Expire first, hard `503` , no fallback copy | \n| 2 | Single model call, partial UI | Expire mid-weekend, show fallback text | \n| 3 | No model call, pure formatting | Keep, no expiry | \n| 4 | Anything needing a schema change | Never scheduled | \n\nTier 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.\n\nMonkeyCode 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.\n\n*Disclosure: This article was prepared as part of MonkeyCode's product outreach.*\n\nTwo 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.\n\nExpiry in source code is not a feature-flag system. It has no audit log, no gradual rollout, no kill switch faster than a redeploy.\n\n`FEATURES`.\nThe 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.", "url": "https://wpnews.pro/news/weekend-build-log-expiring-feature-flags-that-cut-scope-at-runtime", "canonical_source": "https://dev.to/devlab_1905/weekend-build-log-expiring-feature-flags-that-cut-scope-at-runtime-421m", "published_at": "2026-10-20 10:07:57+00:00", "updated_at": "2026-10-20 10:18:50.451521+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents"], "entities": [], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/weekend-build-log-expiring-feature-flags-that-cut-scope-at-runtime", "markdown": "https://wpnews.pro/news/weekend-build-log-expiring-feature-flags-that-cut-scope-at-runtime.md", "text": "https://wpnews.pro/news/weekend-build-log-expiring-feature-flags-that-cut-scope-at-runtime.txt", "jsonld": "https://wpnews.pro/news/weekend-build-log-expiring-feature-flags-that-cut-scope-at-runtime.jsonld"}}