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
112 changes: 106 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ def _env_flag(name: str) -> bool:
return str(os.getenv(name) or "").strip().lower() in {"1", "true", "yes", "on"}


def _split_env_list(value: str | None) -> tuple[str, ...]:
return tuple(
item.strip()
for item in str(value or "").replace(";", ",").split(",")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve newline-separated crisis chat IDs

When CRISIS_ALERT_TELEGRAM_CHAT_IDS is supplied as newline-separated values, the existing runtime config parser (runtime_config_support.split_env_list) normalizes those into separate recipients, but this new helper only replaces semicolons before splitting on commas. That makes the fallback send one Telegram request with a single chat ID containing newlines, so runtime-entry failure alerts miss all intended crisis chats in that supported configuration; reuse the existing splitter or RUNTIME_SETTINGS.crisis_alert_telegram_chat_ids.

Useful? React with 👍 / 👎.

if item.strip()
)


RUNTIME_SETTINGS = load_platform_runtime_settings(project_id_resolver=get_project_id)
IB_HOST = None
IB_PORT = get_ib_port()
Expand Down Expand Up @@ -523,6 +531,82 @@ def publish_notification(*, detailed_text, compact_text):
)


def _runtime_error_notification_targets() -> tuple[tuple[str, str], ...]:
targets: list[tuple[str, str]] = []
if TG_TOKEN and TG_CHAT_ID:
targets.append((TG_TOKEN, TG_CHAT_ID))
crisis_token = os.getenv("CRISIS_ALERT_TELEGRAM_BOT_TOKEN")
for chat_id in _split_env_list(os.getenv("CRISIS_ALERT_TELEGRAM_CHAT_IDS")):
if crisis_token and chat_id:
targets.append((crisis_token, chat_id))

seen: set[tuple[str, str]] = set()
unique_targets: list[tuple[str, str]] = []
for target in targets:
if target in seen:
continue
seen.add(target)
unique_targets.append(target)
return tuple(unique_targets)


def _runtime_error_notification_message(exc: Exception, *, route_label: str | None = None) -> str:
error_text = f"{type(exc).__name__}: {exc}"
if len(error_text) > 1200:
error_text = error_text[:1197] + "..."
route = route_label or f"{request.method} {request.path}"
return "\n".join(
(
"IBKR strategy run failed",
f"service: {SERVICE_NAME or os.getenv('K_SERVICE', 'interactive-brokers-platform')}",
f"revision: {os.getenv('K_REVISION') or '<unknown>'}",
f"route: {route}",
f"strategy: {STRATEGY_PROFILE}",
f"account_group: {ACCOUNT_GROUP}",
f"error: {error_text}",
)
)


def _notify_runtime_error(exc: Exception, *, route_label: str | None = None) -> bool:
targets = _runtime_error_notification_targets()
if not targets:
print("IBKR runtime error notification skipped: no Telegram target configured.", flush=True)
return False
message = _runtime_error_notification_message(exc, route_label=route_label)
for token, chat_id in targets:
send_telegram_message(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the configured Telegram API endpoint

When a service relies on CRISIS_ALERT_TELEGRAM_API_BASE_URL (the workflow syncs it into Cloud Run in .github/workflows/sync-cloud-run-env.yml:767-768), this fallback bypasses that setting and calls send_telegram_message, which hard-codes https://api.telegram.org in notifications/telegram.py:322. In those egress/proxy deployments, runtime-entry failures now post to the wrong endpoint and the intended alert is not delivered; route this through the configured crisis Telegram settings/adapter instead of the generic sender.

Useful? React with 👍 / 👎.

message,
token=token,
chat_id=chat_id,
requests_module=requests,
)
return True


def _publish_runtime_failure_notification(*, detailed_text: str, compact_text: str, exc: Exception) -> bool:
try:
publish_notification(detailed_text=detailed_text, compact_text=compact_text)
return True
except Exception as notification_exc:
print(f"IBKR runtime error notification fallback: {notification_exc}", flush=True)
return _notify_runtime_error(exc)


def _handle_route_runtime_error(exc: Exception, *, route_label: str | None = None):
print(f"IBKR route failed before strategy-cycle handling: {type(exc).__name__}: {exc}", flush=True)
traceback.print_exc()
_notify_runtime_error(exc, route_label=route_label)
return "Error", 500


def _route_with_runtime_error_fallback(handler, *args, route_label: str | None = None, **kwargs):
try:
return handler(*args, **kwargs)
except Exception as exc:
return _handle_route_runtime_error(exc, route_label=route_label)


def require_gateway_execution_backend():
if EXECUTION_BACKEND == EXECUTION_BACKEND_GATEWAY:
return
Expand Down Expand Up @@ -911,7 +995,11 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body:
error_message=str(exc),
)
error_msg = f"🚨 【IBKR 连接异常】\n{str(exc)}"
publish_notification(detailed_text=error_msg, compact_text=error_msg)
_publish_runtime_failure_notification(
detailed_text=error_msg,
compact_text=error_msg,
exc=exc,
)
return "Error", 500
except Exception as exc:
append_runtime_report_error(
Expand All @@ -930,7 +1018,11 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body:
error_message=str(exc),
)
error_msg = f"{t('error_title')}\n{traceback.format_exc()}"
publish_notification(detailed_text=error_msg, compact_text=error_msg)
_publish_runtime_failure_notification(
detailed_text=error_msg,
compact_text=error_msg,
exc=exc,
)
return "Error", 500
finally:
if lock_acquired:
Expand Down Expand Up @@ -1009,7 +1101,11 @@ def _handle_probe(*, response_body: str = "Probe OK"):
error_message=str(exc),
)
error_msg = f"{t('health_probe_title')}\n{t('health_probe_error_prefix')}{traceback.format_exc()}"
publish_notification(detailed_text=error_msg, compact_text=error_msg)
_publish_runtime_failure_notification(
detailed_text=error_msg,
compact_text=error_msg,
exc=exc,
)
return "Error", 500
finally:
if ib is not None and hasattr(ib, "disconnect"):
Expand All @@ -1027,17 +1123,21 @@ def _handle_probe(*, response_body: str = "Probe OK"):

@app.route("/", methods=["POST", "GET"])
def handle_request():
return _handle_request()
return _route_with_runtime_error_fallback(_handle_request)


@app.route("/precheck", methods=["POST", "GET"])
def handle_precheck():
return _handle_request(dry_run_only_override=True, response_body="Precheck OK")
return _route_with_runtime_error_fallback(
_handle_request,
dry_run_only_override=True,
response_body="Precheck OK",
)


@app.route("/probe", methods=["POST", "GET"])
def handle_probe():
return _handle_probe()
return _route_with_runtime_error_fallback(_handle_probe)


@app.route("/health", methods=["GET"])
Expand Down
27 changes: 27 additions & 0 deletions tests/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,30 @@ def test_global_telegram_chat_id_is_used(strategy_module_factory):
)

assert module.TG_CHAT_ID == "shared-chat-id"


def test_handle_request_runtime_error_fallback_sends_telegram(strategy_module, monkeypatch):
sent_payloads = []

class FakeResponse:
status_code = 200
text = "ok"

def fake_post(_url, *, json, timeout):
sent_payloads.append((json, timeout))
return FakeResponse()

monkeypatch.setattr(strategy_module, "_handle_request", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
monkeypatch.setattr(strategy_module, "TG_TOKEN", "token-1")
monkeypatch.setattr(strategy_module, "TG_CHAT_ID", "chat-1")
monkeypatch.setattr(strategy_module.requests, "post", fake_post)

with strategy_module.app.test_request_context("/", method="POST"):
body, status = strategy_module.handle_request()

assert status == 500
assert body == "Error"
assert len(sent_payloads) == 1
assert sent_payloads[0][0]["chat_id"] == "chat-1"
assert "IBKR strategy run failed" in sent_payloads[0][0]["text"]
assert "RuntimeError: boom" in sent_payloads[0][0]["text"]