From def2ae45b04f54d753055bb4ea24e98856db5665 Mon Sep 17 00:00:00 2001 From: z <1837800317@qq.com> Date: Sat, 29 Aug 2026 23:16:34 +0800 Subject: [PATCH] fix: honor PROXY_URL for live-trading REST calls env.example documents PROXY_URL as covering 'market data, exchange and broker API traffic'. Market data (CCXT) honors it, but the live-trading funnel BaseRestClient._request used plain requests without proxies, so on deployments that reach exchanges only through PROXY_URL, every private REST call (position snapshot, orders, account queries) failed with ConnectionResetError while signals kept working from cached market data. Add a cached _get_proxies() resolver next to _get_requests_verify(): PROXY_URL set -> explicit http/https proxies (socks5(h) works, PySocks is already a dependency); unset -> None, so standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY env vars and NO_PROXY bypasses keep applying via trust_env, unchanged from current behavior. Covered by unit tests for explicit/socks/unset/cached resolution. --- .../app/services/live_trading/base.py | 30 ++++++ .../tests/test_live_rest_proxy.py | 92 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 backend_api_python/tests/test_live_rest_proxy.py 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