Skip to content

Commit d341dbe

Browse files
corleymjcursoragent
andcommitted
Fix double URL-encoding of context param in remote feature flags
The `_prepare_query_params` method was manually URL-encoding the JSON context string via `urllib.parse.quote()` before passing it as an httpx query parameter. Since httpx also URL-encodes all query parameter values, the context was double-encoded — e.g. `{` became `%257B` instead of `%7B` — causing the Mixpanel `/flags` API to return 400 Bad Request. The fix passes the raw JSON string directly and lets httpx handle the single layer of URL encoding as intended. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 58d9c38 commit d341dbe

2 files changed

Lines changed: 43 additions & 4 deletions

File tree

mixpanel/flags/remote_feature_flags.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import asyncio
44
import json
55
import logging
6-
import urllib.parse
76
from datetime import datetime
87
from typing import Any, Callable
98

@@ -277,9 +276,7 @@ def _prepare_query_params(
277276
self, context: dict[str, Any], flag_key: str | None = None
278277
) -> dict[str, str]:
279278
params = self._request_params_base.copy()
280-
context_json = json.dumps(context).encode("utf-8")
281-
url_encoded_context = urllib.parse.quote(context_json)
282-
params["context"] = url_encoded_context
279+
params["context"] = json.dumps(context)
283280
if flag_key is not None:
284281
params["flag_key"] = flag_key
285282
return params

mixpanel/flags/test_remote_feature_flags.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,48 @@ async def test_atrack_exposure_event_successfully_tracks(self):
201201
self.mock_tracker.assert_called_once()
202202

203203

204+
class TestPrepareQueryParams:
205+
def setup_method(self):
206+
config = RemoteFlagsConfig()
207+
self.mock_tracker = Mock()
208+
self._flags = RemoteFeatureFlagsProvider(
209+
"test-token", config, "1.0.0", self.mock_tracker
210+
)
211+
212+
def teardown_method(self):
213+
self._flags.__exit__(None, None, None)
214+
215+
def test_context_is_json_string_not_url_encoded(self):
216+
context = {"distinct_id": "user123", "plan": "premium"}
217+
params = self._flags._prepare_query_params(context)
218+
assert params["context"] == '{"distinct_id": "user123", "plan": "premium"}'
219+
220+
def test_context_is_not_bytes(self):
221+
context = {"distinct_id": "user123"}
222+
params = self._flags._prepare_query_params(context)
223+
assert isinstance(params["context"], str)
224+
225+
def test_flag_key_included_when_provided(self):
226+
context = {"distinct_id": "user123"}
227+
params = self._flags._prepare_query_params(context, flag_key="my_flag")
228+
assert params["flag_key"] == "my_flag"
229+
230+
def test_flag_key_absent_when_not_provided(self):
231+
context = {"distinct_id": "user123"}
232+
params = self._flags._prepare_query_params(context)
233+
assert "flag_key" not in params
234+
235+
@respx.mock
236+
def test_request_url_has_properly_encoded_context(self):
237+
respx.get(ENDPOINT).mock(return_value=create_success_response({}))
238+
self._flags.get_all_variants({"distinct_id": "user123"})
239+
240+
request = respx.calls.last.request
241+
url_str = str(request.url)
242+
assert "%257B" not in url_str, "context param is double-encoded"
243+
assert "b%27" not in url_str, "context param contains bytes literal"
244+
245+
204246
class TestRemoteFeatureFlagsProviderSync:
205247
def setup_method(self):
206248
config = RemoteFlagsConfig()

0 commit comments

Comments
 (0)