Skip to content

Commit 18d5268

Browse files
committed
chore: add autogenerated replaneClientId context prop
1 parent acc68a4 commit 18d5268

4 files changed

Lines changed: 167 additions & 3 deletions

File tree

replane/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
... })
3232
"""
3333

34-
from ._sync import Replane
34+
from ._sync import REPLANE_CLIENT_ID_KEY, Replane
3535
from .errors import (
3636
AuthenticationError,
3737
ClientClosedError,

replane/_async.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import asyncio
1010
import logging
11+
import uuid
1112
from typing import Any, Awaitable, Callable, TypeVar
1213

1314
from ._eval import evaluate_config
@@ -25,6 +26,11 @@
2526
from .types import Config, ContextValue, parse_config
2627
from .version import VERSION
2728

29+
#: The context key for the auto-generated client ID.
30+
#: This key is automatically set by the SDK and can be used for segmentation.
31+
#: User-provided values for this key take precedence over the auto-generated value.
32+
REPLANE_CLIENT_ID_KEY = "replaneClientId"
33+
2834
try:
2935
import httpx
3036
except ImportError:
@@ -137,7 +143,12 @@ def __init__(
137143

138144
self._base_url = base_url.rstrip("/")
139145
self._sdk_key = sdk_key
140-
self._context = context or {}
146+
# Generate replaneClientId and set it as base context.
147+
# User-provided context values take precedence (merged on top).
148+
auto_generated_context: dict[str, ContextValue] = {
149+
REPLANE_CLIENT_ID_KEY: str(uuid.uuid4()),
150+
}
151+
self._context = {**auto_generated_context, **(context or {})}
141152
self._defaults = defaults or {}
142153
self._required = set(required or [])
143154
self._request_timeout = request_timeout_ms / 1000.0

replane/_sync.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import ssl
1414
import threading
1515
import time
16+
import uuid
1617
from typing import Any, Callable, TypeVar
1718
from urllib.parse import urlparse
1819

@@ -30,6 +31,11 @@
3031
from .types import Config, ContextValue, parse_config
3132
from .version import VERSION
3233

34+
#: The context key for the auto-generated client ID.
35+
#: This key is automatically set by the SDK and can be used for segmentation.
36+
#: User-provided values for this key take precedence over the auto-generated value.
37+
REPLANE_CLIENT_ID_KEY = "replaneClientId"
38+
3339
T = TypeVar("T")
3440

3541
# Sentinel value for detecting when no default was provided
@@ -129,7 +135,12 @@ def __init__(
129135
logger.debug("Required configs: %s", required)
130136
self._base_url = base_url.rstrip("/")
131137
self._sdk_key = sdk_key
132-
self._context = context or {}
138+
# Generate replaneClientId and set it as base context.
139+
# User-provided context values take precedence (merged on top).
140+
auto_generated_context: dict[str, ContextValue] = {
141+
REPLANE_CLIENT_ID_KEY: str(uuid.uuid4()),
142+
}
143+
self._context = {**auto_generated_context, **(context or {})}
133144
self._defaults = defaults or {}
134145
self._required = set(required or [])
135146
self._request_timeout = request_timeout_ms / 1000.0

tests/test_sync_client.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,3 +1154,145 @@ def test_inactivity_timeout_triggers_reconnect(self, mock_server: MockSSEServer)
11541154

11551155
# Should have the new value after reconnect
11561156
assert client.get("feature") == "after-reconnect"
1157+
1158+
1159+
class TestReplaneClientId:
1160+
"""Test auto-generated replaneClientId context field."""
1161+
1162+
def test_auto_generates_replane_client_id(self, mock_server: MockSSEServer):
1163+
"""Client auto-generates replaneClientId and uses it for segmentation."""
1164+
mock_server.send_init(
1165+
[
1166+
create_config(
1167+
"feature",
1168+
"default",
1169+
overrides=[
1170+
create_override(
1171+
"segmented-override",
1172+
"segmented-value",
1173+
[
1174+
{
1175+
"operator": "segmentation",
1176+
"property": "replaneClientId",
1177+
"fromPercentage": 0,
1178+
"toPercentage": 100,
1179+
"seed": "test-seed",
1180+
}
1181+
],
1182+
),
1183+
],
1184+
),
1185+
]
1186+
)
1187+
1188+
# Create client without providing replaneClientId
1189+
with Replane(
1190+
base_url=mock_server.url,
1191+
sdk_key="rp_test_key",
1192+
) as client:
1193+
# Should match segmentation because replaneClientId is auto-generated
1194+
assert client.get("feature") == "segmented-value"
1195+
1196+
def test_user_provided_replane_client_id_takes_precedence(
1197+
self, mock_server: MockSSEServer
1198+
):
1199+
"""User-provided replaneClientId takes precedence over auto-generated one."""
1200+
user_provided_id = "user-provided-client-id"
1201+
mock_server.send_init(
1202+
[
1203+
create_config(
1204+
"feature",
1205+
"default",
1206+
overrides=[
1207+
create_override(
1208+
"user-override",
1209+
"user-override-value",
1210+
[create_condition("equals", "replaneClientId", user_provided_id)],
1211+
),
1212+
],
1213+
),
1214+
]
1215+
)
1216+
1217+
# Provide replaneClientId in context - should take precedence
1218+
with Replane(
1219+
base_url=mock_server.url,
1220+
sdk_key="rp_test_key",
1221+
context={"replaneClientId": user_provided_id},
1222+
) as client:
1223+
assert client.get("feature") == "user-override-value"
1224+
1225+
def test_per_request_context_overrides_replane_client_id(
1226+
self, mock_server: MockSSEServer
1227+
):
1228+
"""Per-request context can override replaneClientId."""
1229+
per_request_id = "per-request-client-id"
1230+
mock_server.send_init(
1231+
[
1232+
create_config(
1233+
"feature",
1234+
"default",
1235+
overrides=[
1236+
create_override(
1237+
"per-request-override",
1238+
"per-request-value",
1239+
[create_condition("equals", "replaneClientId", per_request_id)],
1240+
),
1241+
],
1242+
),
1243+
]
1244+
)
1245+
1246+
with Replane(
1247+
base_url=mock_server.url,
1248+
sdk_key="rp_test_key",
1249+
) as client:
1250+
# Default should be based on auto-generated ID (won't match)
1251+
assert client.get("feature") == "default"
1252+
1253+
# Per-request context should override the auto-generated ID
1254+
assert (
1255+
client.get("feature", context={"replaneClientId": per_request_id})
1256+
== "per-request-value"
1257+
)
1258+
1259+
def test_unique_replane_client_id_per_client_instance(
1260+
self, mock_server: MockSSEServer
1261+
):
1262+
"""Each client instance gets a unique replaneClientId."""
1263+
mock_server.send_init(
1264+
[
1265+
create_config(
1266+
"feature",
1267+
"default",
1268+
overrides=[
1269+
create_override(
1270+
"50-percent-rollout",
1271+
"rollout-value",
1272+
[
1273+
{
1274+
"operator": "segmentation",
1275+
"property": "replaneClientId",
1276+
"fromPercentage": 0,
1277+
"toPercentage": 50,
1278+
"seed": "test-seed",
1279+
}
1280+
],
1281+
),
1282+
],
1283+
),
1284+
]
1285+
)
1286+
1287+
# Create multiple clients and check they get different segmentation results
1288+
results = []
1289+
for _ in range(10):
1290+
with Replane(
1291+
base_url=mock_server.url,
1292+
sdk_key="rp_test_key",
1293+
) as client:
1294+
results.append(client.get("feature"))
1295+
1296+
# With 10 clients and 50% rollout, we should statistically see both values
1297+
# This test mainly verifies that segmentation is working
1298+
assert "rollout-value" in results or "default" in results

0 commit comments

Comments
 (0)