Skip to content

Commit 188602c

Browse files
Pigbibicodex
andcommitted
fix: require writable IBKR gateway readiness
Co-Authored-By: Codex <noreply@openai.com>
1 parent 77fbcb6 commit 188602c

2 files changed

Lines changed: 196 additions & 2 deletions

File tree

scripts/wait_for_ib_gateway_ready.sh

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ gateway_mode="${1:-${IB_GATEWAY_MODE:-paper}}"
66
ready_timeout_seconds="${IB_GATEWAY_READY_TIMEOUT_SECONDS:-240}"
77
poll_interval_seconds="${IB_GATEWAY_READY_POLL_INTERVAL_SECONDS:-5}"
88
handshake_timeout_seconds="${IB_GATEWAY_HANDSHAKE_TIMEOUT_SECONDS:-12}"
9+
order_access_timeout_seconds="${IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS:-4}"
910
ready_stability_seconds="${IB_GATEWAY_READY_STABILITY_SECONDS:-35}"
1011
configured_healthcheck_client_id="${IB_GATEWAY_HEALTHCHECK_CLIENT_ID:-}"
1112

@@ -26,11 +27,13 @@ deadline=$((SECONDS + ready_timeout_seconds))
2627

2728
check_api_handshake() {
2829
local healthcheck_client_id="$1"
30+
local process_timeout_seconds=$((handshake_timeout_seconds + order_access_timeout_seconds))
2931

30-
timeout "${handshake_timeout_seconds}" docker exec -i "${container_name}" \
32+
timeout "${process_timeout_seconds}" docker exec -i "${container_name}" \
3133
env IB_GATEWAY_HEALTHCHECK_PORT="${gateway_port}" \
3234
IB_GATEWAY_HEALTHCHECK_CLIENT_ID="${healthcheck_client_id}" \
3335
IB_GATEWAY_HEALTHCHECK_TIMEOUT_SECONDS="${handshake_timeout_seconds}" \
36+
IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS="${order_access_timeout_seconds}" \
3437
python3 <<'PY'
3538
import os
3639
import socket
@@ -43,7 +46,18 @@ host = "127.0.0.1"
4346
port = int(os.environ["IB_GATEWAY_HEALTHCHECK_PORT"])
4447
client_id = int(os.environ["IB_GATEWAY_HEALTHCHECK_CLIENT_ID"])
4548
timeout_seconds = float(os.environ["IB_GATEWAY_HEALTHCHECK_TIMEOUT_SECONDS"])
49+
order_access_timeout_seconds = float(
50+
os.environ["IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS"]
51+
)
4652
deadline = time.monotonic() + timeout_seconds
53+
if client_id <= 0:
54+
raise RuntimeError("IB API healthcheck client ID must be greater than zero")
55+
read_only_api = os.environ.get("READ_ONLY_API", "").strip().lower()
56+
if read_only_api not in {"yes", "no"}:
57+
raise RuntimeError(
58+
"READ_ONLY_API must be explicitly configured as yes or no for the healthcheck"
59+
)
60+
require_order_access = read_only_api == "no"
4761
4862
4963
try:
@@ -54,6 +68,32 @@ except ImportError:
5468
5569
if IB is not None:
5670
ib = IB()
71+
read_only_errors = []
72+
73+
def is_read_only_error(message):
74+
normalized = str(message).lower().replace("-", " ").replace("_", " ")
75+
return "read only" in " ".join(normalized.split())
76+
77+
def capture_api_error(_request_id, error_code, error_message, _contract):
78+
if is_read_only_error(error_message):
79+
read_only_errors.append((error_code, str(error_message)))
80+
81+
def request_order_data(label, callback):
82+
try:
83+
callback()
84+
except Exception as exc:
85+
if read_only_errors or is_read_only_error(exc):
86+
raise RuntimeError(
87+
"IB API writable healthcheck failed: Gateway is in Read-Only mode"
88+
) from exc
89+
raise RuntimeError(
90+
f"IB API writable healthcheck {label} failed: {type(exc).__name__}"
91+
) from exc
92+
if read_only_errors:
93+
raise RuntimeError(
94+
"IB API writable healthcheck failed: Gateway is in Read-Only mode"
95+
)
96+
5797
try:
5898
try:
5999
ib.connect(
@@ -66,11 +106,31 @@ if IB is not None:
66106
accounts = ib.managedAccounts()
67107
if not accounts:
68108
raise RuntimeError("IB API healthcheck did not receive managed accounts")
109+
if require_order_access:
110+
original_raise_request_errors = ib.RaiseRequestErrors
111+
original_request_timeout = ib.RequestTimeout
112+
ib.errorEvent += capture_api_error
113+
try:
114+
# Read order state only; this never calls placeOrder or cancelOrder.
115+
ib.RaiseRequestErrors = True
116+
ib.RequestTimeout = order_access_timeout_seconds
117+
request_order_data("open orders request", ib.reqOpenOrders)
118+
finally:
119+
ib.errorEvent -= capture_api_error
120+
ib.RaiseRequestErrors = original_raise_request_errors
121+
ib.RequestTimeout = original_request_timeout
122+
print(
123+
"IB API writable healthcheck ready: "
124+
f"server_version={ib.client.serverVersion()} "
125+
f"client_id={client_id} "
126+
f"account_count={len(accounts)}"
127+
)
69128
print(
70129
"IB API ib_insync healthcheck ready: "
71130
f"server_version={ib.client.serverVersion()} "
72131
f"client_id={client_id} "
73-
f"accounts={','.join(accounts)}"
132+
f"writable={str(require_order_access).lower()} "
133+
f"account_count={len(accounts)}"
74134
)
75135
except Exception as exc:
76136
print(
@@ -84,6 +144,13 @@ if IB is not None:
84144
ib.disconnect()
85145
raise SystemExit(0)
86146
147+
if require_order_access:
148+
print(
149+
"IB API writable healthcheck requires ib_insync; refusing raw handshake fallback",
150+
file=sys.stderr,
151+
)
152+
raise SystemExit(1)
153+
87154
88155
def remaining_timeout() -> float:
89156
remaining = deadline - time.monotonic()

tests/test_wait_for_ib_gateway_ready.sh

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ test -x "$script_file" || true
99
grep -Fq 'container_name="${IB_GATEWAY_CONTAINER_NAME:-ib-gateway}"' "$script_file"
1010
grep -Fq 'ready_timeout_seconds="${IB_GATEWAY_READY_TIMEOUT_SECONDS:-240}"' "$script_file"
1111
grep -Fq 'ready_stability_seconds="${IB_GATEWAY_READY_STABILITY_SECONDS:-35}"' "$script_file"
12+
grep -Fq 'order_access_timeout_seconds="${IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS:-4}"' "$script_file"
1213
grep -Fq 'gateway_port=4002' "$script_file"
1314
grep -Fq 'gateway_port=4001' "$script_file"
1415
grep -Fq "docker inspect --format '{{.State.Running}}'" "$script_file"
@@ -20,8 +21,134 @@ grep -Fq 'confirm_stable_ready()' "$script_file"
2021
grep -Fq 'confirming stability' "$script_file"
2122
grep -Fq 'from ib_insync import IB' "$script_file"
2223
grep -Fq 'readonly=True' "$script_file"
24+
grep -Fq 'read_only_api = os.environ.get("READ_ONLY_API", "").strip().lower()' "$script_file"
25+
grep -Fq 'require_order_access = read_only_api == "no"' "$script_file"
26+
grep -Fq 'ib.RaiseRequestErrors = True' "$script_file"
27+
grep -Fq 'ib.RequestTimeout = order_access_timeout_seconds' "$script_file"
28+
grep -Fq 'request_order_data("open orders request", ib.reqOpenOrders)' "$script_file"
29+
if grep -Fq 'ib.reqCompletedOrders(' "$script_file"; then
30+
echo "Gateway readiness must not depend on completed-order history" >&2
31+
exit 1
32+
fi
33+
grep -Fq 'if client_id <= 0:' "$script_file"
34+
grep -Fq 'IB API writable healthcheck ready' "$script_file"
35+
grep -Fq 'account_count={len(accounts)}' "$script_file"
36+
if grep -Fq "accounts={','.join(accounts)}" "$script_file"; then
37+
echo "Gateway healthcheck must not log account identifiers" >&2
38+
exit 1
39+
fi
2340
grep -Fq 'IB API ib_insync healthcheck ready' "$script_file"
2441
grep -Fq 'b"API\0" + struct.pack(">I", len(b"v157..176")) + b"v157..176"' "$script_file"
2542
grep -Fq 'has_next_valid_id and has_managed_accounts' "$script_file"
2643
grep -Fq 'IB API handshake readiness' "$script_file"
2744
grep -Fq 'docker logs --tail 120 "${container_name}"' "$script_file"
45+
46+
tmp_dir="$(mktemp -d)"
47+
trap 'rm -rf "$tmp_dir"' EXIT
48+
awk '
49+
/^[[:space:]]*python3 <<'\''PY'\''$/ { capture = 1; next }
50+
capture && /^PY$/ { exit }
51+
capture { print }
52+
' "$script_file" > "$tmp_dir/check_api.py"
53+
cat > "$tmp_dir/ib_insync.py" <<'PY'
54+
import os
55+
56+
57+
class Event:
58+
def __init__(self):
59+
self.handlers = []
60+
61+
def __iadd__(self, handler):
62+
self.handlers.append(handler)
63+
return self
64+
65+
def __isub__(self, handler):
66+
self.handlers.remove(handler)
67+
return self
68+
69+
def emit(self, *args):
70+
for handler in tuple(self.handlers):
71+
handler(*args)
72+
73+
74+
class Client:
75+
@staticmethod
76+
def serverVersion():
77+
return 176
78+
79+
80+
class IB:
81+
RaiseRequestErrors = False
82+
RequestTimeout = 0
83+
84+
def __init__(self):
85+
self.client = Client()
86+
self.errorEvent = Event()
87+
self.connected = False
88+
89+
def connect(self, *_args, **kwargs):
90+
assert kwargs["clientId"] > 0
91+
assert kwargs["readonly"] is True
92+
self.connected = True
93+
94+
@staticmethod
95+
def managedAccounts():
96+
return ["U_TEST"]
97+
98+
def reqOpenOrders(self):
99+
assert self.RaiseRequestErrors is True
100+
assert self.RequestTimeout == 1
101+
if os.environ.get("FAKE_IB_READ_ONLY") == "1":
102+
self.errorEvent.emit(-1, 321, "API is in Read-Only mode", None)
103+
raise TimeoutError("open orders timed out")
104+
if os.environ.get("FAKE_IB_UNRELATED_321") == "1":
105+
self.errorEvent.emit(-1, 321, "Generic validation error", None)
106+
if os.environ.get("FAKE_IB_FAIL_ON_ORDER_ACCESS") == "1":
107+
raise AssertionError("order access probe must not run")
108+
return []
109+
110+
def reqCompletedOrders(self, *, apiOnly):
111+
assert apiOnly is True
112+
return []
113+
114+
def isConnected(self):
115+
return self.connected
116+
117+
def disconnect(self):
118+
self.connected = False
119+
PY
120+
121+
healthcheck_env=(
122+
IB_GATEWAY_HEALTHCHECK_PORT=4001
123+
IB_GATEWAY_HEALTHCHECK_CLIENT_ID=9001
124+
IB_GATEWAY_HEALTHCHECK_TIMEOUT_SECONDS=2
125+
IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS=1
126+
PYTHONPATH="$tmp_dir"
127+
)
128+
129+
env "${healthcheck_env[@]}" READ_ONLY_API=no \
130+
python3 "$tmp_dir/check_api.py" > "$tmp_dir/writable.out"
131+
grep -Fq 'IB API writable healthcheck ready' "$tmp_dir/writable.out"
132+
grep -Fq 'account_count=1' "$tmp_dir/writable.out"
133+
134+
if env "${healthcheck_env[@]}" READ_ONLY_API=no FAKE_IB_READ_ONLY=1 \
135+
python3 "$tmp_dir/check_api.py" > "$tmp_dir/read-only.out" 2>&1; then
136+
echo "Read-Only Gateway unexpectedly passed writable healthcheck" >&2
137+
exit 1
138+
fi
139+
grep -Fq 'Gateway is in Read-Only mode' "$tmp_dir/read-only.out"
140+
141+
env "${healthcheck_env[@]}" READ_ONLY_API=no FAKE_IB_UNRELATED_321=1 \
142+
python3 "$tmp_dir/check_api.py" > "$tmp_dir/unrelated-321.out"
143+
grep -Fq 'IB API writable healthcheck ready' "$tmp_dir/unrelated-321.out"
144+
145+
env "${healthcheck_env[@]}" READ_ONLY_API=yes FAKE_IB_FAIL_ON_ORDER_ACCESS=1 \
146+
python3 "$tmp_dir/check_api.py" > "$tmp_dir/read-only-expected.out"
147+
grep -Fq 'writable=false' "$tmp_dir/read-only-expected.out"
148+
149+
if env "${healthcheck_env[@]}" IB_GATEWAY_HEALTHCHECK_CLIENT_ID=0 READ_ONLY_API=no \
150+
python3 "$tmp_dir/check_api.py" > "$tmp_dir/client-zero.out" 2>&1; then
151+
echo "clientId=0 unexpectedly passed Gateway healthcheck" >&2
152+
exit 1
153+
fi
154+
grep -Fq 'client ID must be greater than zero' "$tmp_dir/client-zero.out"

0 commit comments

Comments
 (0)