Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions scripts/wait_for_ib_gateway_ready.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ gateway_mode="${1:-${IB_GATEWAY_MODE:-paper}}"
ready_timeout_seconds="${IB_GATEWAY_READY_TIMEOUT_SECONDS:-240}"
poll_interval_seconds="${IB_GATEWAY_READY_POLL_INTERVAL_SECONDS:-5}"
handshake_timeout_seconds="${IB_GATEWAY_HANDSHAKE_TIMEOUT_SECONDS:-12}"
order_access_timeout_seconds="${IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS:-4}"
ready_stability_seconds="${IB_GATEWAY_READY_STABILITY_SECONDS:-35}"
configured_healthcheck_client_id="${IB_GATEWAY_HEALTHCHECK_CLIENT_ID:-}"

Expand All @@ -22,15 +23,42 @@ case "${gateway_mode}" in
;;
esac

sum_timeout_seconds() {
awk -v first="$1" -v second="$2" '
function is_nonnegative_number(value) {
return value ~ /^([0-9]+([.][0-9]*)?|[.][0-9]+)$/ && value + 0 >= 0
}

BEGIN {
if (!is_nonnegative_number(first) || !is_nonnegative_number(second)) {
exit 1
}
total = first + second
if (total <= 0) {
exit 1
}
printf "%.15g\n", total
}
'
}

if ! process_timeout_seconds="$(
sum_timeout_seconds "${handshake_timeout_seconds}" "${order_access_timeout_seconds}"
)"; then
echo "IB Gateway handshake and order-access timeouts must be non-negative numbers with a positive total" >&2
exit 1
fi

deadline=$((SECONDS + ready_timeout_seconds))

check_api_handshake() {
local healthcheck_client_id="$1"

timeout "${handshake_timeout_seconds}" docker exec -i "${container_name}" \
timeout "${process_timeout_seconds}" docker exec -i "${container_name}" \
env IB_GATEWAY_HEALTHCHECK_PORT="${gateway_port}" \
IB_GATEWAY_HEALTHCHECK_CLIENT_ID="${healthcheck_client_id}" \
IB_GATEWAY_HEALTHCHECK_TIMEOUT_SECONDS="${handshake_timeout_seconds}" \
IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS="${order_access_timeout_seconds}" \
python3 <<'PY'
import os
import socket
Expand All @@ -43,7 +71,18 @@ host = "127.0.0.1"
port = int(os.environ["IB_GATEWAY_HEALTHCHECK_PORT"])
client_id = int(os.environ["IB_GATEWAY_HEALTHCHECK_CLIENT_ID"])
timeout_seconds = float(os.environ["IB_GATEWAY_HEALTHCHECK_TIMEOUT_SECONDS"])
order_access_timeout_seconds = float(
os.environ["IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS"]
)
deadline = time.monotonic() + timeout_seconds
if client_id <= 0:
raise RuntimeError("IB API healthcheck client ID must be greater than zero")
read_only_api = os.environ.get("READ_ONLY_API", "").strip().lower()
if read_only_api not in {"yes", "no"}:
raise RuntimeError(
"READ_ONLY_API must be explicitly configured as yes or no for the healthcheck"
)
require_order_access = read_only_api == "no"


try:
Expand All @@ -54,6 +93,32 @@ except ImportError:

if IB is not None:
ib = IB()
read_only_errors = []

def is_read_only_error(message):
normalized = str(message).lower().replace("-", " ").replace("_", " ")
return "read only" in " ".join(normalized.split())

def capture_api_error(_request_id, error_code, error_message, _contract):
if is_read_only_error(error_message):
read_only_errors.append((error_code, str(error_message)))

def request_order_data(label, callback):
try:
callback()
except Exception as exc:
if read_only_errors or is_read_only_error(exc):
raise RuntimeError(
"IB API writable healthcheck failed: Gateway is in Read-Only mode"
) from exc
raise RuntimeError(
f"IB API writable healthcheck {label} failed: {type(exc).__name__}"
) from exc
if read_only_errors:
raise RuntimeError(
"IB API writable healthcheck failed: Gateway is in Read-Only mode"
)

try:
try:
ib.connect(
Expand All @@ -66,11 +131,31 @@ if IB is not None:
accounts = ib.managedAccounts()
if not accounts:
raise RuntimeError("IB API healthcheck did not receive managed accounts")
if require_order_access:
original_raise_request_errors = ib.RaiseRequestErrors
original_request_timeout = ib.RequestTimeout
ib.errorEvent += capture_api_error
try:
# Read order state only; this never calls placeOrder or cancelOrder.
ib.RaiseRequestErrors = True
ib.RequestTimeout = order_access_timeout_seconds
request_order_data("open orders request", ib.reqOpenOrders)
finally:
ib.errorEvent -= capture_api_error
ib.RaiseRequestErrors = original_raise_request_errors
ib.RequestTimeout = original_request_timeout
print(
"IB API writable healthcheck ready: "
f"server_version={ib.client.serverVersion()} "
f"client_id={client_id} "
f"account_count={len(accounts)}"
)
print(
"IB API ib_insync healthcheck ready: "
f"server_version={ib.client.serverVersion()} "
f"client_id={client_id} "
f"accounts={','.join(accounts)}"
f"writable={str(require_order_access).lower()} "
f"account_count={len(accounts)}"
)
except Exception as exc:
print(
Expand All @@ -84,6 +169,13 @@ if IB is not None:
ib.disconnect()
raise SystemExit(0)

if require_order_access:
print(
"IB API writable healthcheck requires ib_insync; refusing raw handshake fallback",
file=sys.stderr,
)
raise SystemExit(1)


def remaining_timeout() -> float:
remaining = deadline - time.monotonic()
Expand Down
140 changes: 140 additions & 0 deletions tests/test_wait_for_ib_gateway_ready.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ test -x "$script_file" || true
grep -Fq 'container_name="${IB_GATEWAY_CONTAINER_NAME:-ib-gateway}"' "$script_file"
grep -Fq 'ready_timeout_seconds="${IB_GATEWAY_READY_TIMEOUT_SECONDS:-240}"' "$script_file"
grep -Fq 'ready_stability_seconds="${IB_GATEWAY_READY_STABILITY_SECONDS:-35}"' "$script_file"
grep -Fq 'order_access_timeout_seconds="${IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS:-4}"' "$script_file"
grep -Fq 'gateway_port=4002' "$script_file"
grep -Fq 'gateway_port=4001' "$script_file"
grep -Fq "docker inspect --format '{{.State.Running}}'" "$script_file"
Expand All @@ -20,8 +21,147 @@ grep -Fq 'confirm_stable_ready()' "$script_file"
grep -Fq 'confirming stability' "$script_file"
grep -Fq 'from ib_insync import IB' "$script_file"
grep -Fq 'readonly=True' "$script_file"
grep -Fq 'read_only_api = os.environ.get("READ_ONLY_API", "").strip().lower()' "$script_file"
grep -Fq 'require_order_access = read_only_api == "no"' "$script_file"
grep -Fq 'ib.RaiseRequestErrors = True' "$script_file"
grep -Fq 'ib.RequestTimeout = order_access_timeout_seconds' "$script_file"
grep -Fq 'request_order_data("open orders request", ib.reqOpenOrders)' "$script_file"
if grep -Fq 'ib.reqCompletedOrders(' "$script_file"; then
echo "Gateway readiness must not depend on completed-order history" >&2
exit 1
fi
grep -Fq 'if client_id <= 0:' "$script_file"
grep -Fq 'IB API writable healthcheck ready' "$script_file"
grep -Fq 'account_count={len(accounts)}' "$script_file"
if grep -Fq "accounts={','.join(accounts)}" "$script_file"; then
echo "Gateway healthcheck must not log account identifiers" >&2
exit 1
fi
grep -Fq 'IB API ib_insync healthcheck ready' "$script_file"
grep -Fq 'b"API\0" + struct.pack(">I", len(b"v157..176")) + b"v157..176"' "$script_file"
grep -Fq 'has_next_valid_id and has_managed_accounts' "$script_file"
grep -Fq 'IB API handshake readiness' "$script_file"
grep -Fq 'docker logs --tail 120 "${container_name}"' "$script_file"

tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
awk '
/^sum_timeout_seconds\(\) \{/ { capture = 1 }
capture { print }
capture && /^}/ { exit }
' "$script_file" > "$tmp_dir/sum_timeout_seconds.sh"
# shellcheck disable=SC1091
source "$tmp_dir/sum_timeout_seconds.sh"
test "$(sum_timeout_seconds 12 0.5)" = "12.5"
if sum_timeout_seconds invalid 0.5 >/dev/null 2>&1; then
echo "Invalid Gateway timeout unexpectedly passed validation" >&2
exit 1
fi

awk '
/^[[:space:]]*python3 <<'\''PY'\''$/ { capture = 1; next }
capture && /^PY$/ { exit }
capture { print }
' "$script_file" > "$tmp_dir/check_api.py"
cat > "$tmp_dir/ib_insync.py" <<'PY'
import os


class Event:
def __init__(self):
self.handlers = []

def __iadd__(self, handler):
self.handlers.append(handler)
return self

def __isub__(self, handler):
self.handlers.remove(handler)
return self

def emit(self, *args):
for handler in tuple(self.handlers):
handler(*args)


class Client:
@staticmethod
def serverVersion():
return 176


class IB:
RaiseRequestErrors = False
RequestTimeout = 0

def __init__(self):
self.client = Client()
self.errorEvent = Event()
self.connected = False

def connect(self, *_args, **kwargs):
assert kwargs["clientId"] > 0
assert kwargs["readonly"] is True
self.connected = True

@staticmethod
def managedAccounts():
return ["U_TEST"]

def reqOpenOrders(self):
assert self.RaiseRequestErrors is True
assert self.RequestTimeout == 1
if os.environ.get("FAKE_IB_READ_ONLY") == "1":
self.errorEvent.emit(-1, 321, "API is in Read-Only mode", None)
raise TimeoutError("open orders timed out")
if os.environ.get("FAKE_IB_UNRELATED_321") == "1":
self.errorEvent.emit(-1, 321, "Generic validation error", None)
if os.environ.get("FAKE_IB_FAIL_ON_ORDER_ACCESS") == "1":
raise AssertionError("order access probe must not run")
return []

def reqCompletedOrders(self, *, apiOnly):
assert apiOnly is True
return []

def isConnected(self):
return self.connected

def disconnect(self):
self.connected = False
PY

healthcheck_env=(
IB_GATEWAY_HEALTHCHECK_PORT=4001
IB_GATEWAY_HEALTHCHECK_CLIENT_ID=9001
IB_GATEWAY_HEALTHCHECK_TIMEOUT_SECONDS=2
IB_GATEWAY_ORDER_ACCESS_TIMEOUT_SECONDS=1
PYTHONPATH="$tmp_dir"
)

env "${healthcheck_env[@]}" READ_ONLY_API=no \
python3 "$tmp_dir/check_api.py" > "$tmp_dir/writable.out"
grep -Fq 'IB API writable healthcheck ready' "$tmp_dir/writable.out"
grep -Fq 'account_count=1' "$tmp_dir/writable.out"

if env "${healthcheck_env[@]}" READ_ONLY_API=no FAKE_IB_READ_ONLY=1 \
python3 "$tmp_dir/check_api.py" > "$tmp_dir/read-only.out" 2>&1; then
echo "Read-Only Gateway unexpectedly passed writable healthcheck" >&2
exit 1
fi
grep -Fq 'Gateway is in Read-Only mode' "$tmp_dir/read-only.out"

env "${healthcheck_env[@]}" READ_ONLY_API=no FAKE_IB_UNRELATED_321=1 \
python3 "$tmp_dir/check_api.py" > "$tmp_dir/unrelated-321.out"
grep -Fq 'IB API writable healthcheck ready' "$tmp_dir/unrelated-321.out"

env "${healthcheck_env[@]}" READ_ONLY_API=yes FAKE_IB_FAIL_ON_ORDER_ACCESS=1 \
python3 "$tmp_dir/check_api.py" > "$tmp_dir/read-only-expected.out"
grep -Fq 'writable=false' "$tmp_dir/read-only-expected.out"

if env "${healthcheck_env[@]}" IB_GATEWAY_HEALTHCHECK_CLIENT_ID=0 READ_ONLY_API=no \
python3 "$tmp_dir/check_api.py" > "$tmp_dir/client-zero.out" 2>&1; then
echo "clientId=0 unexpectedly passed Gateway healthcheck" >&2
exit 1
fi
grep -Fq 'client ID must be greater than zero' "$tmp_dir/client-zero.out"
Loading