Skip to content

Commit e63f6f0

Browse files
committed
feat(flags): feature-flag system with sdk.flags and operation gating
Platform instances enable features per environment, exposed at GET /ide-service/flags (categories internal/preview/features, each with available+enabled lists). This adds: - sdk.flags (FlagsResource): get()/refresh()/is_available()/is_enabled()/ require(), category-scoped or union lookups, categories_with() for collision insight. Snapshot models are frozen pydantic classes - Gating infrastructure: APIOperation gains required_flag: FlagRequirement | None (mirrors the input_model pattern); enforced as the first step of execute_operation, so a gated call raises BEFORE any request when the flag is not enabled. _require_flag() escape hatch on ResourceBase/BoundModel for non-operation paths (streams, shell features). No existing endpoint is gated - FlagsStore engine in new top-level feature_flags.py (cycle-free wrt core<->http_client), hosted per-connection on APIHttpClient; lazy fetch under an asyncio.Lock (one fetch across concurrent gated calls), cached for client lifetime, refresh() re-fetches. Fail closed on legacy instances: flags-endpoint 404 caches an empty snapshot and gated calls raise with a legacy-platform hint; transient errors propagate uncached - New exceptions: FeatureFlagError base (flag/category attrs) with FeatureNotAvailableError (wrong instance/platform version) and FeatureNotEnabledError (available but off), exported top-level along with FlagCategory/FlagsSnapshot/CategoryFlags - Tests: snapshot model matrix, sdk.flags caching/refresh/404/transient behavior, operation gating incl. pre-request fail-fast proof (gated route never called), single flag fetch across concurrent gated calls, _require_flag on ResourceBase and bound/detached BoundModel; exception matrix extended; live integration test - Docs: CONTRIBUTING 'Gating an Endpoint Behind a Feature Flag' recipe (explicit category required in SDK declarations, async-generator guidance, test pattern), README 'Feature flags' section
1 parent 1a6c672 commit e63f6f0

18 files changed

Lines changed: 706 additions & 3 deletions

CONTRIBUTING.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,56 @@ versions.
129129
130130
---
131131
132+
## Gating an Endpoint Behind a Feature Flag
133+
134+
Platform instances enable features via flags (`GET /ide-service/flags`,
135+
three categories: `internal`, `preview`, `features`). If an SDK feature only
136+
works when a flag is enabled, declare it on the operation — **always with an
137+
explicit category**:
138+
139+
```python
140+
from ...core.operations import APIOperation
141+
from ...feature_flags import FlagCategory, FlagRequirement
142+
143+
_LIST_VPN_CONFIGS_OP = APIOperation(
144+
method="GET",
145+
endpoint_template="/vpn/configs",
146+
response_model=ResourceList[VpnConfig],
147+
required_flag=FlagRequirement("vpn", FlagCategory.INTERNAL),
148+
)
149+
```
150+
151+
Nothing else is needed: `execute_operation` checks the flag **before any
152+
request** and raises `FeatureNotAvailableError` (flag missing on this
153+
instance) or `FeatureNotEnabledError` (available but off). The flags
154+
snapshot is fetched once per client and cached; `await sdk.flags.refresh()`
155+
re-fetches. Instances without the flags endpoint fail closed.
156+
157+
For code paths that don't go through an `APIOperation` (SSE streams,
158+
shell-command features), check explicitly:
159+
160+
```python
161+
await self._require_flag(FlagRequirement("workspace-ssh", FlagCategory.PREVIEW))
162+
```
163+
164+
For async generators, perform this check in a plain `async def` factory
165+
*before* returning the generator, so the call fails fast instead of at the
166+
first iteration.
167+
168+
**Testing a gated endpoint:** register a respx route for
169+
`/ide-service/flags` alongside the endpoint route, and assert the endpoint
170+
route was **not** called when the flag is disabled:
171+
172+
```python
173+
mock_api.get("/ide-service/flags").respond(200, json={"preview": {"available": ["x"], "enabled": []}})
174+
route = mock_api.get("/gated").respond(200, json={})
175+
with pytest.raises(FeatureNotEnabledError):
176+
await resource.gated_call()
177+
assert route.called is False
178+
```
179+
180+
---
181+
132182
## Testing Guidelines
133183
134184
We maintain two types of tests: **unit tests** and **integration tests**. When contributing, please ensure appropriate test coverage for your changes.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ add it to `retry_methods` explicitly:
8282
RetryConfig(max_retries=3, retry_methods=frozenset({"GET", "POST"}))
8383
```
8484

85+
### Feature flags
86+
87+
Platform instances enable different features. Inspect them via `sdk.flags`:
88+
89+
```python
90+
snapshot = await sdk.flags.get() # fetched once, then cached
91+
if await sdk.flags.is_enabled("workspace-ssh"):
92+
...
93+
94+
await sdk.flags.refresh() # re-fetch after platform changes
95+
```
96+
97+
SDK features that depend on a platform flag raise `FeatureNotEnabledError`
98+
(or `FeatureNotAvailableError` if the instance doesn't have the feature at
99+
all) *before* sending any request when the flag is off.
100+
85101
## Quick Start
86102

87103
```python

src/codesphere/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,16 @@
2929
AuthorizationError,
3030
CodesphereError,
3131
ConflictError,
32+
FeatureFlagError,
33+
FeatureNotAvailableError,
34+
FeatureNotEnabledError,
3235
NetworkError,
3336
NotFoundError,
3437
RateLimitError,
3538
TimeoutError,
3639
ValidationError,
3740
)
41+
from .resources.flags import CategoryFlags, FlagCategory, FlagsSnapshot
3842
from .resources.metadata import Characteristic, Datacenter, Image, WsPlan
3943
from .resources.team import (
4044
CustomDomainConfig,
@@ -72,6 +76,12 @@
7276
"DomainRouting",
7377
"DomainVerificationStatus",
7478
"EnvVar",
79+
"FeatureFlagError",
80+
"FeatureNotAvailableError",
81+
"FeatureNotEnabledError",
82+
"FlagCategory",
83+
"FlagsSnapshot",
84+
"CategoryFlags",
7585
"Image",
7686
"NetworkError",
7787
"NotFoundError",

src/codesphere/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .config import RetryConfig, Settings
77
from .exceptions import AuthenticationError
88
from .http_client import APIHttpClient
9+
from .resources.flags import FlagsResource
910
from .resources.metadata import MetadataResource
1011
from .resources.team import TeamsResource
1112
from .resources.workspace import WorkspacesResource
@@ -24,6 +25,7 @@ class CodesphereSDK:
2425
teams: TeamsResource
2526
workspaces: WorkspacesResource
2627
metadata: MetadataResource
28+
flags: FlagsResource
2729

2830
def __init__(
2931
self,
@@ -60,6 +62,7 @@ def __init__(
6062
self.teams = TeamsResource(self._http_client)
6163
self.workspaces = WorkspacesResource(self._http_client)
6264
self.metadata = MetadataResource(self._http_client)
65+
self.flags = FlagsResource(self._http_client)
6366

6467
@staticmethod
6568
def _resolve_token(token: str | SecretStr | None, settings: Settings) -> SecretStr:

src/codesphere/core/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pydantic import BaseModel, ConfigDict, PrivateAttr, RootModel
66
from pydantic.alias_generators import to_camel
77

8+
from ..feature_flags import FlagRequirement
89
from ..http_client import APIHttpClient
910
from .handler import RequestData, execute_operation
1011
from .operations import APIOperation
@@ -31,6 +32,10 @@ async def _execute(
3132
self._http_client, op, data=data, params=params, path_params=path_params
3233
)
3334

35+
async def _require_flag(self, requirement: FlagRequirement) -> None:
36+
"""Fail fast unless the platform flag is enabled (non-operation paths)."""
37+
await self._http_client.flags_store.require(requirement)
38+
3439

3540
class TeamScopedResource(ResourceBase):
3641
"""Base for managers operating within one team."""
@@ -137,6 +142,10 @@ async def _execute(
137142
self._client(), op, data=data, params=params, path_params=path_params
138143
)
139144

145+
async def _require_flag(self, requirement: FlagRequirement) -> None:
146+
"""Fail fast unless the platform flag is enabled (non-operation paths)."""
147+
await self._client().flags_store.require(requirement)
148+
140149

141150
class ResourceList(RootModel[list[ModelT]], Generic[ModelT]):
142151
root: list[ModelT]

src/codesphere/core/handler.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ async def execute_operation(
2929
Path parameters are passed explicitly and formatted into
3030
``op.endpoint_template``. When ``op.input_model`` is set, ``data`` is
3131
validated against it before the request is sent. A non-``None`` ``data``
32-
is always sent as the JSON body, even when empty.
32+
is always sent as the JSON body, even when empty. When the operation
33+
declares ``required_flag``, the flag is checked first — nothing is
34+
sent if the connected instance does not have it enabled.
3335
"""
36+
if op.required_flag is not None:
37+
await client.flags_store.require(op.required_flag)
38+
3439
endpoint = _format_endpoint(op.endpoint_template, path_params or {})
3540

3641
request_kwargs: dict[str, Any] = {}

src/codesphere/core/operations.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from pydantic import BaseModel
55

6+
from ..feature_flags import FlagRequirement
7+
68
ResponseT = TypeVar("ResponseT")
79
EntryT = TypeVar("EntryT", bound=BaseModel)
810

@@ -15,18 +17,25 @@ class APIOperation(Generic[ResponseT]):
1517
type of ``_execute``: use a pydantic model class (``ResourceList[Team]``
1618
included) or ``types.NoneType`` for endpoints without a response body.
1719
``input_model``, when set, validates the ``data`` payload before the
18-
request is sent.
20+
request is sent. ``required_flag``, when set, gates the operation on a
21+
platform feature flag — the call fails before any request when the
22+
flag is not enabled on the connected instance.
1923
"""
2024

2125
method: str
2226
endpoint_template: str
2327
response_model: type[ResponseT]
2428
input_model: type[BaseModel] | None = None
29+
required_flag: FlagRequirement | None = None
2530

2631

2732
@dataclass(frozen=True, slots=True)
2833
class StreamOperation(Generic[EntryT]):
29-
"""Declarative description of an SSE streaming endpoint."""
34+
"""Declarative description of an SSE streaming endpoint.
35+
36+
Streaming endpoints are not flag-gated declaratively yet; gate their
37+
entry points explicitly with ``await self._require_flag(...)``.
38+
"""
3039

3140
endpoint_template: str
3241
entry_model: type[EntryT]

src/codesphere/exceptions.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,83 @@ def __init__(self, message: str | None = None):
160160
super().__init__(message)
161161

162162

163+
def _flag_label(flag: str, category: str | None) -> str:
164+
if category:
165+
return f"'{flag}' (category '{category}')"
166+
return f"'{flag}'"
167+
168+
169+
class FeatureFlagError(CodesphereError):
170+
"""Base for feature-flag gating errors.
171+
172+
Raised before any request is sent when an SDK feature requires a
173+
platform feature flag that the connected instance does not satisfy.
174+
"""
175+
176+
def __init__(
177+
self,
178+
message: str | None = None,
179+
*,
180+
flag: str = "",
181+
category: str | None = None,
182+
):
183+
self.flag = flag
184+
self.category = category
185+
if message is None:
186+
message = (
187+
f"Feature flag {_flag_label(flag, category)} blocks this SDK feature."
188+
)
189+
super().__init__(message)
190+
191+
192+
class FeatureNotAvailableError(FeatureFlagError):
193+
"""The required flag is not available on the connected instance.
194+
195+
The feature does not exist on this platform instance (wrong
196+
environment, or the platform predates the feature).
197+
"""
198+
199+
def __init__(
200+
self,
201+
message: str | None = None,
202+
*,
203+
flag: str = "",
204+
category: str | None = None,
205+
legacy_platform: bool = False,
206+
):
207+
if message is None:
208+
message = (
209+
f"Feature flag {_flag_label(flag, category)} is not available "
210+
"on this Codesphere instance."
211+
)
212+
if legacy_platform:
213+
message += (
214+
" The instance did not expose the flags endpoint at all; "
215+
"it may predate feature flags."
216+
)
217+
message += " Use sdk.flags.get() to inspect available flags."
218+
super().__init__(message, flag=flag, category=category)
219+
220+
221+
class FeatureNotEnabledError(FeatureFlagError):
222+
"""The required flag is available but not enabled on the connected instance."""
223+
224+
def __init__(
225+
self,
226+
message: str | None = None,
227+
*,
228+
flag: str = "",
229+
category: str | None = None,
230+
):
231+
if message is None:
232+
message = (
233+
f"Feature flag {_flag_label(flag, category)} is not enabled on "
234+
"this Codesphere instance. Ask an administrator to enable it, "
235+
"or use sdk.flags.get() to inspect the current flags."
236+
)
237+
super().__init__(message, flag=flag, category=category)
238+
239+
163240
def raise_for_status(response: httpx.Response) -> None:
164241
"""Convert HTTP errors to appropriate SDK exceptions.
165242

0 commit comments

Comments
 (0)