Skip to content

Commit b187001

Browse files
Pigbibicodex
andcommitted
fix: harden IBKR runtime against historical failures
Co-Authored-By: Codex <noreply@openai.com>
1 parent 9d96bad commit b187001

9 files changed

Lines changed: 209 additions & 8 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ jobs:
100100
assert resolve_hk_canonical_profile("hk_global_etf_tactical_rotation") == "hk_global_etf_tactical_rotation"
101101
PY
102102
103+
- name: Validate production Cloud Run startup
104+
run: uv run --no-sync python scripts/validate_cloud_run_startup.py
105+
103106
- name: Install editable shared repositories
104107
run: |
105108
set -euo pipefail

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ RUN apt-get update \
1616
COPY . .
1717
RUN python -m pip install --upgrade pip uv \
1818
&& uv sync --frozen --no-dev \
19+
&& python scripts/validate_cloud_run_startup.py \
1920
&& apt-get purge -y git \
2021
&& apt-get autoremove -y --purge \
2122
&& rm -rf /var/lib/apt/lists/* \

application/runtime_broker_adapters.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,11 @@ def fetch_account_portfolio_snapshot(self, ib):
8282

8383
def connect_ib(self):
8484
self.ensure_event_loop_fn()
85-
host = self.host_resolver()
8685
last_error = None
8786
for attempt in range(1, self.connect_attempts + 1):
87+
# Resolve on every retry. GCE-backed gateways can receive a new
88+
# internal IP after restart, so retries must not pin the first one.
89+
host = self.host_resolver()
8890
client_id = self.ib_client_id + ((attempt - 1) * self.client_id_retry_offset)
8991
self.printer(
9092
"Connecting to IB gateway "

main.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,16 @@ def resolve_gce_instance_ip(instance_name, zone):
131131

132132
def get_ib_host():
133133
global IB_HOST
134-
if IB_HOST:
135-
return IB_HOST
136134
host = RUNTIME_SETTINGS.ib_gateway_instance_name
137135
zone = RUNTIME_SETTINGS.ib_gateway_zone
138136
if zone:
137+
# A restarted GCE gateway can receive a new internal IP. The broker
138+
# adapter calls this resolver for every retry, so refresh zoned hosts.
139139
host = resolve_gce_instance_ip(host, zone)
140+
IB_HOST = host
141+
return host
142+
if IB_HOST:
143+
return IB_HOST
140144
IB_HOST = host
141145
return host
142146

@@ -1309,29 +1313,44 @@ def _handle_request(
13091313
},
13101314
)
13111315
raise
1312-
except TimeoutError as exc:
1316+
except (ConnectionError, TimeoutError) as exc:
13131317
append_runtime_report_error(
13141318
report,
13151319
stage="ibkr_connect",
13161320
message=str(exc),
13171321
error_type=type(exc).__name__,
1322+
failure_category="ibkr_gateway_unavailable",
1323+
)
1324+
finalize_runtime_report(
1325+
report,
1326+
status="error",
1327+
diagnostics={"failure_category": "ibkr_gateway_unavailable"},
1328+
)
1329+
event = (
1330+
"ibkr_gateway_connect_timeout"
1331+
if isinstance(exc, TimeoutError)
1332+
else "ibkr_gateway_connect_failed"
13181333
)
1319-
finalize_runtime_report(report, status="error")
13201334
log_runtime_event(
13211335
log_context,
1322-
"ibkr_gateway_connect_timeout",
1323-
message="IBKR gateway handshake timed out",
1336+
event,
1337+
message=(
1338+
"IBKR gateway handshake timed out"
1339+
if isinstance(exc, TimeoutError)
1340+
else "IBKR gateway connection failed"
1341+
),
13241342
severity="ERROR",
13251343
error_type=type(exc).__name__,
13261344
error_message=str(exc),
1345+
failure_category="ibkr_gateway_unavailable",
13271346
)
13281347
error_msg = f"🚨 【IBKR 连接异常】\n{str(exc)}"
13291348
_publish_runtime_failure_notification(
13301349
detailed_text=error_msg,
13311350
compact_text=error_msg,
13321351
exc=exc,
13331352
)
1334-
return "Error", 500
1353+
return "Error", 503
13351354
except Exception as exc:
13361355
append_runtime_report_error(
13371356
report,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python3
2+
"""Import the production WSGI app with an inert IBKR configuration.
3+
4+
This check performs no network or broker operations. It catches dependency API
5+
drift, module import failures, and Flask route registration conflicts before a
6+
container can be deployed.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import os
13+
14+
15+
def _install_smoke_environment() -> None:
16+
account_group = "startup-smoke"
17+
os.environ["ACCOUNT_GROUP"] = account_group
18+
os.environ["IB_ACCOUNT_GROUP_CONFIG_JSON"] = json.dumps(
19+
{
20+
"groups": {
21+
account_group: {
22+
"ib_gateway_instance_name": "127.0.0.1",
23+
"ib_gateway_mode": "paper",
24+
"ib_client_id": 1,
25+
"account_ids": ["DU000000"],
26+
}
27+
}
28+
},
29+
separators=(",", ":"),
30+
)
31+
os.environ["IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME"] = ""
32+
os.environ["RUNTIME_TARGET_JSON"] = json.dumps(
33+
{
34+
"platform_id": "ibkr",
35+
"strategy_profile": "global_etf_rotation",
36+
"dry_run_only": True,
37+
"execution_mode": "paper",
38+
"account_scope": account_group,
39+
},
40+
separators=(",", ":"),
41+
)
42+
43+
44+
def validate_startup() -> None:
45+
_install_smoke_environment()
46+
47+
import main
48+
49+
routes = {
50+
rule.rule: set(rule.methods) - {"HEAD", "OPTIONS"}
51+
for rule in main.app.url_map.iter_rules()
52+
}
53+
required_routes = {
54+
"/health": {"GET"},
55+
"/run": {"GET", "POST"},
56+
"/dry-run": {"GET", "POST"},
57+
"/probe": {"GET", "POST"},
58+
"/monitor-dispatch": {"GET", "POST"},
59+
}
60+
missing_or_invalid = {
61+
path: {"expected": sorted(methods), "actual": sorted(routes.get(path, set()))}
62+
for path, methods in required_routes.items()
63+
if routes.get(path) != methods
64+
}
65+
if missing_or_invalid:
66+
raise RuntimeError(f"Cloud Run route contract is invalid: {missing_or_invalid}")
67+
68+
print(f"Cloud Run startup validation passed: routes={len(routes)}")
69+
70+
71+
if __name__ == "__main__":
72+
validate_startup()
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pathlib import Path
2+
3+
4+
ROOT = Path(__file__).resolve().parents[1]
5+
6+
7+
def test_production_startup_validation_is_a_ci_and_image_build_gate():
8+
command = "python scripts/validate_cloud_run_startup.py"
9+
dockerfile = (ROOT / "Dockerfile").read_text()
10+
ci_workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text()
11+
12+
assert command in dockerfile
13+
assert "Validate production Cloud Run startup" in ci_workflow
14+
assert f"uv run --no-sync {command}" in ci_workflow

tests/test_event_loop.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ def get(self, project, zone, instance):
138138
assert module.IB_HOST == "10.0.0.8"
139139

140140

141+
def test_get_ib_host_refreshes_zoned_gateway_ip(strategy_module_factory, monkeypatch):
142+
module = strategy_module_factory(
143+
IB_GATEWAY_ZONE="us-central1-a",
144+
IB_ACCOUNT_GROUP_CONFIG_JSON=(
145+
'{"groups":{"default":{"ib_gateway_instance_name":"ib-gateway",'
146+
'"ib_gateway_mode":"live","ib_client_id":1}}}'
147+
),
148+
)
149+
resolved = iter(("10.0.0.8", "10.0.0.9"))
150+
monkeypatch.setattr(module, "resolve_gce_instance_ip", lambda *_args: next(resolved))
151+
152+
assert module.get_ib_host() == "10.0.0.8"
153+
assert module.get_ib_host() == "10.0.0.9"
154+
assert module.IB_HOST == "10.0.0.9"
155+
156+
141157
def test_ib_gateway_mode_derives_paper_port(strategy_module_factory):
142158
module = strategy_module_factory(
143159
IB_ACCOUNT_GROUP_CONFIG_JSON=(

tests/test_request_handling.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,47 @@ def test_handle_request_error_persists_machine_readable_report(strategy_module,
903903
assert len(observed["messages"]) == 1
904904

905905

906+
def test_handle_request_classifies_gateway_connection_failure_as_unavailable(strategy_module, monkeypatch):
907+
observed = {"events": [], "notifications": []}
908+
909+
monkeypatch.setattr(strategy_module, "is_market_open_now", lambda **_kwargs: True)
910+
monkeypatch.setattr(
911+
strategy_module,
912+
"run_strategy_core",
913+
lambda **_kwargs: (_ for _ in ()).throw(
914+
ConnectionRefusedError("TCP preflight failed: connection refused")
915+
),
916+
)
917+
monkeypatch.setattr(
918+
strategy_module,
919+
"persist_execution_report",
920+
lambda report: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json",
921+
)
922+
monkeypatch.setattr(
923+
strategy_module,
924+
"log_runtime_event",
925+
lambda _context, event, **fields: observed["events"].append((event, fields)),
926+
)
927+
monkeypatch.setattr(
928+
strategy_module,
929+
"publish_notification",
930+
lambda **kwargs: observed["notifications"].append(kwargs),
931+
)
932+
933+
with strategy_module.app.test_request_context("/run", method="POST"):
934+
body, status = strategy_module.handle_request()
935+
936+
assert status == 503
937+
assert body == "Error"
938+
assert observed["report"]["status"] == "error"
939+
assert observed["report"]["errors"][0]["stage"] == "ibkr_connect"
940+
assert observed["report"]["errors"][0]["failure_category"] == "ibkr_gateway_unavailable"
941+
assert observed["report"]["diagnostics"]["failure_category"] == "ibkr_gateway_unavailable"
942+
assert observed["events"][-1][0] == "ibkr_gateway_connect_failed"
943+
assert observed["events"][-1][1]["failure_category"] == "ibkr_gateway_unavailable"
944+
assert len(observed["notifications"]) == 1
945+
946+
906947
def test_run_strategy_core_allows_multiple_runs_in_same_process(strategy_module, monkeypatch):
907948
observed = {"connect_calls": 0, "disconnect_calls": 0, "messages": []}
908949

tests/test_runtime_broker_adapters.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,39 @@ def test_connect_ib_accepts_configured_managed_account():
5353
assert ib.managedAccounts() == ["U1234567"]
5454

5555

56+
def test_connect_ib_resolves_gateway_host_again_for_each_retry():
57+
observed = {"hosts": [], "resolved": []}
58+
resolved_hosts = iter(("10.0.0.8", "10.0.0.9"))
59+
60+
def resolve_host():
61+
host = next(resolved_hosts)
62+
observed["resolved"].append(host)
63+
return host
64+
65+
def connect(host, *_args, **_kwargs):
66+
observed["hosts"].append(host)
67+
if len(observed["hosts"]) == 1:
68+
raise ConnectionRefusedError("gateway restarting")
69+
return SimpleNamespace(managedAccounts=lambda: ["U1234567"])
70+
71+
adapters = _build_adapters()
72+
adapters = adapters.__class__(
73+
**{
74+
**adapters.__dict__,
75+
"host_resolver": resolve_host,
76+
"connect_ib_fn": connect,
77+
"connect_attempts": 2,
78+
}
79+
)
80+
81+
adapters.connect_ib()
82+
83+
assert observed == {
84+
"hosts": ["10.0.0.8", "10.0.0.9"],
85+
"resolved": ["10.0.0.8", "10.0.0.9"],
86+
}
87+
88+
5689
def test_connect_ib_rejects_configured_account_not_visible_to_gateway_username():
5790
observed = {"disconnects": 0}
5891

0 commit comments

Comments
 (0)