Skip to content

Commit df92df2

Browse files
committed
fix: defer canaid.api.local import until first chat turn
Streamlit Cloud's launcher polls /api/v2/app/status every ~300ms while waiting for the script to finish its first run. Our pre-import of canaid.api.local at module load triggered the full chain (boto3 + langchain + langgraph + faiss + langfuse) which appears to exceed the launcher's startup budget on the Cloud container — leaving the page blank with no runtime logs and no error banner. Fix: move the import behind `@st.cache_resource`-cached helper `_lazy_import_run_chat()`. The first page render is now lightweight (stdlib + Streamlit only). The first chat click pays the import cost once with a visible spinner ("Loading chat backend (one-time)…"); subsequent reruns use the cached module. Errors during the lazy import are returned as a traceback string and surface in the chat bubble's existing error path.
1 parent de955d9 commit df92df2

1 file changed

Lines changed: 28 additions & 23 deletions

File tree

src/canaid/ui/streamlit_app.py

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,31 @@
2626

2727
import streamlit as st
2828

29-
# Pre-import the heavy chat machinery at module load so any import failure
30-
# (langchain, faiss, langgraph, etc.) blows up loudly with a visible
31-
# traceback at the top of the page rather than silently inside a generator.
29+
# We deliberately do NOT pre-import canaid.api.local at module load —
30+
# Streamlit Cloud's startup budget appears to be too short for the cumulative
31+
# `boto3 + langchain + langgraph + faiss + langfuse` import chain (a few
32+
# seconds), and exceeding it leaves the launcher in a "polling for ready"
33+
# loop with a blank page. The import is performed lazily on the first chat
34+
# turn via `_lazy_import_run_chat()` below; errors there surface in the chat
35+
# bubble's traceback expander.
36+
_run_chat: Any = None
3237
_BOOT_ERROR: str | None = None
33-
try:
34-
from canaid.api.local import run_chat as _run_chat
35-
except Exception:
36-
_run_chat = None
37-
_BOOT_ERROR = traceback.format_exc()
38+
39+
40+
@st.cache_resource(show_spinner="Loading chat backend (one-time)…")
41+
def _lazy_import_run_chat():
42+
"""Import `canaid.api.local.run_chat` once and cache it.
43+
44+
Streamlit's `cache_resource` keeps the loaded module across reruns of the
45+
script, so the heavy import is paid exactly once per container lifetime.
46+
Returns either the run_chat coroutine function or a string with the
47+
formatted traceback on failure.
48+
"""
49+
try:
50+
from canaid.api.local import run_chat
51+
return run_chat
52+
except Exception:
53+
return traceback.format_exc()
3854

3955
API_URL = os.getenv("CANAID_API_URL", "").strip()
4056
EMBEDDED_MODE = not API_URL
@@ -75,11 +91,9 @@ def _embedded_stream(
7591
Uses a daemon thread running ``asyncio.run`` and bridges frames back via
7692
a Queue — robust under Streamlit's script-runner thread.
7793
"""
78-
if _run_chat is None:
79-
raise RuntimeError(
80-
"canaid.api.local.run_chat could not be imported at boot — "
81-
"see the boot-error banner above for details."
82-
)
94+
run_chat = _lazy_import_run_chat()
95+
if isinstance(run_chat, str):
96+
raise RuntimeError(f"Failed to import canaid.api.local:\n{run_chat}")
8397

8498
q: queue.Queue = queue.Queue(maxsize=64)
8599
DONE = object()
@@ -88,7 +102,7 @@ def _embedded_stream(
88102
def _runner() -> None:
89103
async def _drive() -> None:
90104
try:
91-
async for frame in _run_chat(
105+
async for frame in run_chat(
92106
message, conversation_id=conversation_id
93107
):
94108
q.put(frame)
@@ -151,15 +165,6 @@ def _bridge_streamlit_secrets() -> None:
151165
"Demo build — no real client data."
152166
)
153167

154-
# Boot-error banner — fires only if the heavy import at module load failed.
155-
if _BOOT_ERROR is not None:
156-
st.error(
157-
"**Boot error:** the chat backend could not be imported. "
158-
"Check Streamlit Cloud secrets and dependency versions."
159-
)
160-
with st.expander("Traceback", expanded=True):
161-
st.code(_BOOT_ERROR, language="python")
162-
st.stop()
163168

164169
if "messages" not in st.session_state:
165170
st.session_state.messages = []

0 commit comments

Comments
 (0)