Skip to content

Commit 54607e4

Browse files
authored
stream response (#379)
before: ![Screenshot 2024-12-12 at 3 32 26 PM](https://github.com/user-attachments/assets/1f691ca3-55c6-4f9b-a7c2-cef8c65f74cb) after: ![Screenshot 2024-12-12 at 3 32 04 PM](https://github.com/user-attachments/assets/15354562-1cca-4a22-8d78-bf7250124d70)
1 parent 813dce4 commit 54607e4

File tree

8 files changed

+86
-21
lines changed

8 files changed

+86
-21
lines changed

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ grpcio==1.62.3
66
protobuf==4.24.4
77
requests
88
semver
9-
pyfarmhash
9+
pyfarmhash
10+
ijson

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
'ip3country',
4141
'grpcio',
4242
'protobuf',
43+
'ijson',
4344
],
4445
tests_require=test_deps,
4546
extras_require=extras,

statsig/dynamic_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Optional
22

3-
from statsig.evaluation_details import EvaluationDetails, EvaluationReason, DataSource
4-
from statsig.statsig_user import StatsigUser
3+
from .evaluation_details import EvaluationDetails, EvaluationReason, DataSource
4+
from .statsig_user import StatsigUser
55

66

77
class DynamicConfig:

statsig/http_worker.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import json
33
import time
44
from concurrent.futures.thread import ThreadPoolExecutor
5+
from decimal import Decimal
56
from io import BytesIO
67
from typing import Callable, Tuple, Optional, Any
78

9+
import ijson
810
import requests
911

1012
from . import globals
@@ -53,7 +55,7 @@ def get_dcs(self, on_complete: Callable, since_time=0, log_on_exception=False, i
5355
tag="download_config_specs")
5456
self._context.source_api = self.__api_for_download_config_specs
5557
if response is not None and self._is_success_code(response.status_code):
56-
on_complete(DataSource.NETWORK, response.json() or {}, None)
58+
on_complete(DataSource.NETWORK, self._stream_response_into_result_dict(response) or {}, None)
5759
return
5860
on_complete(DataSource.NETWORK, None, None)
5961

@@ -64,7 +66,7 @@ def get_dcs_fallback(self, on_complete: Callable, since_time=0, log_on_exception
6466
tag="download_config_specs")
6567
self._context.source_api = STATSIG_CDN
6668
if response is not None and self._is_success_code(response.status_code):
67-
on_complete(DataSource.STATSIG_NETWORK, response.json() or {}, None)
69+
on_complete(DataSource.STATSIG_NETWORK, self._stream_response_into_result_dict(response) or {}, None)
6870
return
6971
on_complete(DataSource.STATSIG_NETWORK, None, None)
7072

@@ -78,7 +80,7 @@ def get_id_lists(self, on_complete: Callable, log_on_exception=False, init_timeo
7880
tag="get_id_lists",
7981
)
8082
if response is not None and self._is_success_code(response.status_code):
81-
return on_complete(response.json() or {}, None)
83+
return on_complete(self._stream_response_into_result_dict(response) or {}, None)
8284
return on_complete(None, None)
8385

8486
def get_id_lists_fallback(self, on_complete: Callable, log_on_exception=False, init_timeout=None):
@@ -91,7 +93,7 @@ def get_id_lists_fallback(self, on_complete: Callable, log_on_exception=False, i
9193
tag="get_id_lists",
9294
)
9395
if response is not None and self._is_success_code(response.status_code):
94-
return on_complete(response.json() or {}, None)
96+
return on_complete(self._stream_response_into_result_dict(response) or {}, None)
9597
return on_complete(None, None)
9698

9799
def get_id_list(self, on_complete, url, headers, log_on_exception=False):
@@ -189,7 +191,7 @@ def _request(
189191
timeout = self.__req_timeout
190192

191193
def request_task():
192-
return requests.request(method, url, data=payload, headers=headers, timeout=timeout)
194+
return requests.request(method, url, data=payload, headers=headers, timeout=timeout, stream=True)
193195

194196
response = None
195197
if init_timeout is not None:
@@ -245,6 +247,32 @@ def request_task():
245247
)
246248
return None
247249

250+
def _stream_response_into_result_dict(self, response):
251+
result = {}
252+
try:
253+
if response.headers.get("Content-Encoding") == "gzip":
254+
stream = gzip.GzipFile(fileobj=response.raw)
255+
else:
256+
stream = response.raw
257+
for k, v in ijson.kvitems(stream, ""):
258+
v = self._convert_decimals_to_floats(v)
259+
result[k] = v
260+
return result
261+
except Exception as e:
262+
globals.logger.warning(
263+
f"Failed to stream response into result dict from {response.url}. {e}"
264+
)
265+
return None
266+
267+
def _convert_decimals_to_floats(self, obj):
268+
if isinstance(obj, Decimal):
269+
return float(obj)
270+
if isinstance(obj, dict):
271+
return {k: self._convert_decimals_to_floats(v) for k, v in obj.items()}
272+
if isinstance(obj, list):
273+
return [self._convert_decimals_to_floats(v) for v in obj]
274+
return obj
275+
248276
def _is_success_code(self, status_code: int) -> bool:
249277
return 200 <= status_code < 300
250278

testdata/download_config_specs.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"id": "1kNmlB23wylPFZi1M0Divl",
3535
"salt": "f2ac6975-174d-497e-be7f-599fea626132"
3636
}
37-
]
37+
],
38+
"entity": "dynamic_config"
3839
},
3940
{
4041
"name": "sample_experiment",
@@ -1114,7 +1115,8 @@
11141115
"id": "2RamGujUou6h2bVNQWhtNZ",
11151116
"salt": "2RamGujUou6h2bVNQWhtNZ"
11161117
}
1117-
]
1118+
],
1119+
"entity": "experiment"
11181120
}
11191121
],
11201122
"feature_gates": [
@@ -1142,7 +1144,8 @@
11421144
"id": "6N6Z8ODekNYZ7F8gFdoLP5",
11431145
"salt": "14862979-1468-4e49-9b2a-c8bb100eed8f"
11441146
}
1145-
]
1147+
],
1148+
"entity": "feature_gate"
11461149
},
11471150
{
11481151
"name": "on_for_statsig_email",
@@ -1170,7 +1173,8 @@
11701173
"id": "7w9rbTSffLT89pxqpyhuqK",
11711174
"salt": "e452510f-bd5b-42cb-a71e-00498a7903fc"
11721175
}
1173-
]
1176+
],
1177+
"entity": "feature_gate"
11741178
},
11751179
{
11761180
"name": "on_for_id_list",
@@ -1196,7 +1200,8 @@
11961200
"id": "7w9rbTSffLT89pxqpyhuqA",
11971201
"salt": "e452510f-bd5b-42cb-a71e-00498a7903fD"
11981202
}
1199-
]
1203+
],
1204+
"entity": "feature_gate"
12001205
}
12011206
],
12021207
"layer_configs": [

tests/network_stub.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import gzip
2+
import io
3+
import json
24
import re
35
from io import BytesIO
46
from typing import Callable, Union, Optional
@@ -12,7 +14,7 @@ class NetworkStub:
1214
mock_statsig_api: bool
1315

1416
class StubResponse:
15-
def __init__(self, status, data=None, headers=None):
17+
def __init__(self, status, data=None, headers=None, raw=None):
1618
if headers is None:
1719
headers = {}
1820

@@ -21,6 +23,7 @@ def __init__(self, status, data=None, headers=None):
2123
self.headers = headers
2224
self._json = data
2325
self.text = data
26+
self.raw = raw
2427

2528
def json(self):
2629
return self._json
@@ -107,8 +110,17 @@ def mock(*args, **kwargs):
107110

108111
if isinstance(response_body, str):
109112
headers["content-length"] = len(response_body)
113+
byte_body = response_body.encode("utf-8")
114+
else:
115+
byte_body = json.dumps(response_body).encode("utf-8")
110116

111-
return NetworkStub.StubResponse(response_code, response_body, headers)
117+
try:
118+
raw = io.BytesIO(byte_body)
119+
except Exception as e:
120+
print(f"Error in creating raw response: {e}")
121+
raw = None
122+
123+
return NetworkStub.StubResponse(response_code, response_body, headers, raw)
112124

113125
return NetworkStub.StubResponse(404)
114126

tests/test_layer_exposures.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import unittest
2-
import os
31
import json
2+
import os
3+
import unittest
44
from unittest.mock import patch
55

6+
from gzip_helpers import GzipHelpers
67
from network_stub import NetworkStub
78
from statsig import statsig, StatsigUser, StatsigOptions, StatsigEnvironmentTier, Layer
8-
from gzip_helpers import GzipHelpers
99
from test_case_with_extras import TestCaseWithExtras
1010

1111
with open(os.path.join(os.path.abspath(os.path.dirname(__file__)),

tests/test_storage_adapter.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import unittest
44
from unittest.mock import patch
55

6-
from statsig import statsig, IDataStore, StatsigOptions, StatsigUser
76
from network_stub import NetworkStub
7+
from statsig import statsig, IDataStore, StatsigOptions, StatsigUser
88

99
with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), '../testdata/download_config_specs.json')) as r:
1010
CONFIG_SPECS_RESPONSE = json.loads(r.read())
@@ -89,8 +89,20 @@ def test_saving(self, mock_request):
8989
statsig.initialize("secret-key", self._options)
9090

9191
stored_string = self._data_adapter.data["statsig.cache"]
92-
expected_string = json.dumps(CONFIG_SPECS_RESPONSE)
93-
self.assertEqual(stored_string, expected_string)
92+
self.assertIsNotNone(stored_string, "Expected statsig.cache to be saved in data adapter")
93+
stored = json.loads(stored_string)
94+
self.assertTrue(
95+
self._contains_spec(stored["feature_gates"], "always_on_gate", "feature_gate"),
96+
"Expected data adapter to have downloaded gates"
97+
)
98+
self.assertTrue(
99+
self._contains_spec(stored["dynamic_configs"], "test_config", "dynamic_config"),
100+
"Expected data adapter to have downloaded configs"
101+
)
102+
self.assertTrue(
103+
self._contains_spec(stored["layer_configs"], "a_layer", "layer"),
104+
"Expected data adapter to have downloaded layers"
105+
)
94106

95107
@patch('requests.request', side_effect=_network_stub.mock)
96108
def test_calls_network_when_adapter_is_empty(self, mock_request):
@@ -135,3 +147,9 @@ def test_bootstrap_is_ignored_when_data_store_is_set(self):
135147

136148
result = statsig.check_gate(self._user, "gate_from_bootstrap")
137149
self.assertEqual(False, result)
150+
151+
def _contains_spec(self, specs, key, spec_type):
152+
for spec in specs:
153+
if spec.get("name") == key and spec.get("entity") == spec_type:
154+
return True
155+
return False

0 commit comments

Comments
 (0)