|
| 1 | +# Tier 2 — Feature flags — design spec |
| 2 | + |
| 3 | +**Status:** Approved — ready for implementation |
| 4 | +**Date:** 2026-04-18 |
| 5 | +**Scope:** Built-in runtime feature flag system for HawkAPI. Ships `FlagProvider` Protocol, three built-in providers (Static / Env / File with mtime-based hot reload), DI helper, `@requires_flag` decorator, plugin hook for audit logging. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Goal |
| 10 | + |
| 11 | +```python |
| 12 | +from hawkapi import HawkAPI, Depends |
| 13 | +from hawkapi.flags import Flags, StaticFlagProvider, get_flags, requires_flag |
| 14 | + |
| 15 | +provider = StaticFlagProvider({"new_checkout": True, "max_retries": 3}) |
| 16 | +app = HawkAPI(flags=provider) |
| 17 | + |
| 18 | +@app.get("/checkout") |
| 19 | +async def checkout(flags: Flags = Depends(get_flags)) -> dict: |
| 20 | + if await flags.bool("new_checkout", default=False): |
| 21 | + return await new_checkout_flow() |
| 22 | + return await old_checkout_flow() |
| 23 | + |
| 24 | + |
| 25 | +@app.get("/beta/reports") |
| 26 | +@requires_flag("beta.reports") # 404 when the flag is off |
| 27 | +async def beta_reports() -> dict: |
| 28 | + ... |
| 29 | +``` |
| 30 | + |
| 31 | +Zero extra dependencies. No OpenFeature, no LaunchDarkly SDK required. Integrations ship as plugins later. |
| 32 | + |
| 33 | +## Semantics |
| 34 | + |
| 35 | +### `FlagProvider` Protocol |
| 36 | + |
| 37 | +```python |
| 38 | +class FlagProvider(Protocol): |
| 39 | + async def get_bool( |
| 40 | + self, key: str, default: bool, *, context: EvalContext | None = None |
| 41 | + ) -> bool: ... |
| 42 | + |
| 43 | + async def get_string( |
| 44 | + self, key: str, default: str, *, context: EvalContext | None = None |
| 45 | + ) -> str: ... |
| 46 | + |
| 47 | + async def get_number( |
| 48 | + self, key: str, default: float, *, context: EvalContext | None = None |
| 49 | + ) -> float: ... |
| 50 | +``` |
| 51 | + |
| 52 | +All methods accept an optional `EvalContext` — providers that don't care about it ignore it. `default` is what the provider returns when the key is missing or the value is the wrong type. |
| 53 | + |
| 54 | +### `EvalContext` |
| 55 | + |
| 56 | +```python |
| 57 | +@dataclass(frozen=True, slots=True) |
| 58 | +class EvalContext: |
| 59 | + user_id: str | None = None |
| 60 | + tenant_id: str | None = None |
| 61 | + headers: Mapping[str, str] = MappingProxyType({}) |
| 62 | + attrs: Mapping[str, Any] = MappingProxyType({}) |
| 63 | +``` |
| 64 | + |
| 65 | +The DI helper (`get_flags`) constructs it from the `Request` automatically: user/tenant pulled from configurable headers (default `X-User-ID` / `X-Tenant-ID`), request headers dict exposed for custom targeting rules. |
| 66 | + |
| 67 | +### `Flags` facade |
| 68 | + |
| 69 | +A tiny user-facing wrapper returned by `get_flags`. Methods: |
| 70 | + |
| 71 | +- `await flags.bool(key: str, default: bool = False) -> bool` |
| 72 | +- `await flags.string(key: str, default: str = "") -> str` |
| 73 | +- `await flags.number(key: str, default: float = 0.0) -> float` |
| 74 | +- `await flags.require(key: str) -> None` — raises `FlagDisabled` if the flag is off; useful for short-circuit guards inside handlers. |
| 75 | + |
| 76 | +`Flags` carries an `EvalContext`; per-call overrides via `context=` kwarg are allowed. |
| 77 | + |
| 78 | +### Built-in providers |
| 79 | + |
| 80 | +- **`StaticFlagProvider(values: Mapping[str, Any])`** — trivial dict-backed; doesn't read `EvalContext`. Intended for tests and simple setups. |
| 81 | +- **`EnvFlagProvider(prefix: str = "HAWKAPI_FLAG_")`** — reads from `os.environ`. Flag `new_checkout` resolves to `HAWKAPI_FLAG_NEW_CHECKOUT` with case-insensitive key normalisation; standard truthy values (`1` / `true` / `yes`) → `True`. |
| 82 | +- **`FileFlagProvider(path: str | Path)`** — loads JSON/YAML/TOML (extension-driven). Re-reads the file on mtime change — lazy, at evaluation time. No background thread, no watcher dependency. YAML is optional (skipped with clear error if `pyyaml` isn't installed). |
| 83 | + |
| 84 | +Users can compose providers by writing a `ChainedFlagProvider(*providers)` themselves; not built-in v1. |
| 85 | + |
| 86 | +### DI helper — `get_flags` |
| 87 | + |
| 88 | +```python |
| 89 | +async def get_flags(request: Request) -> Flags: |
| 90 | + app = request.scope["app"] |
| 91 | + provider = app.flags # stored by HawkAPI(flags=...) |
| 92 | + ctx = EvalContext( |
| 93 | + user_id=request.headers.get("x-user-id"), |
| 94 | + tenant_id=request.headers.get("x-tenant-id"), |
| 95 | + headers=request.headers, |
| 96 | + ) |
| 97 | + return Flags(provider, ctx) |
| 98 | +``` |
| 99 | + |
| 100 | +Users inject `flags: Flags = Depends(get_flags)` in handler signatures. |
| 101 | + |
| 102 | +### `@requires_flag(key)` decorator |
| 103 | + |
| 104 | +Wraps a handler; evaluates the flag per request; if falsy, raises `HTTPException(404)` (matching how "missing route" feels to the client — flags-off routes should be indistinguishable from non-existent routes, not 403). |
| 105 | + |
| 106 | +Optional kwargs: |
| 107 | +- `status_code: int = 404` — override. |
| 108 | +- `default: bool = False` — what the provider returns if the key is missing (fail-closed by default). |
| 109 | + |
| 110 | +### Plugin hook — `on_flag_evaluated` |
| 111 | + |
| 112 | +When the app has a `Plugin` registered with an `on_flag_evaluated(key, value, context)` hook, the helper calls it after each evaluation. Useful for audit logs, metrics, and debugging. Synchronous hook; async hooks are fire-and-forget via `asyncio.create_task`. |
| 113 | + |
| 114 | +### HawkAPI ctor integration |
| 115 | + |
| 116 | +New optional kwarg: |
| 117 | + |
| 118 | +```python |
| 119 | +class HawkAPI(Router): |
| 120 | + def __init__(self, *, flags: FlagProvider | None = None, ...) -> None: |
| 121 | + ... |
| 122 | + self.flags = flags or StaticFlagProvider({}) |
| 123 | +``` |
| 124 | + |
| 125 | +`flags` is a plain attribute for introspection (tests assert `app.flags`). When `None`, an empty `StaticFlagProvider` is used — calling `flags.bool("x", default=False)` just returns the default. |
| 126 | + |
| 127 | +## Module layout |
| 128 | + |
| 129 | +``` |
| 130 | +src/hawkapi/flags/ |
| 131 | + __init__.py # re-exports FlagProvider, Flags, EvalContext, |
| 132 | + # StaticFlagProvider, EnvFlagProvider, FileFlagProvider, |
| 133 | + # get_flags, requires_flag, FlagDisabled |
| 134 | + base.py # FlagProvider Protocol, EvalContext, Flags, FlagDisabled |
| 135 | + providers.py # StaticFlagProvider, EnvFlagProvider, FileFlagProvider |
| 136 | + _decorator.py # requires_flag |
| 137 | + _di.py # get_flags |
| 138 | +``` |
| 139 | + |
| 140 | +Source files each < 150 lines, single-responsibility, all imports lazy where they pull third-party deps (YAML). |
| 141 | + |
| 142 | +## Tests — `tests/unit/test_flags.py` |
| 143 | + |
| 144 | +Target ~20 tests covering: |
| 145 | + |
| 146 | +- **StaticFlagProvider**: returns stored values, returns default on missing, returns default on wrong type. |
| 147 | +- **EnvFlagProvider**: reads env, normalises key, truthy/falsy parsing, defaults on missing. |
| 148 | +- **FileFlagProvider**: loads JSON, loads TOML, loads YAML (skip if pyyaml missing), hot-reloads on mtime change, raises clear error on unknown extension. |
| 149 | +- **Flags facade**: `.bool`, `.string`, `.number`, `.require` happy + missing + wrong-type paths. |
| 150 | +- **`get_flags` DI helper**: injected via `Depends`, receives correct Request → context mapping. |
| 151 | +- **`@requires_flag`**: 404 when off, passes through when on, custom status code works. |
| 152 | +- **HawkAPI(flags=...)**: stored on `app.flags`; default is an empty StaticFlagProvider so calls with `default=` return gracefully. |
| 153 | +- **Plugin hook**: `on_flag_evaluated` called when registered; not called when absent. |
| 154 | +- Integration: route with `flags: Flags = Depends(get_flags)` evaluates correctly end-to-end through TestClient. |
| 155 | + |
| 156 | +## Docs — `docs/guide/feature-flags.md` |
| 157 | + |
| 158 | +Covers: when to use, `Flags`-via-DI pattern, decorator pattern, built-in providers (Static/Env/File), targeting via `EvalContext`, roadmap (LaunchDarkly/OpenFeature plugins, percentage rollouts). |
| 159 | + |
| 160 | +## Mkdocs nav + CHANGELOG |
| 161 | + |
| 162 | +- `mkdocs.yml`: new `Feature flags` entry under Guide (after Client codegen, before Bulkhead). |
| 163 | +- `CHANGELOG.md`: one `[Unreleased] ### Added` bullet. |
| 164 | + |
| 165 | +## Out of scope |
| 166 | + |
| 167 | +- **LaunchDarkly / Flagsmith / Unleash SDK integrations** — v2 plugin packages. |
| 168 | +- **OpenFeature SDK adapter** — v2 plugin, but worth tracking. |
| 169 | +- **Percentage rollouts, A/B variant targeting** — needs a rules DSL; v2. |
| 170 | +- **Web UI for flag management** — separate project. |
| 171 | +- **Redis-backed provider** — follow-up (bulkhead / circuit-breaker / rate-limit show the shape). |
| 172 | +- **Hot reload via filesystem watcher** — v1 uses mtime-check-at-read for zero-dep simplicity. |
| 173 | + |
| 174 | +## Success criteria |
| 175 | + |
| 176 | +1. `flags: Flags = Depends(get_flags)` in a handler returns a working `Flags` with request-derived context. |
| 177 | +2. `StaticFlagProvider({"x": True}).get_bool("x", False)` → `True`; `.get_bool("missing", True)` → `True`. |
| 178 | +3. `EnvFlagProvider` reads `HAWKAPI_FLAG_NEW_CHECKOUT=true` → `True`; case-insensitive key normalisation. |
| 179 | +4. `FileFlagProvider` on a JSON file reloads when mtime changes. |
| 180 | +5. `@requires_flag` returns 404 on off; 200 on on. |
| 181 | +6. Plugin hook `on_flag_evaluated(key, value, context)` fires on each evaluation. |
| 182 | +7. `HawkAPI()` without `flags=` still works (empty provider, defaults returned). |
| 183 | +8. Full suite + ruff + mkdocs strict clean. |
| 184 | + |
| 185 | +## Files touched |
| 186 | + |
| 187 | +- `src/hawkapi/flags/__init__.py` — new |
| 188 | +- `src/hawkapi/flags/base.py` — new |
| 189 | +- `src/hawkapi/flags/providers.py` — new |
| 190 | +- `src/hawkapi/flags/_decorator.py` — new |
| 191 | +- `src/hawkapi/flags/_di.py` — new |
| 192 | +- `src/hawkapi/app.py` — +`flags=` kwarg |
| 193 | +- `src/hawkapi/__init__.py` — re-exports |
| 194 | +- `tests/unit/test_flags.py` — new |
| 195 | +- `docs/guide/feature-flags.md` — new |
| 196 | +- `mkdocs.yml` — nav entry |
| 197 | +- `CHANGELOG.md` — bullet |
| 198 | + |
| 199 | +## Rollback |
| 200 | + |
| 201 | +New module tree + additive kwarg + new docs. No existing paths change. Revert = delete `flags/` package, revert one kwarg on HawkAPI, revert three doc diffs. |
0 commit comments