Skip to content

Commit 6e8fea5

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
docs: tier 2 feature flags spec — Static/Env/File providers + DI + decorator
1 parent 577136d commit 6e8fea5

1 file changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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

Comments
 (0)