Skip to content

Commit c2b2801

Browse files
author
Matias Melograno
committed
added logic for bypassing cdn
1 parent bd7e52b commit c2b2801

File tree

10 files changed

+302
-49
lines changed

10 files changed

+302
-49
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
9.1.0 (Jul XX, 2021)
2+
- Added Cache-Control header for on-demand requests to sdk-server.
3+
- Updated the synchronization flow to be more reliable in the event of an edge case generating delay in cache purge propagation, keeping the SDK cache properly synced.
4+
15
9.0.0 (May 3, 2021)
26
- BREAKING CHANGE: Removed splitSdkMachineIp and splitSdkMachineName configs.
37
- BREAKING CHANGE: Deprecated `redisCharset` config.

splitio/api/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,38 @@ def headers_from_metadata(sdk_metadata, client_key=None):
4141
metadata['SplitSDKClientKey'] = client_key
4242

4343
return metadata
44+
45+
46+
class FetchOptions(object):
47+
"""Fetch Options object."""
48+
49+
def __init__(self, cache_control_headers=False, change_number=None):
50+
"""
51+
Class constructor.
52+
53+
:param cache_control_headers: Flag for Cache-Control header
54+
:type cache_control_headers: bool
55+
56+
:param change_number: ChangeNumber to use for bypassing CDN in request.
57+
:type change_number: int
58+
"""
59+
self._cache_control_headers = cache_control_headers
60+
self._change_number = change_number
61+
62+
@property
63+
def cache_control_headers(self):
64+
"""Return cache control headers."""
65+
return self._cache_control_headers
66+
67+
@property
68+
def change_number(self):
69+
"""Return change number."""
70+
return self._change_number
71+
72+
def __eq__(self, other):
73+
"""Match between other options."""
74+
if self._cache_control_headers != other._cache_control_headers:
75+
return False
76+
if self._change_number != other._change_number:
77+
return False
78+
return True

splitio/api/splits.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
_LOGGER = logging.getLogger(__name__)
1111

1212

13+
_CACHE_CONTROL = 'Cache-Control'
14+
_CACHE_CONTROL_NO_CACHE = 'no-cache'
15+
16+
1317
class SplitsAPI(object): # pylint: disable=too-few-public-methods
1418
"""Class that uses an httpClient to communicate with the splits API."""
1519

@@ -28,23 +32,50 @@ def __init__(self, client, apikey, sdk_metadata):
2832
self._apikey = apikey
2933
self._metadata = headers_from_metadata(sdk_metadata)
3034

31-
def fetch_splits(self, change_number):
35+
def _build_fetch(self, change_number, fetch_options):
36+
"""
37+
Build fetch with new flags if that is the case.
38+
39+
:param change_number: Last known timestamp of a split modification.
40+
:type change_number: int
41+
42+
:param fetch_options: Fetch options for getting split definitions.
43+
:type fetch_options: splitio.api.FetchOptions
44+
45+
:return: Objects for fetch
46+
:rtype: dict, dict
47+
"""
48+
query = {'since': change_number}
49+
extra_headers = self._metadata
50+
if fetch_options is None:
51+
return query, extra_headers
52+
if fetch_options.cache_control_headers:
53+
extra_headers[_CACHE_CONTROL] = _CACHE_CONTROL_NO_CACHE
54+
if fetch_options.change_number is not None:
55+
query['till'] = fetch_options.change_number
56+
return query, extra_headers
57+
58+
def fetch_splits(self, change_number, fetch_options):
3259
"""
3360
Fetch splits from backend.
3461
35-
:param changeNumber: Last known timestamp of a split modification.
36-
:type changeNumber: int
62+
:param change_number: Last known timestamp of a split modification.
63+
:type change_number: int
64+
65+
:param fetch_options: Fetch options for getting split definitions.
66+
:type fetch_options: splitio.api.FetchOptions
3767
3868
:return: Json representation of a splitChanges response.
3969
:rtype: dict
4070
"""
4171
try:
72+
query, extra_headers = self._build_fetch(change_number, fetch_options)
4273
response = self._client.get(
4374
'sdk',
4475
'/splitChanges',
4576
self._apikey,
46-
extra_headers=self._metadata,
47-
query={'since': change_number}
77+
extra_headers=extra_headers,
78+
query=query,
4879
)
4980
if 200 <= response.status_code < 300:
5081
return json.loads(response.body)

splitio/sync/split.py

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import re
44
import itertools
55
import yaml
6+
import time
67

7-
from splitio.api import APIException
8+
from splitio.api import APIException, FetchOptions
89
from splitio.models import splits
10+
from splitio.util.backoff import Backoff
911

1012

1113
_LEGACY_COMMENT_LINE_RE = re.compile(r'^#.*$')
@@ -15,6 +17,11 @@
1517
_LOGGER = logging.getLogger(__name__)
1618

1719

20+
_ON_DEMAND_FETCH_BACKOFF_BASE = 10 # backoff base starting at 10 seconds
21+
_ON_DEMAND_FETCH_BACKOFF_MAX_WAIT = 60 # don't sleep for more than 1 minute
22+
_ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES = 10
23+
24+
1825
class SplitSynchronizer(object):
1926
"""Split changes synchronizer."""
2027

@@ -30,39 +37,83 @@ def __init__(self, split_api, split_storage):
3037
"""
3138
self._api = split_api
3239
self._split_storage = split_storage
40+
self._backoff = Backoff(
41+
_ON_DEMAND_FETCH_BACKOFF_BASE,
42+
_ON_DEMAND_FETCH_BACKOFF_MAX_WAIT)
3343

34-
def synchronize_splits(self, till=None):
44+
def attempt_split_sync(self, fetch_options, till=None):
3545
"""
3646
Hit endpoint, update storage and return True if sync is complete.
3747
48+
:param fetch_options Fetch options for getting split definitions.
49+
:type fetch_options splitio.api.FetchOptions
50+
3851
:param till: Passed till from Streaming.
3952
:type till: int
53+
54+
:return: Flags to check if it should perform bypass or operation ended
55+
:rtype: bool, int, int
4056
"""
57+
self._backoff.reset()
58+
remaining_attempts = _ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES
4159
while True:
42-
change_number = self._split_storage.get_change_number()
43-
if change_number is None:
44-
change_number = -1
45-
if till is not None and till < change_number:
46-
# the passed till is less than change_number, no need to perform updates
47-
return
48-
49-
try:
50-
split_changes = self._api.fetch_splits(change_number)
51-
except APIException as exc:
52-
_LOGGER.error('Exception raised while fetching splits')
53-
_LOGGER.debug('Exception information: ', exc_info=True)
54-
raise exc
55-
56-
for split in split_changes.get('splits', []):
57-
if split['status'] == splits.Status.ACTIVE.value:
58-
self._split_storage.put(splits.from_raw(split))
59-
else:
60-
self._split_storage.remove(split['name'])
61-
62-
self._split_storage.set_change_number(split_changes['till'])
63-
if split_changes['till'] == split_changes['since'] \
64-
and (till is None or split_changes['till'] >= till):
65-
return
60+
remaining_attempts -= 1
61+
while True: # Fetch until since==till
62+
change_number = self._split_storage.get_change_number()
63+
if change_number is None:
64+
change_number = -1
65+
if till is not None and till < change_number:
66+
# the passed till is less than change_number, no need to perform updates
67+
break
68+
69+
try:
70+
split_changes = self._api.fetch_splits(change_number, fetch_options)
71+
except APIException as exc:
72+
_LOGGER.error('Exception raised while fetching splits')
73+
_LOGGER.debug('Exception information: ', exc_info=True)
74+
raise exc
75+
76+
for split in split_changes.get('splits', []):
77+
if split['status'] == splits.Status.ACTIVE.value:
78+
self._split_storage.put(splits.from_raw(split))
79+
else:
80+
self._split_storage.remove(split['name'])
81+
82+
self._split_storage.set_change_number(split_changes['till'])
83+
if split_changes['till'] == split_changes['since']:
84+
break
85+
86+
if till is None or till <= change_number:
87+
return True, remaining_attempts, change_number
88+
elif remaining_attempts <= 0:
89+
return False, remaining_attempts, change_number
90+
how_long = self._backoff.get()
91+
time.sleep(how_long)
92+
93+
def synchronize_splits(self, till=None):
94+
"""
95+
Hit endpoint, update storage and return True if sync is complete.
96+
97+
:param till: Passed till from Streaming.
98+
:type till: int
99+
"""
100+
fetch_options = FetchOptions(True) # Set Cache-Control to no-cache
101+
successful_sync, remaining_attempts, change_number = self.attempt_split_sync(fetch_options,
102+
till)
103+
attempts = _ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES - remaining_attempts
104+
if successful_sync: # succedeed sync
105+
_LOGGER.debug('Refresh completed in %s attempts.', attempts)
106+
return
107+
with_cdn_bypass = FetchOptions(True, change_number) # Set flag for bypassing CDN
108+
without_cdn_successful_sync, remaining_attempts, change_number = self.attempt_split_sync(with_cdn_bypass, till)
109+
without_cdn_attempts = _ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES - remaining_attempts
110+
if without_cdn_successful_sync:
111+
_LOGGER.debug('Refresh completed bypassing the CDN in %s attempts.',
112+
without_cdn_attempts)
113+
return
114+
else:
115+
_LOGGER.debug('No changes fetched after %s attempts with CDN bypassed.',
116+
without_cdn_attempts)
66117

67118
def kill_split(self, split_name, default_treatment, change_number):
68119
"""

splitio/util/backoff.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ class Backoff(object):
66

77
MAX_ALLOWED_WAIT = 30 * 60 # half an hour
88

9-
def __init__(self, base=1):
9+
def __init__(self, base=1, max_allowed=MAX_ALLOWED_WAIT):
1010
"""
1111
Class constructor.
1212
1313
:param base: basic unit to be multiplied on each iteration (seconds)
1414
:param base: float
15+
16+
:param max_allowed: max seconds to wait
17+
:param max_allowed: int
1518
"""
1619
self._base = base
20+
self._max_allowed = max_allowed
1721
self._attempt = 0
1822

1923
def get(self):
@@ -23,7 +27,7 @@ def get(self):
2327
:returns: time to wait until next retry.
2428
:rtype: float
2529
"""
26-
to_return = min(self._base * (2 ** self._attempt), self.MAX_ALLOWED_WAIT)
30+
to_return = min(self._base * (2 ** self._attempt), self._max_allowed)
2731
self._attempt += 1
2832
return to_return
2933

splitio/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '9.0.0'
1+
__version__ = '9.1.0-rc1'

tests/api/test_splits_api.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Split API tests module."""
22

33
import pytest
4-
from splitio.api import splits, client, APIException
4+
from splitio.api import splits, client, APIException, FetchOptions
55
from splitio.client.util import SdkMetadata
66

77

@@ -13,22 +13,46 @@ def test_fetch_split_changes(self, mocker):
1313
httpclient = mocker.Mock(spec=client.HttpClient)
1414
httpclient.get.return_value = client.HttpResponse(200, '{"prop1": "value1"}')
1515
split_api = splits.SplitsAPI(httpclient, 'some_api_key', SdkMetadata('1.0', 'some', '1.2.3.4'))
16-
response = split_api.fetch_splits(123)
1716

17+
response = split_api.fetch_splits(123, FetchOptions())
1818
assert response['prop1'] == 'value1'
19-
assert httpclient.get.mock_calls == [mocker.call('sdk', '/splitChanges', 'some_api_key',
19+
assert httpclient.get.mock_calls == [mocker.call('sdk', '/splitChanges', 'some_api_key',
2020
extra_headers={
21-
'SplitSDKVersion': '1.0',
22-
'SplitSDKMachineIP': '1.2.3.4',
21+
'SplitSDKVersion': '1.0',
22+
'SplitSDKMachineIP': '1.2.3.4',
2323
'SplitSDKMachineName': 'some'
2424
},
2525
query={'since': 123})]
2626

27+
httpclient.reset_mock()
28+
response = split_api.fetch_splits(123, FetchOptions(True))
29+
assert response['prop1'] == 'value1'
30+
assert httpclient.get.mock_calls == [mocker.call('sdk', '/splitChanges', 'some_api_key',
31+
extra_headers={
32+
'SplitSDKVersion': '1.0',
33+
'SplitSDKMachineIP': '1.2.3.4',
34+
'SplitSDKMachineName': 'some',
35+
'Cache-Control': 'no-cache'
36+
},
37+
query={'since': 123})]
38+
39+
httpclient.reset_mock()
40+
response = split_api.fetch_splits(123, FetchOptions(True, 123))
41+
assert response['prop1'] == 'value1'
42+
assert httpclient.get.mock_calls == [mocker.call('sdk', '/splitChanges', 'some_api_key',
43+
extra_headers={
44+
'SplitSDKVersion': '1.0',
45+
'SplitSDKMachineIP': '1.2.3.4',
46+
'SplitSDKMachineName': 'some',
47+
'Cache-Control': 'no-cache'
48+
},
49+
query={'since': 123, 'till': 123})]
50+
2751
httpclient.reset_mock()
2852
def raise_exception(*args, **kwargs):
2953
raise client.HttpClientException('some_message')
3054
httpclient.get.side_effect = raise_exception
3155
with pytest.raises(APIException) as exc_info:
32-
response = split_api.fetch_splits(123)
56+
response = split_api.fetch_splits(123, FetchOptions())
3357
assert exc_info.type == APIException
3458
assert exc_info.value.message == 'some_message'

0 commit comments

Comments
 (0)