From 2427557f3595bee53122de041fa411c8b8a921eb Mon Sep 17 00:00:00 2001 From: no value <53093042+matthewestopinal@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:42:27 +0000 Subject: [PATCH 1/4] feat(multi): Create toggleable `use_https` param to allow `http` in dev services --- mixpanel/flags/local_feature_flags.py | 3 +- mixpanel/flags/remote_feature_flags.py | 3 +- mixpanel/flags/test_local_feature_flags.py | 63 +++++++++++++++++++++ mixpanel/flags/test_remote_feature_flags.py | 59 +++++++++++++++++++ mixpanel/flags/types.py | 4 ++ mixpanel/flags/utils.py | 4 ++ 6 files changed, 134 insertions(+), 2 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 9cc07b8..9db065b 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -70,8 +70,9 @@ def __init__( else: auth = httpx.BasicAuth(token, "") + scheme = "https" if config.use_https else "http" httpx_client_parameters = { - "base_url": f"https://{config.api_host}", + "base_url": f"{scheme}://{config.api_host}", "headers": REQUEST_HEADERS, "auth": auth, "timeout": httpx.Timeout(config.request_timeout_in_seconds), diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 1435341..3711fa0 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -63,8 +63,9 @@ def __init__( else: auth = httpx.BasicAuth(token, "") + scheme = "https" if config.use_https else "http" httpx_client_parameters = { - "base_url": f"https://{config.api_host}", + "base_url": f"{scheme}://{config.api_host}", "headers": REQUEST_HEADERS, "auth": auth, "timeout": httpx.Timeout(config.request_timeout_in_seconds), diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 04affcc..ae25b73 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -1094,3 +1094,66 @@ def test_sync_context_manager_exit_closes_both_clients(): assert provider._sync_client.is_closed assert provider._async_client.is_closed + + +def _make_provider(**config_kwargs): + config = LocalFlagsConfig(enable_polling=False, **config_kwargs) + return LocalFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) + + +def test_use_https_defaults_to_true(): + assert LocalFlagsConfig().use_https is True + + +def test_default_config_uses_https_base_url(): + provider = _make_provider() + + assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" + assert str(provider._async_client.base_url) == "https://api.mixpanel.com" + + provider.shutdown() + + +def test_explicit_use_https_true_matches_default(): + provider = _make_provider(use_https=True) + + assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" + assert str(provider._async_client.base_url) == "https://api.mixpanel.com" + + provider.shutdown() + + +def test_use_https_false_uses_http_base_url(): + provider = _make_provider(use_https=False) + + assert str(provider._sync_client.base_url) == "http://api.mixpanel.com" + assert str(provider._async_client.base_url) == "http://api.mixpanel.com" + + provider.shutdown() + + +def test_use_https_false_builds_full_http_definitions_url(): + provider = _make_provider(api_host="host.minikube.internal/tproxy", use_https=False) + + for client in (provider._sync_client, provider._async_client): + request = client.build_request( + "GET", LocalFeatureFlagsProvider.FLAGS_DEFINITIONS_URL_PATH + ) + assert ( + str(request.url) == "http://host.minikube.internal/tproxy/flags/definitions" + ) + + provider.shutdown() + + +def test_scheme_headers_stay_https_when_use_https_false(): + """The backend's auth rejects requests not marked as https, so these + headers must not follow the transport scheme (see utils.REQUEST_HEADERS). + """ + provider = _make_provider(use_https=False) + + for client in (provider._sync_client, provider._async_client): + assert client.headers["X-Forwarded-Proto"] == "https" + assert client.headers["X-Scheme"] == "https" + + provider.shutdown() diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index 4d68e15..ee93447 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -652,3 +652,62 @@ def test_sync_context_manager_exit_closes_both_clients(): assert provider._sync_client.is_closed assert provider._async_client.is_closed + + +def _make_provider(**config_kwargs): + config = RemoteFlagsConfig(**config_kwargs) + return RemoteFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) + + +def test_use_https_defaults_to_true(): + assert RemoteFlagsConfig().use_https is True + + +def test_default_config_uses_https_base_url(): + provider = _make_provider() + + assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" + assert str(provider._async_client.base_url) == "https://api.mixpanel.com" + + provider.shutdown() + + +def test_explicit_use_https_true_matches_default(): + provider = _make_provider(use_https=True) + + assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" + assert str(provider._async_client.base_url) == "https://api.mixpanel.com" + + provider.shutdown() + + +def test_use_https_false_uses_http_base_url(): + provider = _make_provider(use_https=False) + + assert str(provider._sync_client.base_url) == "http://api.mixpanel.com" + assert str(provider._async_client.base_url) == "http://api.mixpanel.com" + + provider.shutdown() + + +def test_use_https_false_builds_full_http_flags_url(): + provider = _make_provider(api_host="host.minikube.internal/tproxy", use_https=False) + + for client in (provider._sync_client, provider._async_client): + request = client.build_request("GET", RemoteFeatureFlagsProvider.FLAGS_URL_PATH) + assert str(request.url) == "http://host.minikube.internal/tproxy/flags" + + provider.shutdown() + + +def test_scheme_headers_stay_https_when_use_https_false(): + """The backend's auth rejects requests not marked as https, so these + headers must not follow the transport scheme (see utils.REQUEST_HEADERS). + """ + provider = _make_provider(use_https=False) + + for client in (provider._sync_client, provider._async_client): + assert client.headers["X-Forwarded-Proto"] == "https" + assert client.headers["X-Scheme"] == "https" + + provider.shutdown() diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 74db31a..3c815fc 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,6 +31,10 @@ class FlagsConfig: # evaluation does not block on the network round trip. None (default) # preserves the existing inline behavior. exposure_executor: Optional[Executor] = None + # Scheme used to reach api_host. True (default) uses https. Set to False + # only to reach a local/dev endpoint served over plain HTTP (e.g. a development + # nginx proxy), which avoids needing a TLS cert the client can verify. + use_https: bool = True @dataclass diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 9acb814..8ea88c5 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -41,6 +41,10 @@ def close_async_client_from_sync(client: httpx.AsyncClient) -> None: ) +# The scheme headers are intentionally always "https", even when a provider is +# configured with use_https=False. They describe the original request's scheme +# to the flags backend, whose auth rejects requests not marked as https, so a +# proxy fronting a plain-HTTP dev endpoint still needs to see https here. REQUEST_HEADERS: dict[str, str] = { "X-Scheme": "https", "X-Forwarded-Proto": "https", From 2255793950a4c83e5395f4ca85b968ba63573b48 Mon Sep 17 00:00:00 2001 From: no value <53093042+matthewestopinal@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:49:08 +0000 Subject: [PATCH 2/4] verify_cert --- mixpanel/flags/local_feature_flags.py | 5 +- mixpanel/flags/remote_feature_flags.py | 5 +- mixpanel/flags/test_local_feature_flags.py | 68 +++++++++++++++++---- mixpanel/flags/test_remote_feature_flags.py | 59 ++++++++++++++---- mixpanel/flags/test_utils.py | 39 ++++++++++++ mixpanel/flags/types.py | 7 +-- mixpanel/flags/utils.py | 41 ++++++++++++- 7 files changed, 189 insertions(+), 35 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 9db065b..275a2bc 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -25,6 +25,7 @@ ) from .utils import ( REQUEST_HEADERS, + build_base_url, close_async_client_from_sync, dispatch_exposure, generate_traceparent, @@ -70,12 +71,12 @@ def __init__( else: auth = httpx.BasicAuth(token, "") - scheme = "https" if config.use_https else "http" httpx_client_parameters = { - "base_url": f"{scheme}://{config.api_host}", + "base_url": build_base_url(config.api_host, config.verify_cert), "headers": REQUEST_HEADERS, "auth": auth, "timeout": httpx.Timeout(config.request_timeout_in_seconds), + "verify": config.verify_cert, } # Build request params - use service account (no token) or token auth diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 3711fa0..ac0cda9 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -21,6 +21,7 @@ from .utils import ( EXPOSURE_EVENT, REQUEST_HEADERS, + build_base_url, close_async_client_from_sync, dispatch_exposure, generate_traceparent, @@ -63,12 +64,12 @@ def __init__( else: auth = httpx.BasicAuth(token, "") - scheme = "https" if config.use_https else "http" httpx_client_parameters = { - "base_url": f"{scheme}://{config.api_host}", + "base_url": build_base_url(config.api_host, config.verify_cert), "headers": REQUEST_HEADERS, "auth": auth, "timeout": httpx.Timeout(config.request_timeout_in_seconds), + "verify": config.verify_cert, } self._async_client: httpx.AsyncClient = httpx.AsyncClient( diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index ae25b73..b7fb585 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -1101,8 +1101,19 @@ def _make_provider(**config_kwargs): return LocalFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) -def test_use_https_defaults_to_true(): - assert LocalFlagsConfig().use_https is True +def test_verify_cert_defaults_to_true(): + assert LocalFlagsConfig().verify_cert is True + + +def test_verify_cert_leaves_positional_args_unshifted(): + """verify_cert must stay last so it can't rebind an existing positional. + + See the rationale comment above the config subclasses in types.py. + """ + config = LocalFlagsConfig("example.com", 5, None, False) + + assert config.enable_polling is False + assert config.verify_cert is True def test_default_config_uses_https_base_url(): @@ -1114,8 +1125,9 @@ def test_default_config_uses_https_base_url(): provider.shutdown() -def test_explicit_use_https_true_matches_default(): - provider = _make_provider(use_https=True) +def test_bare_api_host_still_https_when_verify_cert_false(): + """verify_cert only relaxes verification; it does not change the scheme.""" + provider = _make_provider(verify_cert=False) assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" assert str(provider._async_client.base_url) == "https://api.mixpanel.com" @@ -1123,17 +1135,19 @@ def test_explicit_use_https_true_matches_default(): provider.shutdown() -def test_use_https_false_uses_http_base_url(): - provider = _make_provider(use_https=False) +def test_explicit_https_api_host_is_preserved(): + provider = _make_provider(api_host="https://api.mixpanel.com") - assert str(provider._sync_client.base_url) == "http://api.mixpanel.com" - assert str(provider._async_client.base_url) == "http://api.mixpanel.com" + assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" + assert str(provider._async_client.base_url) == "https://api.mixpanel.com" provider.shutdown() -def test_use_https_false_builds_full_http_definitions_url(): - provider = _make_provider(api_host="host.minikube.internal/tproxy", use_https=False) +def test_http_api_host_allowed_when_verify_cert_false(): + provider = _make_provider( + api_host="http://host.minikube.internal/tproxy", verify_cert=False + ) for client in (provider._sync_client, provider._async_client): request = client.build_request( @@ -1146,11 +1160,41 @@ def test_use_https_false_builds_full_http_definitions_url(): provider.shutdown() -def test_scheme_headers_stay_https_when_use_https_false(): +def test_http_api_host_rejected_when_verify_cert_true(): + with pytest.raises(ValueError, match="verify_cert=False"): + _make_provider(api_host="http://host.minikube.internal/tproxy") + + +@patch("mixpanel.flags.local_feature_flags.httpx.AsyncClient") +@patch("mixpanel.flags.local_feature_flags.httpx.Client") +def test_verify_cert_is_forwarded_to_httpx_clients(sync_client, async_client): + _make_provider(verify_cert=False) + + assert sync_client.call_args.kwargs["verify"] is False + assert async_client.call_args.kwargs["verify"] is False + + +@patch("mixpanel.flags.local_feature_flags.httpx.AsyncClient") +@patch("mixpanel.flags.local_feature_flags.httpx.Client") +def test_verify_cert_defaults_to_verifying_httpx_clients(sync_client, async_client): + _make_provider() + + assert sync_client.call_args.kwargs["verify"] is True + assert async_client.call_args.kwargs["verify"] is True + + +def test_unsupported_scheme_in_api_host_rejected(): + with pytest.raises(ValueError, match="Unsupported scheme"): + _make_provider(api_host="ftp://host.minikube.internal", verify_cert=False) + + +def test_scheme_headers_stay_https_over_plain_http(): """The backend's auth rejects requests not marked as https, so these headers must not follow the transport scheme (see utils.REQUEST_HEADERS). """ - provider = _make_provider(use_https=False) + provider = _make_provider( + api_host="http://host.minikube.internal/tproxy", verify_cert=False + ) for client in (provider._sync_client, provider._async_client): assert client.headers["X-Forwarded-Proto"] == "https" diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index ee93447..4b303ce 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -4,7 +4,7 @@ import threading from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict -from unittest.mock import Mock +from unittest.mock import Mock, patch import httpx import pytest @@ -659,8 +659,8 @@ def _make_provider(**config_kwargs): return RemoteFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) -def test_use_https_defaults_to_true(): - assert RemoteFlagsConfig().use_https is True +def test_verify_cert_defaults_to_true(): + assert RemoteFlagsConfig().verify_cert is True def test_default_config_uses_https_base_url(): @@ -672,8 +672,9 @@ def test_default_config_uses_https_base_url(): provider.shutdown() -def test_explicit_use_https_true_matches_default(): - provider = _make_provider(use_https=True) +def test_bare_api_host_still_https_when_verify_cert_false(): + """verify_cert only relaxes verification; it does not change the scheme.""" + provider = _make_provider(verify_cert=False) assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" assert str(provider._async_client.base_url) == "https://api.mixpanel.com" @@ -681,17 +682,19 @@ def test_explicit_use_https_true_matches_default(): provider.shutdown() -def test_use_https_false_uses_http_base_url(): - provider = _make_provider(use_https=False) +def test_explicit_https_api_host_is_preserved(): + provider = _make_provider(api_host="https://api.mixpanel.com") - assert str(provider._sync_client.base_url) == "http://api.mixpanel.com" - assert str(provider._async_client.base_url) == "http://api.mixpanel.com" + assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" + assert str(provider._async_client.base_url) == "https://api.mixpanel.com" provider.shutdown() -def test_use_https_false_builds_full_http_flags_url(): - provider = _make_provider(api_host="host.minikube.internal/tproxy", use_https=False) +def test_http_api_host_allowed_when_verify_cert_false(): + provider = _make_provider( + api_host="http://host.minikube.internal/tproxy", verify_cert=False + ) for client in (provider._sync_client, provider._async_client): request = client.build_request("GET", RemoteFeatureFlagsProvider.FLAGS_URL_PATH) @@ -700,11 +703,41 @@ def test_use_https_false_builds_full_http_flags_url(): provider.shutdown() -def test_scheme_headers_stay_https_when_use_https_false(): +def test_http_api_host_rejected_when_verify_cert_true(): + with pytest.raises(ValueError, match="verify_cert=False"): + _make_provider(api_host="http://host.minikube.internal/tproxy") + + +@patch("mixpanel.flags.remote_feature_flags.httpx.AsyncClient") +@patch("mixpanel.flags.remote_feature_flags.httpx.Client") +def test_verify_cert_is_forwarded_to_httpx_clients(sync_client, async_client): + _make_provider(verify_cert=False) + + assert sync_client.call_args.kwargs["verify"] is False + assert async_client.call_args.kwargs["verify"] is False + + +@patch("mixpanel.flags.remote_feature_flags.httpx.AsyncClient") +@patch("mixpanel.flags.remote_feature_flags.httpx.Client") +def test_verify_cert_defaults_to_verifying_httpx_clients(sync_client, async_client): + _make_provider() + + assert sync_client.call_args.kwargs["verify"] is True + assert async_client.call_args.kwargs["verify"] is True + + +def test_unsupported_scheme_in_api_host_rejected(): + with pytest.raises(ValueError, match="Unsupported scheme"): + _make_provider(api_host="ftp://host.minikube.internal", verify_cert=False) + + +def test_scheme_headers_stay_https_over_plain_http(): """The backend's auth rejects requests not marked as https, so these headers must not follow the transport scheme (see utils.REQUEST_HEADERS). """ - provider = _make_provider(use_https=False) + provider = _make_provider( + api_host="http://host.minikube.internal/tproxy", verify_cert=False + ) for client in (provider._sync_client, provider._async_client): assert client.headers["X-Forwarded-Proto"] == "https" diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py index 767127a..aeb6095 100644 --- a/mixpanel/flags/test_utils.py +++ b/mixpanel/flags/test_utils.py @@ -10,6 +10,7 @@ from .utils import ( _log_tracker_future_exception, + build_base_url, close_async_client_from_sync, dispatch_exposure, generate_traceparent, @@ -17,6 +18,44 @@ ) +class TestBuildBaseUrl: + @pytest.mark.parametrize("verify_cert", [True, False]) + def test_bare_host_defaults_to_https(self, verify_cert): + # verify_cert relaxes verification only; it never downgrades the scheme. + assert build_base_url("api.mixpanel.com", verify_cert) == ( + "https://api.mixpanel.com" + ) + + @pytest.mark.parametrize("verify_cert", [True, False]) + def test_explicit_https_is_preserved(self, verify_cert): + assert build_base_url("https://api.mixpanel.com", verify_cert) == ( + "https://api.mixpanel.com" + ) + + def test_http_allowed_when_not_verifying(self): + assert build_base_url("http://host.minikube.internal/tproxy", False) == ( + "http://host.minikube.internal/tproxy" + ) + + def test_http_rejected_when_verifying(self): + with pytest.raises(ValueError, match="verify_cert=False"): + build_base_url("http://host.minikube.internal/tproxy", True) + + def test_scheme_match_is_case_insensitive(self): + assert build_base_url("HTTP://host.internal", False) == "HTTP://host.internal" + + @pytest.mark.parametrize("api_host", ["ftp://host.internal", "ws://host.internal"]) + def test_other_schemes_rejected(self, api_host): + with pytest.raises(ValueError, match="Unsupported scheme"): + build_base_url(api_host, False) + + def test_host_with_path_and_port_is_untouched(self): + # A bare host may carry a port and path prefix (a dev proxy mount). + assert build_base_url("host.internal:8000/tproxy", True) == ( + "https://host.internal:8000/tproxy" + ) + + class TestUtils: def test_traceparent_format_is_correct(self): traceparent = generate_traceparent() diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 3c815fc..684811a 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,21 +31,18 @@ class FlagsConfig: # evaluation does not block on the network round trip. None (default) # preserves the existing inline behavior. exposure_executor: Optional[Executor] = None - # Scheme used to reach api_host. True (default) uses https. Set to False - # only to reach a local/dev endpoint served over plain HTTP (e.g. a development - # nginx proxy), which avoids needing a TLS cert the client can verify. - use_https: bool = True @dataclass class LocalFlagsConfig(FlagsConfig): enable_polling: bool = True polling_interval_in_seconds: int = 60 + verify_cert: bool = True @dataclass class RemoteFlagsConfig(FlagsConfig): - pass + verify_cert: bool = True @dataclass diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 8ea88c5..74e9d04 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -41,8 +41,47 @@ def close_async_client_from_sync(client: httpx.AsyncClient) -> None: ) +def build_base_url(api_host: str, verify_cert: bool) -> str: + """Build the httpx ``base_url`` for ``api_host``. + + A bare host (the default, ``api.mixpanel.com``) is served over https. + ``api_host`` may instead carry an explicit scheme; ``http://`` is accepted + only when ``verify_cert`` is False, so plaintext is always a deliberate + opt-out of transport security rather than something a typo can cause + silently. + + :param api_host: Host, optionally prefixed with ``http://``/``https://`` + :param verify_cert: Whether the caller verifies the server's TLS cert + :return: Base URL including scheme + :raises ValueError: if ``api_host`` is plain http while ``verify_cert`` is + True, or carries a scheme other than http/https + """ + scheme, separator, _ = api_host.partition("://") + if not separator: + return f"https://{api_host}" + + scheme = scheme.lower() + if scheme == "https": + return api_host + if scheme == "http": + if verify_cert: + msg = ( + f"api_host {api_host!r} uses http://, which sends flag " + "requests (including the token) in the clear. Pass " + "verify_cert=False to acknowledge that, or use https://." + ) + raise ValueError(msg) + return api_host + + msg = ( + f"Unsupported scheme {scheme!r} in api_host {api_host!r}; " + "use http:// or https://." + ) + raise ValueError(msg) + + # The scheme headers are intentionally always "https", even when a provider is -# configured with use_https=False. They describe the original request's scheme +# reaching a plain-http api_host. They describe the original request's scheme # to the flags backend, whose auth rejects requests not marked as https, so a # proxy fronting a plain-HTTP dev endpoint still needs to see https here. REQUEST_HEADERS: dict[str, str] = { From 11699560b0e7c6fd9cf77b0006cdf18851ca4729 Mon Sep 17 00:00:00 2001 From: no value <53093042+matthewestopinal@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:16:24 +0000 Subject: [PATCH 3/4] Revert "verify_cert" This reverts commit 2255793950a4c83e5395f4ca85b968ba63573b48. --- mixpanel/flags/local_feature_flags.py | 5 +- mixpanel/flags/remote_feature_flags.py | 5 +- mixpanel/flags/test_local_feature_flags.py | 68 ++++----------------- mixpanel/flags/test_remote_feature_flags.py | 59 ++++-------------- mixpanel/flags/test_utils.py | 39 ------------ mixpanel/flags/types.py | 7 ++- mixpanel/flags/utils.py | 41 +------------ 7 files changed, 35 insertions(+), 189 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 275a2bc..9db065b 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -25,7 +25,6 @@ ) from .utils import ( REQUEST_HEADERS, - build_base_url, close_async_client_from_sync, dispatch_exposure, generate_traceparent, @@ -71,12 +70,12 @@ def __init__( else: auth = httpx.BasicAuth(token, "") + scheme = "https" if config.use_https else "http" httpx_client_parameters = { - "base_url": build_base_url(config.api_host, config.verify_cert), + "base_url": f"{scheme}://{config.api_host}", "headers": REQUEST_HEADERS, "auth": auth, "timeout": httpx.Timeout(config.request_timeout_in_seconds), - "verify": config.verify_cert, } # Build request params - use service account (no token) or token auth diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index ac0cda9..3711fa0 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -21,7 +21,6 @@ from .utils import ( EXPOSURE_EVENT, REQUEST_HEADERS, - build_base_url, close_async_client_from_sync, dispatch_exposure, generate_traceparent, @@ -64,12 +63,12 @@ def __init__( else: auth = httpx.BasicAuth(token, "") + scheme = "https" if config.use_https else "http" httpx_client_parameters = { - "base_url": build_base_url(config.api_host, config.verify_cert), + "base_url": f"{scheme}://{config.api_host}", "headers": REQUEST_HEADERS, "auth": auth, "timeout": httpx.Timeout(config.request_timeout_in_seconds), - "verify": config.verify_cert, } self._async_client: httpx.AsyncClient = httpx.AsyncClient( diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index b7fb585..ae25b73 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -1101,19 +1101,8 @@ def _make_provider(**config_kwargs): return LocalFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) -def test_verify_cert_defaults_to_true(): - assert LocalFlagsConfig().verify_cert is True - - -def test_verify_cert_leaves_positional_args_unshifted(): - """verify_cert must stay last so it can't rebind an existing positional. - - See the rationale comment above the config subclasses in types.py. - """ - config = LocalFlagsConfig("example.com", 5, None, False) - - assert config.enable_polling is False - assert config.verify_cert is True +def test_use_https_defaults_to_true(): + assert LocalFlagsConfig().use_https is True def test_default_config_uses_https_base_url(): @@ -1125,9 +1114,8 @@ def test_default_config_uses_https_base_url(): provider.shutdown() -def test_bare_api_host_still_https_when_verify_cert_false(): - """verify_cert only relaxes verification; it does not change the scheme.""" - provider = _make_provider(verify_cert=False) +def test_explicit_use_https_true_matches_default(): + provider = _make_provider(use_https=True) assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" assert str(provider._async_client.base_url) == "https://api.mixpanel.com" @@ -1135,19 +1123,17 @@ def test_bare_api_host_still_https_when_verify_cert_false(): provider.shutdown() -def test_explicit_https_api_host_is_preserved(): - provider = _make_provider(api_host="https://api.mixpanel.com") +def test_use_https_false_uses_http_base_url(): + provider = _make_provider(use_https=False) - assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" - assert str(provider._async_client.base_url) == "https://api.mixpanel.com" + assert str(provider._sync_client.base_url) == "http://api.mixpanel.com" + assert str(provider._async_client.base_url) == "http://api.mixpanel.com" provider.shutdown() -def test_http_api_host_allowed_when_verify_cert_false(): - provider = _make_provider( - api_host="http://host.minikube.internal/tproxy", verify_cert=False - ) +def test_use_https_false_builds_full_http_definitions_url(): + provider = _make_provider(api_host="host.minikube.internal/tproxy", use_https=False) for client in (provider._sync_client, provider._async_client): request = client.build_request( @@ -1160,41 +1146,11 @@ def test_http_api_host_allowed_when_verify_cert_false(): provider.shutdown() -def test_http_api_host_rejected_when_verify_cert_true(): - with pytest.raises(ValueError, match="verify_cert=False"): - _make_provider(api_host="http://host.minikube.internal/tproxy") - - -@patch("mixpanel.flags.local_feature_flags.httpx.AsyncClient") -@patch("mixpanel.flags.local_feature_flags.httpx.Client") -def test_verify_cert_is_forwarded_to_httpx_clients(sync_client, async_client): - _make_provider(verify_cert=False) - - assert sync_client.call_args.kwargs["verify"] is False - assert async_client.call_args.kwargs["verify"] is False - - -@patch("mixpanel.flags.local_feature_flags.httpx.AsyncClient") -@patch("mixpanel.flags.local_feature_flags.httpx.Client") -def test_verify_cert_defaults_to_verifying_httpx_clients(sync_client, async_client): - _make_provider() - - assert sync_client.call_args.kwargs["verify"] is True - assert async_client.call_args.kwargs["verify"] is True - - -def test_unsupported_scheme_in_api_host_rejected(): - with pytest.raises(ValueError, match="Unsupported scheme"): - _make_provider(api_host="ftp://host.minikube.internal", verify_cert=False) - - -def test_scheme_headers_stay_https_over_plain_http(): +def test_scheme_headers_stay_https_when_use_https_false(): """The backend's auth rejects requests not marked as https, so these headers must not follow the transport scheme (see utils.REQUEST_HEADERS). """ - provider = _make_provider( - api_host="http://host.minikube.internal/tproxy", verify_cert=False - ) + provider = _make_provider(use_https=False) for client in (provider._sync_client, provider._async_client): assert client.headers["X-Forwarded-Proto"] == "https" diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index 4b303ce..ee93447 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -4,7 +4,7 @@ import threading from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict -from unittest.mock import Mock, patch +from unittest.mock import Mock import httpx import pytest @@ -659,8 +659,8 @@ def _make_provider(**config_kwargs): return RemoteFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) -def test_verify_cert_defaults_to_true(): - assert RemoteFlagsConfig().verify_cert is True +def test_use_https_defaults_to_true(): + assert RemoteFlagsConfig().use_https is True def test_default_config_uses_https_base_url(): @@ -672,9 +672,8 @@ def test_default_config_uses_https_base_url(): provider.shutdown() -def test_bare_api_host_still_https_when_verify_cert_false(): - """verify_cert only relaxes verification; it does not change the scheme.""" - provider = _make_provider(verify_cert=False) +def test_explicit_use_https_true_matches_default(): + provider = _make_provider(use_https=True) assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" assert str(provider._async_client.base_url) == "https://api.mixpanel.com" @@ -682,19 +681,17 @@ def test_bare_api_host_still_https_when_verify_cert_false(): provider.shutdown() -def test_explicit_https_api_host_is_preserved(): - provider = _make_provider(api_host="https://api.mixpanel.com") +def test_use_https_false_uses_http_base_url(): + provider = _make_provider(use_https=False) - assert str(provider._sync_client.base_url) == "https://api.mixpanel.com" - assert str(provider._async_client.base_url) == "https://api.mixpanel.com" + assert str(provider._sync_client.base_url) == "http://api.mixpanel.com" + assert str(provider._async_client.base_url) == "http://api.mixpanel.com" provider.shutdown() -def test_http_api_host_allowed_when_verify_cert_false(): - provider = _make_provider( - api_host="http://host.minikube.internal/tproxy", verify_cert=False - ) +def test_use_https_false_builds_full_http_flags_url(): + provider = _make_provider(api_host="host.minikube.internal/tproxy", use_https=False) for client in (provider._sync_client, provider._async_client): request = client.build_request("GET", RemoteFeatureFlagsProvider.FLAGS_URL_PATH) @@ -703,41 +700,11 @@ def test_http_api_host_allowed_when_verify_cert_false(): provider.shutdown() -def test_http_api_host_rejected_when_verify_cert_true(): - with pytest.raises(ValueError, match="verify_cert=False"): - _make_provider(api_host="http://host.minikube.internal/tproxy") - - -@patch("mixpanel.flags.remote_feature_flags.httpx.AsyncClient") -@patch("mixpanel.flags.remote_feature_flags.httpx.Client") -def test_verify_cert_is_forwarded_to_httpx_clients(sync_client, async_client): - _make_provider(verify_cert=False) - - assert sync_client.call_args.kwargs["verify"] is False - assert async_client.call_args.kwargs["verify"] is False - - -@patch("mixpanel.flags.remote_feature_flags.httpx.AsyncClient") -@patch("mixpanel.flags.remote_feature_flags.httpx.Client") -def test_verify_cert_defaults_to_verifying_httpx_clients(sync_client, async_client): - _make_provider() - - assert sync_client.call_args.kwargs["verify"] is True - assert async_client.call_args.kwargs["verify"] is True - - -def test_unsupported_scheme_in_api_host_rejected(): - with pytest.raises(ValueError, match="Unsupported scheme"): - _make_provider(api_host="ftp://host.minikube.internal", verify_cert=False) - - -def test_scheme_headers_stay_https_over_plain_http(): +def test_scheme_headers_stay_https_when_use_https_false(): """The backend's auth rejects requests not marked as https, so these headers must not follow the transport scheme (see utils.REQUEST_HEADERS). """ - provider = _make_provider( - api_host="http://host.minikube.internal/tproxy", verify_cert=False - ) + provider = _make_provider(use_https=False) for client in (provider._sync_client, provider._async_client): assert client.headers["X-Forwarded-Proto"] == "https" diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py index aeb6095..767127a 100644 --- a/mixpanel/flags/test_utils.py +++ b/mixpanel/flags/test_utils.py @@ -10,7 +10,6 @@ from .utils import ( _log_tracker_future_exception, - build_base_url, close_async_client_from_sync, dispatch_exposure, generate_traceparent, @@ -18,44 +17,6 @@ ) -class TestBuildBaseUrl: - @pytest.mark.parametrize("verify_cert", [True, False]) - def test_bare_host_defaults_to_https(self, verify_cert): - # verify_cert relaxes verification only; it never downgrades the scheme. - assert build_base_url("api.mixpanel.com", verify_cert) == ( - "https://api.mixpanel.com" - ) - - @pytest.mark.parametrize("verify_cert", [True, False]) - def test_explicit_https_is_preserved(self, verify_cert): - assert build_base_url("https://api.mixpanel.com", verify_cert) == ( - "https://api.mixpanel.com" - ) - - def test_http_allowed_when_not_verifying(self): - assert build_base_url("http://host.minikube.internal/tproxy", False) == ( - "http://host.minikube.internal/tproxy" - ) - - def test_http_rejected_when_verifying(self): - with pytest.raises(ValueError, match="verify_cert=False"): - build_base_url("http://host.minikube.internal/tproxy", True) - - def test_scheme_match_is_case_insensitive(self): - assert build_base_url("HTTP://host.internal", False) == "HTTP://host.internal" - - @pytest.mark.parametrize("api_host", ["ftp://host.internal", "ws://host.internal"]) - def test_other_schemes_rejected(self, api_host): - with pytest.raises(ValueError, match="Unsupported scheme"): - build_base_url(api_host, False) - - def test_host_with_path_and_port_is_untouched(self): - # A bare host may carry a port and path prefix (a dev proxy mount). - assert build_base_url("host.internal:8000/tproxy", True) == ( - "https://host.internal:8000/tproxy" - ) - - class TestUtils: def test_traceparent_format_is_correct(self): traceparent = generate_traceparent() diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 684811a..3c815fc 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,18 +31,21 @@ class FlagsConfig: # evaluation does not block on the network round trip. None (default) # preserves the existing inline behavior. exposure_executor: Optional[Executor] = None + # Scheme used to reach api_host. True (default) uses https. Set to False + # only to reach a local/dev endpoint served over plain HTTP (e.g. a development + # nginx proxy), which avoids needing a TLS cert the client can verify. + use_https: bool = True @dataclass class LocalFlagsConfig(FlagsConfig): enable_polling: bool = True polling_interval_in_seconds: int = 60 - verify_cert: bool = True @dataclass class RemoteFlagsConfig(FlagsConfig): - verify_cert: bool = True + pass @dataclass diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 74e9d04..8ea88c5 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -41,47 +41,8 @@ def close_async_client_from_sync(client: httpx.AsyncClient) -> None: ) -def build_base_url(api_host: str, verify_cert: bool) -> str: - """Build the httpx ``base_url`` for ``api_host``. - - A bare host (the default, ``api.mixpanel.com``) is served over https. - ``api_host`` may instead carry an explicit scheme; ``http://`` is accepted - only when ``verify_cert`` is False, so plaintext is always a deliberate - opt-out of transport security rather than something a typo can cause - silently. - - :param api_host: Host, optionally prefixed with ``http://``/``https://`` - :param verify_cert: Whether the caller verifies the server's TLS cert - :return: Base URL including scheme - :raises ValueError: if ``api_host`` is plain http while ``verify_cert`` is - True, or carries a scheme other than http/https - """ - scheme, separator, _ = api_host.partition("://") - if not separator: - return f"https://{api_host}" - - scheme = scheme.lower() - if scheme == "https": - return api_host - if scheme == "http": - if verify_cert: - msg = ( - f"api_host {api_host!r} uses http://, which sends flag " - "requests (including the token) in the clear. Pass " - "verify_cert=False to acknowledge that, or use https://." - ) - raise ValueError(msg) - return api_host - - msg = ( - f"Unsupported scheme {scheme!r} in api_host {api_host!r}; " - "use http:// or https://." - ) - raise ValueError(msg) - - # The scheme headers are intentionally always "https", even when a provider is -# reaching a plain-http api_host. They describe the original request's scheme +# configured with use_https=False. They describe the original request's scheme # to the flags backend, whose auth rejects requests not marked as https, so a # proxy fronting a plain-HTTP dev endpoint still needs to see https here. REQUEST_HEADERS: dict[str, str] = { From 1e5c5d10c65d8cb97a20e79d48cc388abcdf4b53 Mon Sep 17 00:00:00 2001 From: no value <53093042+matthewestopinal@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:26:27 +0000 Subject: [PATCH 4/4] fix positional bug --- mixpanel/flags/test_local_feature_flags.py | 14 ++++++++++++++ mixpanel/flags/types.py | 7 ++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index ae25b73..29aa122 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -1105,6 +1105,20 @@ def test_use_https_defaults_to_true(): assert LocalFlagsConfig().use_https is True +def test_use_https_leaves_positional_args_unshifted(): + """use_https must stay last so it can't rebind an existing positional. + + Declaring it on FlagsConfig would move enable_polling from the fourth + positional slot to the fifth, so a pre-existing + ``LocalFlagsConfig(host, timeout, executor, False)`` would silently leave + polling enabled and disable HTTPS. See the rationale comment in types.py. + """ + config = LocalFlagsConfig("example.com", 5, None, False) + + assert config.enable_polling is False + assert config.use_https is True + + def test_default_config_uses_https_base_url(): provider = _make_provider() diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 3c815fc..ebbe863 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,21 +31,18 @@ class FlagsConfig: # evaluation does not block on the network round trip. None (default) # preserves the existing inline behavior. exposure_executor: Optional[Executor] = None - # Scheme used to reach api_host. True (default) uses https. Set to False - # only to reach a local/dev endpoint served over plain HTTP (e.g. a development - # nginx proxy), which avoids needing a TLS cert the client can verify. - use_https: bool = True @dataclass class LocalFlagsConfig(FlagsConfig): enable_polling: bool = True polling_interval_in_seconds: int = 60 + use_https: bool = True @dataclass class RemoteFlagsConfig(FlagsConfig): - pass + use_https: bool = True @dataclass