Skip to content

Commit 3c99066

Browse files
authored
Merge pull request #600 from splitio/feature/fallback-treatment
Feature/fallback treatment
2 parents cc56af8 + 1226d2b commit 3c99066

22 files changed

+1315
-183
lines changed

CHANGES.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
10.5.0 (Sep 15, 2025)
2+
- Changed the log level from error to debug when renewing the token for Streaming service in asyncio mode.
3+
- Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs.
4+
- Deprecated config parameter `redisErrors` as it is removed in redis lib since 6.0.0 version (https://github.com/redis/redis-py/releases/tag/v6.0.0).
5+
16
10.4.0 (Aug 4, 2025)
27
- Added a new optional argument to the client `getTreatment` methods to allow passing additional evaluation options, such as a map of properties to append to the generated impressions sent to Split backend. Read more in our docs.
38

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
tests_require=TESTS_REQUIRES,
4646
extras_require={
4747
'test': TESTS_REQUIRES,
48-
'redis': ['redis>=2.10.5'],
48+
'redis': ['redis>=2.10.5,<7.0.0'],
4949
'uwsgi': ['uwsgi>=2.0.0'],
5050
'cpphash': ['mmh3cffi==0.2.1'],
5151
'asyncio': ['aiohttp>=3.8.4', 'aiofiles>=23.1.0'],

splitio/client/client.py

Lines changed: 57 additions & 38 deletions
Large diffs are not rendered by default.

splitio/client/config.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from enum import Enum
55

66
from splitio.engine.impressions import ImpressionsMode
7-
from splitio.client.input_validator import validate_flag_sets
7+
from splitio.client.input_validator import validate_flag_sets, validate_fallback_treatment, validate_regex_name
8+
from splitio.models.fallback_config import FallbackTreatmentsConfiguration
89

910
_LOGGER = logging.getLogger(__name__)
1011
DEFAULT_DATA_SAMPLING = 1
@@ -69,7 +70,8 @@ class AuthenticateScheme(Enum):
6970
'flagSetsFilter': None,
7071
'httpAuthenticateScheme': AuthenticateScheme.NONE,
7172
'kerberosPrincipalUser': None,
72-
'kerberosPrincipalPassword': None
73+
'kerberosPrincipalPassword': None,
74+
'fallbackTreatments': FallbackTreatmentsConfiguration(None)
7375
}
7476

7577
def _parse_operation_mode(sdk_key, config):
@@ -168,4 +170,38 @@ def sanitize(sdk_key, config):
168170
' Defaulting to `none` mode.')
169171
processed["httpAuthenticateScheme"] = authenticate_scheme
170172

173+
processed = _sanitize_fallback_config(config, processed)
174+
175+
if config.get("redisErrors") is not None:
176+
_LOGGER.warning('Parameter `redisErrors` is deprecated as it is no longer supported in redis lib.' \
177+
' Will ignore this value.')
178+
179+
processed["redisErrors"] = None
171180
return processed
181+
182+
def _sanitize_fallback_config(config, processed):
183+
if config.get('fallbackTreatments') is None:
184+
return processed
185+
186+
if not isinstance(config['fallbackTreatments'], FallbackTreatmentsConfiguration):
187+
_LOGGER.warning('Config: fallbackTreatments parameter should be of `FallbackTreatmentsConfiguration` class.')
188+
processed['fallbackTreatments'] = None
189+
return processed
190+
191+
sanitized_global_fallback_treatment = config['fallbackTreatments'].global_fallback_treatment
192+
if config['fallbackTreatments'].global_fallback_treatment is not None and not validate_fallback_treatment(config['fallbackTreatments'].global_fallback_treatment):
193+
_LOGGER.warning('Config: global fallbacktreatment parameter is discarded.')
194+
sanitized_global_fallback_treatment = None
195+
196+
sanitized_flag_fallback_treatments = {}
197+
if config['fallbackTreatments'].by_flag_fallback_treatment is not None:
198+
for feature_name in config['fallbackTreatments'].by_flag_fallback_treatment.keys():
199+
if not validate_regex_name(feature_name) or not validate_fallback_treatment(config['fallbackTreatments'].by_flag_fallback_treatment[feature_name]):
200+
_LOGGER.warning('Config: fallback treatment parameter for feature flag %s is discarded.', feature_name)
201+
continue
202+
203+
sanitized_flag_fallback_treatments[feature_name] = config['fallbackTreatments'].by_flag_fallback_treatment[feature_name]
204+
205+
processed['fallbackTreatments'] = FallbackTreatmentsConfiguration(sanitized_global_fallback_treatment, sanitized_flag_fallback_treatments)
206+
207+
return processed

splitio/client/factory.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
TelemetryStorageProducerAsync, TelemetryStorageConsumerAsync
1919
from splitio.engine.impressions.manager import Counter as ImpressionsCounter
2020
from splitio.engine.impressions.unique_keys_tracker import UniqueKeysTracker, UniqueKeysTrackerAsync
21-
21+
from splitio.models.fallback_config import FallbackTreatmentCalculator
2222
# Storage
2323
from splitio.storage.inmemmory import InMemorySplitStorage, InMemorySegmentStorage, \
2424
InMemoryImpressionStorage, InMemoryEventStorage, InMemoryTelemetryStorage, LocalhostTelemetryStorage, \
@@ -170,7 +170,8 @@ def __init__( # pylint: disable=too-many-arguments
170170
telemetry_producer=None,
171171
telemetry_init_producer=None,
172172
telemetry_submitter=None,
173-
preforked_initialization=False
173+
preforked_initialization=False,
174+
fallback_treatment_calculator=None
174175
):
175176
"""
176177
Class constructor.
@@ -201,6 +202,7 @@ def __init__( # pylint: disable=too-many-arguments
201202
self._ready_time = get_current_epoch_time_ms()
202203
_LOGGER.debug("Running in threading mode")
203204
self._sdk_internal_ready_flag = sdk_ready_flag
205+
self._fallback_treatment_calculator = fallback_treatment_calculator
204206
self._start_status_updater()
205207

206208
def _start_status_updater(self):
@@ -242,7 +244,7 @@ def client(self):
242244
This client is only a set of references to structures hold by the factory.
243245
Creating one a fast operation and safe to be used anywhere.
244246
"""
245-
return Client(self, self._recorder, self._labels_enabled)
247+
return Client(self, self._recorder, self._labels_enabled, self._fallback_treatment_calculator)
246248

247249
def manager(self):
248250
"""
@@ -338,7 +340,8 @@ def __init__( # pylint: disable=too-many-arguments
338340
telemetry_init_producer=None,
339341
telemetry_submitter=None,
340342
manager_start_task=None,
341-
api_client=None
343+
api_client=None,
344+
fallback_treatment_calculator=None
342345
):
343346
"""
344347
Class constructor.
@@ -372,6 +375,7 @@ def __init__( # pylint: disable=too-many-arguments
372375
self._sdk_ready_flag = asyncio.Event()
373376
self._ready_task = asyncio.get_running_loop().create_task(self._update_status_when_ready_async())
374377
self._api_client = api_client
378+
self._fallback_treatment_calculator = fallback_treatment_calculator
375379

376380
async def _update_status_when_ready_async(self):
377381
"""Wait until the sdk is ready and update the status for async mode."""
@@ -460,7 +464,7 @@ def client(self):
460464
This client is only a set of references to structures hold by the factory.
461465
Creating one a fast operation and safe to be used anywhere.
462466
"""
463-
return ClientAsync(self, self._recorder, self._labels_enabled)
467+
return ClientAsync(self, self._recorder, self._labels_enabled, self._fallback_treatment_calculator)
464468

465469
def _wrap_impression_listener(listener, metadata):
466470
"""
@@ -623,15 +627,16 @@ def _build_in_memory_factory(api_key, cfg, sdk_url=None, events_url=None, # pyl
623627
synchronizer._split_synchronizers._segment_sync.shutdown()
624628

625629
return SplitFactory(api_key, storages, cfg['labelsEnabled'],
626-
recorder, manager, None, telemetry_producer, telemetry_init_producer, telemetry_submitter, preforked_initialization=preforked_initialization)
630+
recorder, manager, None, telemetry_producer, telemetry_init_producer, telemetry_submitter, preforked_initialization=preforked_initialization,
631+
fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments']))
627632

628633
initialization_thread = threading.Thread(target=manager.start, name="SDKInitializer", daemon=True)
629634
initialization_thread.start()
630635

631636
return SplitFactory(api_key, storages, cfg['labelsEnabled'],
632637
recorder, manager, sdk_ready_flag,
633638
telemetry_producer, telemetry_init_producer,
634-
telemetry_submitter)
639+
telemetry_submitter, fallback_treatment_calculator = FallbackTreatmentCalculator(cfg['fallbackTreatments']))
635640

636641
async def _build_in_memory_factory_async(api_key, cfg, sdk_url=None, events_url=None, # pylint:disable=too-many-arguments,too-many-localsa
637642
auth_api_base_url=None, streaming_api_base_url=None, telemetry_api_base_url=None,
@@ -750,7 +755,7 @@ async def _build_in_memory_factory_async(api_key, cfg, sdk_url=None, events_url=
750755
recorder, manager,
751756
telemetry_producer, telemetry_init_producer,
752757
telemetry_submitter, manager_start_task=manager_start_task,
753-
api_client=http_client)
758+
api_client=http_client, fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments']))
754759

755760
def _build_redis_factory(api_key, cfg):
756761
"""Build and return a split factory with redis-based storage."""
@@ -828,7 +833,8 @@ def _build_redis_factory(api_key, cfg):
828833
manager,
829834
sdk_ready_flag=None,
830835
telemetry_producer=telemetry_producer,
831-
telemetry_init_producer=telemetry_init_producer
836+
telemetry_init_producer=telemetry_init_producer,
837+
fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments'])
832838
)
833839
redundant_factory_count, active_factory_count = _get_active_and_redundant_count()
834840
storages['telemetry'].record_active_and_redundant_factories(active_factory_count, redundant_factory_count)
@@ -910,7 +916,8 @@ async def _build_redis_factory_async(api_key, cfg):
910916
manager,
911917
telemetry_producer=telemetry_producer,
912918
telemetry_init_producer=telemetry_init_producer,
913-
telemetry_submitter=telemetry_submitter
919+
telemetry_submitter=telemetry_submitter,
920+
fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments'])
914921
)
915922
redundant_factory_count, active_factory_count = _get_active_and_redundant_count()
916923
await storages['telemetry'].record_active_and_redundant_factories(active_factory_count, redundant_factory_count)
@@ -992,7 +999,8 @@ def _build_pluggable_factory(api_key, cfg):
992999
manager,
9931000
sdk_ready_flag=None,
9941001
telemetry_producer=telemetry_producer,
995-
telemetry_init_producer=telemetry_init_producer
1002+
telemetry_init_producer=telemetry_init_producer,
1003+
fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments'])
9961004
)
9971005
redundant_factory_count, active_factory_count = _get_active_and_redundant_count()
9981006
storages['telemetry'].record_active_and_redundant_factories(active_factory_count, redundant_factory_count)
@@ -1072,7 +1080,8 @@ async def _build_pluggable_factory_async(api_key, cfg):
10721080
manager,
10731081
telemetry_producer=telemetry_producer,
10741082
telemetry_init_producer=telemetry_init_producer,
1075-
telemetry_submitter=telemetry_submitter
1083+
telemetry_submitter=telemetry_submitter,
1084+
fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments'])
10761085
)
10771086
redundant_factory_count, active_factory_count = _get_active_and_redundant_count()
10781087
await storages['telemetry'].record_active_and_redundant_factories(active_factory_count, redundant_factory_count)
@@ -1150,6 +1159,7 @@ def _build_localhost_factory(cfg):
11501159
telemetry_producer=telemetry_producer,
11511160
telemetry_init_producer=telemetry_producer.get_telemetry_init_producer(),
11521161
telemetry_submitter=LocalhostTelemetrySubmitter(),
1162+
fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments'])
11531163
)
11541164

11551165
async def _build_localhost_factory_async(cfg):
@@ -1220,7 +1230,8 @@ async def _build_localhost_factory_async(cfg):
12201230
telemetry_producer=telemetry_producer,
12211231
telemetry_init_producer=telemetry_producer.get_telemetry_init_producer(),
12221232
telemetry_submitter=LocalhostTelemetrySubmitterAsync(),
1223-
manager_start_task=manager_start_task
1233+
manager_start_task=manager_start_task,
1234+
fallback_treatment_calculator=FallbackTreatmentCalculator(cfg['fallbackTreatments'])
12241235
)
12251236

12261237
def get_factory(api_key, **kwargs):

splitio/client/input_validator.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
from splitio.client.key import Key
99
from splitio.client import client
1010
from splitio.engine.evaluator import CONTROL
11+
from splitio.models.fallback_treatment import FallbackTreatment
1112

1213

1314
_LOGGER = logging.getLogger(__name__)
1415
MAX_LENGTH = 250
1516
EVENT_TYPE_PATTERN = r'^[a-zA-Z0-9][-_.:a-zA-Z0-9]{0,79}$'
1617
MAX_PROPERTIES_LENGTH_BYTES = 32768
1718
_FLAG_SETS_REGEX = '^[a-z0-9][_a-z0-9]{0,49}$'
18-
19+
_FALLBACK_TREATMENT_REGEX = '^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$'
20+
_FALLBACK_TREATMENT_SIZE = 100
1921

2022
def _check_not_null(value, name, operation):
2123
"""
@@ -500,7 +502,7 @@ def validate_feature_flags_get_treatments( # pylint: disable=invalid-name
500502
valid_feature_flags.append(ff)
501503
return valid_feature_flags
502504

503-
def generate_control_treatments(feature_flags):
505+
def generate_control_treatments(feature_flags, fallback_treatment_calculator):
504506
"""
505507
Generate valid feature flags to control.
506508
@@ -515,7 +517,11 @@ def generate_control_treatments(feature_flags):
515517
to_return = {}
516518
for feature_flag in feature_flags:
517519
if isinstance(feature_flag, str) and len(feature_flag.strip())> 0:
518-
to_return[feature_flag] = (CONTROL, None)
520+
fallback_treatment = fallback_treatment_calculator.resolve(feature_flag, "")
521+
treatment = fallback_treatment.treatment
522+
config = fallback_treatment.config
523+
524+
to_return[feature_flag] = (treatment, config)
519525
return to_return
520526

521527

@@ -712,3 +718,28 @@ def validate_flag_sets(flag_sets, method_name):
712718
sanitized_flag_sets.add(flag_set)
713719

714720
return list(sanitized_flag_sets)
721+
722+
def validate_fallback_treatment(fallback_treatment):
723+
if not isinstance(fallback_treatment, FallbackTreatment):
724+
_LOGGER.warning("Config: Fallback treatment instance should be FallbackTreatment, input is discarded")
725+
return False
726+
727+
if not isinstance(fallback_treatment.treatment, str):
728+
_LOGGER.warning("Config: Fallback treatment value should be str type, input is discarded")
729+
return False
730+
731+
if not validate_regex_name(fallback_treatment.treatment):
732+
_LOGGER.warning("Config: Fallback treatment should match regex %s", _FALLBACK_TREATMENT_REGEX)
733+
return False
734+
735+
if len(fallback_treatment.treatment) > _FALLBACK_TREATMENT_SIZE:
736+
_LOGGER.warning("Config: Fallback treatment size should not exceed %s characters", _FALLBACK_TREATMENT_SIZE)
737+
return False
738+
739+
return True
740+
741+
def validate_regex_name(name):
742+
if re.match(_FALLBACK_TREATMENT_REGEX, name) == None:
743+
return False
744+
745+
return True

splitio/client/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,4 @@ def get_metadata(config):
5050
"""
5151
version = 'python-%s' % __version__
5252
ip_address, hostname = _get_hostname_and_ip(config)
53-
return SdkMetadata(version, hostname, ip_address)
53+
return SdkMetadata(version, hostname, ip_address)

splitio/engine/evaluator.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
class Evaluator(object): # pylint: disable=too-few-public-methods
2121
"""Split Evaluator class."""
2222

23-
def __init__(self, splitter):
23+
def __init__(self, splitter, fallback_treatment_calculator=None):
2424
"""
2525
Construct a Evaluator instance.
2626
2727
:param splitter: partition object.
2828
:type splitter: splitio.engine.splitters.Splitters
2929
"""
3030
self._splitter = splitter
31+
self._fallback_treatment_calculator = fallback_treatment_calculator
3132

3233
def eval_many_with_context(self, key, bucketing, features, attrs, ctx):
3334
"""
@@ -51,6 +52,10 @@ def eval_with_context(self, key, bucketing, feature_name, attrs, ctx):
5152
if not feature:
5253
_LOGGER.warning('Unknown or invalid feature: %s', feature)
5354
label = Label.SPLIT_NOT_FOUND
55+
fallback_treatment = self._fallback_treatment_calculator.resolve(feature_name, label)
56+
label = fallback_treatment.label
57+
_treatment = fallback_treatment.treatment
58+
config = fallback_treatment.config
5459
else:
5560
_change_number = feature.change_number
5661
if feature.killed:
@@ -59,17 +64,18 @@ def eval_with_context(self, key, bucketing, feature_name, attrs, ctx):
5964
else:
6065
label, _treatment = self._check_prerequisites(feature, bucketing, key, attrs, ctx, label, _treatment)
6166
label, _treatment = self._get_treatment(feature, bucketing, key, attrs, ctx, label, _treatment)
67+
config = feature.get_configurations_for(_treatment)
6268

6369
return {
6470
'treatment': _treatment,
65-
'configurations': feature.get_configurations_for(_treatment) if feature else None,
71+
'configurations': config,
6672
'impression': {
6773
'label': label,
6874
'change_number': _change_number
6975
},
7076
'impressions_disabled': feature.impressions_disabled if feature else None
7177
}
72-
78+
7379
def _get_treatment(self, feature, bucketing, key, attrs, ctx, label, _treatment):
7480
if _treatment == CONTROL:
7581
treatment, label = self._treatment_for_flag(feature, key, bucketing, attrs, ctx)

0 commit comments

Comments
 (0)