Skip to content

Commit 2bbbf53

Browse files
committed
Notify Telegram on runtime entry failures
1 parent 7769d54 commit 2bbbf53

2 files changed

Lines changed: 146 additions & 8 deletions

File tree

main.py

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from flask import Flask
1212

1313
import google.auth
14+
import requests
1415
from application.runtime_broker_adapters import build_runtime_broker_adapters
1516
from application.runtime_composer import build_runtime_composer
1617
from application.rebalance_service import run_strategy as run_rebalance_cycle
@@ -110,6 +111,14 @@ def t(key, **kwargs):
110111
return build_translator(NOTIFY_LANG)(key, **kwargs)
111112

112113

114+
def _split_env_list(value: str | None) -> tuple[str, ...]:
115+
return tuple(
116+
item.strip()
117+
for item in str(value or "").replace(";", ",").split(",")
118+
if item.strip()
119+
)
120+
121+
113122
signal_text = build_signal_text(t)
114123
strategy_display_name = build_strategy_display_name(t)(
115124
STRATEGY_PROFILE,
@@ -235,6 +244,79 @@ def build_strategy_plugin_alert_messages(signals):
235244
return STRATEGY_ADAPTERS.build_strategy_plugin_alert_messages(signals)
236245

237246

247+
def _runtime_error_notification_targets() -> tuple[tuple[str, str], ...]:
248+
targets: list[tuple[str, str]] = []
249+
if TG_TOKEN and TG_CHAT_ID:
250+
targets.append((TG_TOKEN, TG_CHAT_ID))
251+
crisis_token = os.getenv("CRISIS_ALERT_TELEGRAM_BOT_TOKEN")
252+
for chat_id in _split_env_list(os.getenv("CRISIS_ALERT_TELEGRAM_CHAT_IDS")):
253+
if crisis_token and chat_id:
254+
targets.append((crisis_token, chat_id))
255+
256+
seen: set[tuple[str, str]] = set()
257+
unique_targets: list[tuple[str, str]] = []
258+
for target in targets:
259+
if target in seen:
260+
continue
261+
seen.add(target)
262+
unique_targets.append(target)
263+
return tuple(unique_targets)
264+
265+
266+
def _runtime_error_notification_message(exc: Exception, *, route_label: str) -> str:
267+
error_text = f"{type(exc).__name__}: {exc}"
268+
if len(error_text) > 1200:
269+
error_text = error_text[:1197] + "..."
270+
return "\n".join(
271+
(
272+
"LongBridge strategy run failed",
273+
f"service: {os.getenv('K_SERVICE') or SECRET_NAME or 'longbridge-platform'}",
274+
f"revision: {os.getenv('K_REVISION') or '<unknown>'}",
275+
f"route: {route_label}",
276+
f"strategy: {STRATEGY_PROFILE}",
277+
f"account_scope: {ACCOUNT_REGION}",
278+
f"error: {error_text}",
279+
)
280+
)
281+
282+
283+
def _notify_runtime_error(exc: Exception, *, route_label: str) -> bool:
284+
targets = _runtime_error_notification_targets()
285+
if not targets:
286+
print("LongBridge runtime error notification skipped: no Telegram target configured.", flush=True)
287+
return False
288+
message = _runtime_error_notification_message(exc, route_label=route_label)
289+
for token, chat_id in targets:
290+
try:
291+
requests.post(
292+
f"https://api.telegram.org/bot{token}/sendMessage",
293+
json={"chat_id": chat_id, "text": message},
294+
timeout=10,
295+
)
296+
except Exception as send_exc:
297+
print(f"LongBridge runtime error Telegram send failed: {send_exc}", flush=True)
298+
return True
299+
300+
301+
def _handle_route_runtime_error(exc: Exception, *, route_label: str):
302+
print(f"LongBridge route failed before strategy-cycle handling: {type(exc).__name__}: {exc}", flush=True)
303+
traceback.print_exc()
304+
_notify_runtime_error(exc, route_label=route_label)
305+
return "Error", 500
306+
307+
308+
def _route_with_runtime_error_fallback(handler, *, success_body: str, route_label: str, **kwargs):
309+
try:
310+
result = handler(**kwargs)
311+
except Exception as exc:
312+
return _handle_route_runtime_error(exc, route_label=route_label)
313+
if result is False:
314+
return "Error", 500
315+
if isinstance(result, tuple):
316+
return result
317+
return success_body, 200
318+
319+
238320
def build_strategy_plugin_alert_state_settings():
239321
return StrategyPluginAlertStateSettings.from_env(
240322
gcp_project_id=PROJECT_ID,
@@ -321,7 +403,7 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
321403
},
322404
)
323405
print(composer.with_prefix("Outside market hours; skip."), flush=True)
324-
return
406+
return True
325407
if force_run and not market_open:
326408
reporting_adapters.log_event(
327409
log_context,
@@ -366,6 +448,7 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
366448
"strategy_cycle_completed",
367449
message="Strategy execution completed",
368450
)
451+
return True
369452

370453
except Exception as exc:
371454
append_runtime_report_error(
@@ -388,6 +471,7 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
388471
detailed_text=f"Strategy error:\n{err}",
389472
compact_text=f"{t('error_title')}\n{err}",
390473
)
474+
return False
391475
finally:
392476
try:
393477
report_path = reporting_adapters.persist_execution_report(report)
@@ -487,28 +571,46 @@ def run_probe(*, response_body: str = "Probe OK"):
487571
@app.route("/", methods=["POST", "GET"])
488572
def handle_trigger():
489573
"""Entrypoint for Cloud Run / scheduler: run strategy and return 200."""
490-
run_strategy()
491-
return "OK", 200
574+
return _route_with_runtime_error_fallback(
575+
run_strategy,
576+
success_body="OK",
577+
route_label="POST /",
578+
)
492579

493580

494581
@app.route("/backfill", methods=["POST", "GET"])
495582
def handle_backfill():
496583
"""Manual backfill entrypoint for verification-only execution."""
497-
run_strategy(force_run=True, validation_only=True)
498-
return "OK", 200
584+
return _route_with_runtime_error_fallback(
585+
run_strategy,
586+
force_run=True,
587+
validation_only=True,
588+
success_body="OK",
589+
route_label="POST /backfill",
590+
)
499591

500592

501593
@app.route("/precheck", methods=["POST", "GET"])
502594
def handle_precheck():
503595
"""Pre-market / post-open verification entrypoint for dry-run only execution."""
504-
run_strategy(force_run=True, validation_only=True, validation_label="precheck")
505-
return "Precheck OK", 200
596+
return _route_with_runtime_error_fallback(
597+
run_strategy,
598+
force_run=True,
599+
validation_only=True,
600+
validation_label="precheck",
601+
success_body="Precheck OK",
602+
route_label="POST /precheck",
603+
)
506604

507605

508606
@app.route("/probe", methods=["POST", "GET"])
509607
def handle_probe():
510608
"""Post-open broker/account health probe; notify only on failure."""
511-
return run_probe()
609+
return _route_with_runtime_error_fallback(
610+
run_probe,
611+
success_body="Probe OK",
612+
route_label="POST /probe",
613+
)
512614

513615

514616
if __name__ == "__main__":

tests/test_request_handling.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,42 @@ def fake_run_strategy():
216216
self.assertEqual(body, "OK",)
217217
self.assertTrue(observed["called"])
218218

219+
def test_handle_trigger_returns_500_when_strategy_reports_failure(self):
220+
module = load_module()
221+
module.run_strategy = lambda: False
222+
223+
with module.app.test_request_context("/", method="POST"):
224+
body, status = module.handle_trigger()
225+
226+
self.assertEqual(status, 500)
227+
self.assertEqual(body, "Error")
228+
229+
def test_handle_trigger_runtime_error_fallback_sends_telegram(self):
230+
module = load_module()
231+
observed = {"payloads": []}
232+
233+
class FakeResponse:
234+
status_code = 200
235+
236+
def fake_post(_url, *, json, timeout):
237+
observed["payloads"].append((json, timeout))
238+
return FakeResponse()
239+
240+
module.TG_TOKEN = "token-1"
241+
module.TG_CHAT_ID = "chat-1"
242+
module.requests.post = fake_post
243+
module.run_strategy = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
244+
245+
with module.app.test_request_context("/", method="POST"):
246+
body, status = module.handle_trigger()
247+
248+
self.assertEqual(status, 500)
249+
self.assertEqual(body, "Error")
250+
self.assertEqual(len(observed["payloads"]), 1)
251+
self.assertEqual(observed["payloads"][0][0]["chat_id"], "chat-1")
252+
self.assertIn("LongBridge strategy run failed", observed["payloads"][0][0]["text"])
253+
self.assertIn("RuntimeError: boom", observed["payloads"][0][0]["text"])
254+
219255
def test_handle_trigger_allows_get(self):
220256
module = load_module()
221257
observed = {"called": False}

0 commit comments

Comments
 (0)