-
Notifications
You must be signed in to change notification settings - Fork 44.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(platform/featureflags): Setting up feature flagging (#8718)
* feature flag formatting and linting * add tests * update poetry lock * remove unneeded changes * fix pyproject * fix formatting and linting * pydantic settings * address comments and format * alphabetize * fix lockfile * fix conflicts
- Loading branch information
1 parent
de45f2b
commit 07bd933
Showing
16 changed files
with
539 additions
and
110 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
167 changes: 167 additions & 0 deletions
167
autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
import asyncio | ||
import contextlib | ||
import logging | ||
from functools import wraps | ||
from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar, Union, cast | ||
|
||
import ldclient | ||
from fastapi import HTTPException | ||
from ldclient import Context, LDClient | ||
from ldclient.config import Config | ||
from typing_extensions import ParamSpec | ||
|
||
from .config import SETTINGS | ||
|
||
logger = logging.getLogger(__name__) | ||
logging.basicConfig(level=logging.DEBUG) | ||
|
||
P = ParamSpec("P") | ||
T = TypeVar("T") | ||
|
||
|
||
def get_client() -> LDClient: | ||
"""Get the LaunchDarkly client singleton.""" | ||
return ldclient.get() | ||
|
||
|
||
def initialize_launchdarkly() -> None: | ||
sdk_key = SETTINGS.launch_darkly_sdk_key | ||
logger.debug( | ||
f"Initializing LaunchDarkly with SDK key: {'present' if sdk_key else 'missing'}" | ||
) | ||
|
||
if not sdk_key: | ||
logger.warning("LaunchDarkly SDK key not configured") | ||
return | ||
|
||
config = Config(sdk_key) | ||
ldclient.set_config(config) | ||
|
||
if ldclient.get().is_initialized(): | ||
logger.info("LaunchDarkly client initialized successfully") | ||
else: | ||
logger.error("LaunchDarkly client failed to initialize") | ||
|
||
|
||
def shutdown_launchdarkly() -> None: | ||
"""Shutdown the LaunchDarkly client.""" | ||
if ldclient.get().is_initialized(): | ||
ldclient.get().close() | ||
logger.info("LaunchDarkly client closed successfully") | ||
|
||
|
||
def create_context( | ||
user_id: str, additional_attributes: Optional[Dict[str, Any]] = None | ||
) -> Context: | ||
"""Create LaunchDarkly context with optional additional attributes.""" | ||
builder = Context.builder(str(user_id)).kind("user") | ||
if additional_attributes: | ||
for key, value in additional_attributes.items(): | ||
builder.set(key, value) | ||
return builder.build() | ||
|
||
|
||
def feature_flag( | ||
flag_key: str, | ||
default: bool = False, | ||
) -> Callable[ | ||
[Callable[P, Union[T, Awaitable[T]]]], Callable[P, Union[T, Awaitable[T]]] | ||
]: | ||
""" | ||
Decorator for feature flag protected endpoints. | ||
""" | ||
|
||
def decorator( | ||
func: Callable[P, Union[T, Awaitable[T]]] | ||
) -> Callable[P, Union[T, Awaitable[T]]]: | ||
@wraps(func) | ||
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: | ||
try: | ||
user_id = kwargs.get("user_id") | ||
if not user_id: | ||
raise ValueError("user_id is required") | ||
|
||
if not get_client().is_initialized(): | ||
logger.warning( | ||
f"LaunchDarkly not initialized, using default={default}" | ||
) | ||
is_enabled = default | ||
else: | ||
context = create_context(str(user_id)) | ||
is_enabled = get_client().variation(flag_key, context, default) | ||
|
||
if not is_enabled: | ||
raise HTTPException(status_code=404, detail="Feature not available") | ||
|
||
result = func(*args, **kwargs) | ||
if asyncio.iscoroutine(result): | ||
return await result | ||
return cast(T, result) | ||
except Exception as e: | ||
logger.error(f"Error evaluating feature flag {flag_key}: {e}") | ||
raise | ||
|
||
@wraps(func) | ||
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: | ||
try: | ||
user_id = kwargs.get("user_id") | ||
if not user_id: | ||
raise ValueError("user_id is required") | ||
|
||
if not get_client().is_initialized(): | ||
logger.warning( | ||
f"LaunchDarkly not initialized, using default={default}" | ||
) | ||
is_enabled = default | ||
else: | ||
context = create_context(str(user_id)) | ||
is_enabled = get_client().variation(flag_key, context, default) | ||
|
||
if not is_enabled: | ||
raise HTTPException(status_code=404, detail="Feature not available") | ||
|
||
return cast(T, func(*args, **kwargs)) | ||
except Exception as e: | ||
logger.error(f"Error evaluating feature flag {flag_key}: {e}") | ||
raise | ||
|
||
return cast( | ||
Callable[P, Union[T, Awaitable[T]]], | ||
async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper, | ||
) | ||
|
||
return decorator | ||
|
||
|
||
def percentage_rollout( | ||
flag_key: str, | ||
default: bool = False, | ||
) -> Callable[ | ||
[Callable[P, Union[T, Awaitable[T]]]], Callable[P, Union[T, Awaitable[T]]] | ||
]: | ||
"""Decorator for percentage-based rollouts.""" | ||
return feature_flag(flag_key, default) | ||
|
||
|
||
def beta_feature( | ||
flag_key: Optional[str] = None, | ||
unauthorized_response: Any = {"message": "Not available in beta"}, | ||
) -> Callable[ | ||
[Callable[P, Union[T, Awaitable[T]]]], Callable[P, Union[T, Awaitable[T]]] | ||
]: | ||
"""Decorator for beta features.""" | ||
actual_key = f"beta-{flag_key}" if flag_key else "beta" | ||
return feature_flag(actual_key, False) | ||
|
||
|
||
@contextlib.contextmanager | ||
def mock_flag_variation(flag_key: str, return_value: Any): | ||
"""Context manager for testing feature flags.""" | ||
original_variation = get_client().variation | ||
get_client().variation = lambda key, context, default: ( | ||
return_value if key == flag_key else original_variation(key, context, default) | ||
) | ||
try: | ||
yield | ||
finally: | ||
get_client().variation = original_variation |
44 changes: 44 additions & 0 deletions
44
autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client_test.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import pytest | ||
from autogpt_libs.feature_flag.client import feature_flag, mock_flag_variation | ||
from ldclient import LDClient | ||
|
||
|
||
@pytest.fixture | ||
def ld_client(mocker): | ||
client = mocker.Mock(spec=LDClient) | ||
mocker.patch("ldclient.get", return_value=client) | ||
client.is_initialized.return_value = True | ||
return client | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_feature_flag_enabled(ld_client): | ||
ld_client.variation.return_value = True | ||
|
||
@feature_flag("test-flag") | ||
async def test_function(user_id: str): | ||
return "success" | ||
|
||
result = test_function(user_id="test-user") | ||
assert result == "success" | ||
ld_client.variation.assert_called_once() | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_feature_flag_unauthorized_response(ld_client): | ||
ld_client.variation.return_value = False | ||
|
||
@feature_flag("test-flag") | ||
async def test_function(user_id: str): | ||
return "success" | ||
|
||
result = test_function(user_id="test-user") | ||
assert result == {"error": "disabled"} | ||
|
||
|
||
def test_mock_flag_variation(ld_client): | ||
with mock_flag_variation("test-flag", True): | ||
assert ld_client.variation("test-flag", None, False) | ||
|
||
with mock_flag_variation("test-flag", False): | ||
assert ld_client.variation("test-flag", None, False) |
15 changes: 15 additions & 0 deletions
15
autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/config.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
from pydantic import Field | ||
from pydantic_settings import BaseSettings, SettingsConfigDict | ||
|
||
|
||
class Settings(BaseSettings): | ||
launch_darkly_sdk_key: str = Field( | ||
default="", | ||
description="The Launch Darkly SDK key", | ||
validation_alias="LAUNCH_DARKLY_SDK_KEY" | ||
) | ||
|
||
model_config = SettingsConfigDict(case_sensitive=True, extra="ignore") | ||
|
||
|
||
SETTINGS = Settings() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 1 addition & 1 deletion
2
...gpt_platform/autogpt_libs/autogpt_libs/supabase_integration_credentials_store/__init__.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.