diff --git a/backend_api_python/app/services/live_trading/base.py b/backend_api_python/app/services/live_trading/base.py index 3d7a655b0..66c70cb4c 100644 --- a/backend_api_python/app/services/live_trading/base.py +++ b/backend_api_python/app/services/live_trading/base.py @@ -86,6 +86,35 @@ def _get_requests_verify() -> Union[bool, str]: return _requests_verify_value +# Cached proxy setting for all live-trading REST calls. +_proxies_value: Optional[Dict[str, str]] = None +_proxies_resolved = False + + +def _get_proxies() -> Optional[Dict[str, str]]: + """ + Resolve the proxy for live-trading REST calls. + + ``PROXY_URL`` is documented (see ``env.example``) to cover "market data, + exchange and broker API traffic": CCXT already honors it for market data, + but plain ``requests`` does not understand that variable, so without this + bridge live-trading REST calls bypass the configured proxy entirely. + + - ``PROXY_URL`` set (http(s):// or socks5(h)://, PySocks is in requirements): + returned explicitly and takes precedence over environment proxies. + - ``PROXY_URL`` unset: returns ``None`` — nothing is forced and standard + ``HTTPS_PROXY`` / ``HTTP_PROXY`` / ``ALL_PROXY`` environment variables + still apply through ``requests`` trust_env (with ``NO_PROXY`` bypasses). + """ + global _proxies_value, _proxies_resolved + if not _proxies_resolved: + proxy_url = (os.environ.get("PROXY_URL") or "").strip() + if proxy_url: + _proxies_value = {"http": proxy_url, "https": proxy_url} + _proxies_resolved = True + return _proxies_value + + @dataclass class LiveOrderResult: exchange_id: str @@ -143,6 +172,7 @@ def _request( headers=request_headers or None, timeout=self.timeout_sec, verify=_get_requests_verify(), + proxies=_get_proxies(), ) as resp: text = resp.text or "" parsed: Dict[str, Any] = {} diff --git a/backend_api_python/tests/test_live_rest_proxy.py b/backend_api_python/tests/test_live_rest_proxy.py new file mode 100644 index 000000000..73171de59 --- /dev/null +++ b/backend_api_python/tests/test_live_rest_proxy.py @@ -0,0 +1,92 @@ +"""Live-trading REST proxy resolution. + +Regression coverage for the live-trading funnel honoring ``PROXY_URL``: +market data (CCXT) already honored it while ``BaseRestClient._request`` +sent exchange REST traffic directly, so deployments that rely on +``PROXY_URL`` for exchange reachability broke only on live trading. +""" + +import pytest + +from app.services.live_trading import base as rest_base +from app.services.live_trading.base import BaseRestClient + + +@pytest.fixture(autouse=True) +def _reset_proxy_cache(): + rest_base._proxies_resolved = False + rest_base._proxies_value = None + yield + rest_base._proxies_resolved = False + rest_base._proxies_value = None + + +def _capture_request(monkeypatch): + calls = {} + + def fake_request(method, url, **kwargs): + calls["method"] = method + calls["url"] = url + calls.update(kwargs) + + class _Resp: + status_code = 200 + text = "{}" + + def json(self): + return {} + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + return _Resp() + + monkeypatch.setattr(rest_base.requests, "request", fake_request) + return calls + + +def test_explicit_proxy_url_is_applied(monkeypatch): + monkeypatch.setenv("PROXY_URL", "http://infra-01:20171") + calls = _capture_request(monkeypatch) + BaseRestClient("https://example.com")._request("GET", "/time") + assert calls["proxies"] == { + "http": "http://infra-01:20171", + "https": "http://infra-01:20171", + } + + +def test_socks5h_proxy_url_is_applied(monkeypatch): + monkeypatch.setenv("PROXY_URL", "socks5h://127.0.0.1:10808") + calls = _capture_request(monkeypatch) + BaseRestClient("https://example.com")._request("GET", "/time") + assert calls["proxies"] == { + "http": "socks5h://127.0.0.1:10808", + "https": "socks5h://127.0.0.1:10808", + } + + +def test_unset_proxy_url_forces_nothing(monkeypatch): + monkeypatch.delenv("PROXY_URL", raising=False) + calls = _capture_request(monkeypatch) + BaseRestClient("https://example.com")._request("GET", "/time") + assert calls["proxies"] is None # trust_env (standard vars) stays in charge + + +def test_resolution_is_cached_per_process(monkeypatch): + monkeypatch.setenv("PROXY_URL", "http://first:1") + assert rest_base._get_proxies()["http"] == "http://first:1" + monkeypatch.setenv("PROXY_URL", "http://second:2") + assert rest_base._get_proxies()["http"] == "http://first:1" + + +def test_funnel_is_single_entrypoint(monkeypatch): + """Guard: _request must keep forwarding the resolved proxies kwarg.""" + monkeypatch.setenv("PROXY_URL", "http://infra-01:20171") + calls = _capture_request(monkeypatch) + client = BaseRestClient("https://example.com") + client._request("POST", "/order", json_body={"x": 1}) + assert calls["method"] == "POST" + assert calls["proxies"] is not None