fix(serve): import app object before uvicorn startup probe (#1760)

main.py --serve-only passes the app to uvicorn as the import string
"api.app:app", so uvicorn imports the app lazily inside the server
thread. That import (litellm + the full app tree, ~10s+ on constrained
hosts) runs inside the 3.0s startup self-check window, so the probe
times out and --serve-only returns 1, causing a container restart loop
on slower machines (e.g. a 2 vCPU VPS). Faster hosts finish the import
under 3s and pass, which is why the issue was spec-dependent and
long unreported.

Import the ASGI app object in the calling thread before the probe and
hand the object to uvicorn, so the heavy import stays out of the probe
window. Genuine import failures still surface immediately. Update the
start_api_server unit tests to stub api.app the same way they already
stub uvicorn.

Fixes #1759

Co-authored-by: gang.wu@ximalaya.com <gang.wu@ximalaya.com>
Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
LunarFeller
2026-06-22 21:11:38 +08:00
committed by GitHub
parent e4c7ccec40
commit 35943d7fe8
3 changed files with 40 additions and 4 deletions

View File

@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 避免 runtime scheduler 重建定时任务时重复立即运行事件监控,减少重复告警和后台任务状态丢失。
- [修复] Web/API runtime scheduler 接管 `--serve --schedule` 后保留 `--dry-run``--no-notify` 等启动参数语义。
- [改进] Web 历史报告详情不再内嵌展示 AI 建议卡片,结构化决策信号集中在 AI 建议页查询,并保留按来源报告 ID 筛选或 URL 参数精确定位入口。
- [修复] `main.py --serve-only` 在低配主机上因 uvicorn 在 3.0s 启动自检窗口内才惰性 import 应用litellm + 整个 app 树)导致超时退出、容器反复重启;改为在计时前于调用线程预先 import app 对象再交给 uvicorn启动自检不再误杀慢启动。
- [修复] Docker 镜像预置 efinance 缓存目录efinance/data属主给非 root 运行用户 dsa修复 A 股 efinance 数据源因写 search-cache.json 触发 PermissionError 而每次抓取失败降级的问题。
- [修复] Docker 部署中 Web 设置页保存自定义 Webhook 模板时自动转义 `$content_json` 等应用占位符,并在运行时还原,避免 Compose 重新部署将其展开为空。

13
main.py
View File

@@ -1033,9 +1033,18 @@ def start_api_server(host: str, port: int, config: Config) -> None:
"log_level": level_name,
"log_config": None,
}
# Import the ASGI app object in the calling thread instead of handing uvicorn
# the "api.app:app" import string. With the string, uvicorn imports the app
# lazily inside the server thread, and that import (litellm + the full app
# tree, ~10s+ on constrained hosts) runs inside the startup probe window
# below, tripping the 3.0s timeout and causing a restart loop on slower
# machines. Importing first keeps the heavy work out of the probe window;
# genuine import failures still surface immediately to the caller.
from api.app import app as fastapi_app
try:
uvicorn_config = uvicorn.Config(
"api.app:app",
fastapi_app,
install_signal_handlers=False,
**uvicorn_kwargs,
)
@@ -1045,7 +1054,7 @@ def start_api_server(host: str, port: int, config: Config) -> None:
# when it's a boolean flag.
use_config_signal_handlers = False
uvicorn_config = uvicorn.Config(
"api.app:app",
fastapi_app,
**uvicorn_kwargs,
)
uvicorn_server = uvicorn.Server(config=uvicorn_config)

View File

@@ -28,6 +28,23 @@ _MAIN_IMPORT_ENV_OVERRIDES = {
}
def _api_app_stub_modules():
"""sys.modules entries so ``start_api_server`` can ``from api.app import app``
without importing the real (heavy) app tree in these isolated unit tests.
``start_api_server`` imports the ASGI app object in the calling thread so the
import stays out of the uvicorn startup probe window; these control-flow tests
stub it the same way they already stub uvicorn.
"""
import types
api_pkg = types.ModuleType("api")
api_app_mod = types.ModuleType("api.app")
api_app_mod.app = SimpleNamespace()
api_pkg.app = api_app_mod
return {"api": api_pkg, "api.app": api_app_mod}
class _DummyConfig(SimpleNamespace):
def validate(self):
return []
@@ -174,7 +191,10 @@ class MainScheduleModeTestCase(unittest.TestCase):
pass
with patch("socket.socket", return_value=_UnusedSocket()), \
patch.dict("sys.modules", {"uvicorn": _FakeUvicornModule()}):
patch.dict(
"sys.modules",
{"uvicorn": _FakeUvicornModule(), **_api_app_stub_modules()},
):
with self.assertRaises(RuntimeError) as caught:
main.start_api_server("127.0.0.1", 8000, config)
@@ -212,7 +232,13 @@ class MainScheduleModeTestCase(unittest.TestCase):
pass
with patch("socket.socket", return_value=_UnusedSocket()), \
patch.dict("sys.modules", {"uvicorn": SimpleNamespace(Config=_CompatConfig, Server=_CompatServer)}):
patch.dict(
"sys.modules",
{
"uvicorn": SimpleNamespace(Config=_CompatConfig, Server=_CompatServer),
**_api_app_stub_modules(),
},
):
main.start_api_server("127.0.0.1", 8000, config)
self.assertIsNotNone(_CompatServer.instance)