Skip to content

Commit 46eae69

Browse files
committed
fix: harden chat timeout handling
1 parent e388090 commit 46eae69

4 files changed

Lines changed: 94 additions & 40 deletions

File tree

backend/main.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ def _resolve_data_asset(filename: str) -> Path:
6464
# Frontend should stay on ai.bdfz.net, but the current VPS reaches the same Worker more reliably via workers.dev.
6565
AI_SERVICE_URL = os.getenv("AI_SERVICE_URL", "https://apis.bdfz.workers.dev/")
6666
AI_SERVICE_LABEL = os.getenv("AI_SERVICE_LABEL", "Gemini")
67-
AI_SERVICE_TIMEOUT = float(os.getenv("AI_SERVICE_TIMEOUT_SEC", "25"))
67+
AI_SERVICE_TIMEOUT = float(os.getenv("AI_SERVICE_TIMEOUT_SEC", "35"))
68+
AI_SERVICE_RETRIES = max(0, int(os.getenv("AI_SERVICE_RETRIES", "1")))
69+
AI_SERVICE_RETRY_DELAY = max(0.0, float(os.getenv("AI_SERVICE_RETRY_DELAY_SEC", "0.8")))
6870
AI_SERVICE_MODEL = os.getenv("AI_SERVICE_MODEL", "gemini-flash-latest").strip() or "gemini-flash-latest"
6971
AI_SERVICE_ORIGIN = os.getenv("AI_SERVICE_ORIGIN", "https://sun.bdfz.net").rstrip("/")
7072
AI_SERVICE_REFERER = os.getenv("AI_SERVICE_REFERER", f"{AI_SERVICE_ORIGIN}/")
@@ -971,22 +973,48 @@ def _call_ai_service(prompt: str) -> dict:
971973
if AI_INTERNAL_TOKEN:
972974
headers["X-Internal-Token"] = AI_INTERNAL_TOKEN
973975

974-
request = urllib.request.Request(
975-
AI_SERVICE_URL,
976-
data=payload,
977-
headers=headers,
978-
method="POST",
979-
)
980-
try:
981-
with urllib.request.urlopen(request, timeout=AI_SERVICE_TIMEOUT) as response:
982-
raw = response.read().decode("utf-8")
983-
except urllib.error.HTTPError as e:
984-
detail = e.read().decode("utf-8", errors="ignore")[:400]
985-
raise HTTPException(502, f"AI service http error: {e.code} {detail}") from e
986-
except urllib.error.URLError as e:
987-
raise HTTPException(502, f"AI service unavailable: {e.reason}") from e
988-
except TimeoutError as e:
989-
raise HTTPException(504, "AI service timeout") from e
976+
last_http_error: Optional[tuple[int, str]] = None
977+
last_network_error: Optional[str] = None
978+
timeout_hit = False
979+
980+
for attempt in range(AI_SERVICE_RETRIES + 1):
981+
request = urllib.request.Request(
982+
AI_SERVICE_URL,
983+
data=payload,
984+
headers=headers,
985+
method="POST",
986+
)
987+
try:
988+
with urllib.request.urlopen(request, timeout=AI_SERVICE_TIMEOUT) as response:
989+
raw = response.read().decode("utf-8")
990+
break
991+
except urllib.error.HTTPError as e:
992+
detail = e.read().decode("utf-8", errors="ignore")[:400]
993+
last_http_error = (e.code, detail)
994+
if e.code >= 500 and attempt < AI_SERVICE_RETRIES:
995+
time.sleep(AI_SERVICE_RETRY_DELAY)
996+
continue
997+
raise HTTPException(502, f"AI service http error: {e.code} {detail}") from e
998+
except urllib.error.URLError as e:
999+
last_network_error = str(e.reason)
1000+
if attempt < AI_SERVICE_RETRIES:
1001+
time.sleep(AI_SERVICE_RETRY_DELAY)
1002+
continue
1003+
raise HTTPException(502, f"AI service unavailable: {e.reason}") from e
1004+
except TimeoutError as e:
1005+
timeout_hit = True
1006+
if attempt < AI_SERVICE_RETRIES:
1007+
time.sleep(AI_SERVICE_RETRY_DELAY)
1008+
continue
1009+
raise HTTPException(504, "AI service timeout") from e
1010+
else:
1011+
if last_http_error:
1012+
raise HTTPException(502, f"AI service http error: {last_http_error[0]} {last_http_error[1]}")
1013+
if last_network_error:
1014+
raise HTTPException(502, f"AI service unavailable: {last_network_error}")
1015+
if timeout_hit:
1016+
raise HTTPException(504, "AI service timeout")
1017+
raise HTTPException(502, "AI service unavailable")
9901018

9911019
try:
9921020
data = json.loads(raw)

frontend/assets/app.js

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ const API = ''; // same origin
44
// Canonical external AI gateway for this project: Worker custom domain -> service `apis` / production.
55
const AI_API = 'https://ai.bdfz.net/';
66
const AI_CHAT_MODEL = 'gemini-flash-latest';
7-
const AI_REQUEST_TIMEOUT_MS = 20000;
7+
const AI_CONTEXT_TIMEOUT_MS = 15000;
8+
const AI_SERVER_CHAT_TIMEOUT_MS = 45000;
9+
const AI_DIRECT_REQUEST_TIMEOUT_MS = 25000;
10+
const AI_SERVER_CHAT_RETRIES = 1;
811
const IMG_CDN = 'https://img.rdfzer.com';
9-
const DEFAULT_FRONTEND_VERSION = '2026.03.06-r11';
12+
const DEFAULT_FRONTEND_VERSION = '2026.03.06-r12';
1013
const FRONTEND_VERSION_FILE = '/assets/version.json';
1114
const AI_MEMORY_LIMIT = 12;
1215
const DOWNLOADABLE_LIBRARY_BOOKS = 316;
@@ -59,7 +62,7 @@ function logClientAIChat({ query, userMessage, summary, provider, success, error
5962
}).catch(() => {});
6063
}
6164

62-
async function fetchWithTimeout(url, options = {}, timeoutMs = AI_REQUEST_TIMEOUT_MS) {
65+
async function fetchWithTimeout(url, options = {}, timeoutMs = AI_SERVER_CHAT_TIMEOUT_MS) {
6366
const controller = new AbortController();
6467
const timer = setTimeout(() => controller.abort(), timeoutMs);
6568
try {
@@ -131,14 +134,37 @@ async function fetchAIContext(userMessage, history) {
131134
user_message: userMessage,
132135
history,
133136
}),
134-
});
137+
}, AI_CONTEXT_TIMEOUT_MS);
135138
const { data, raw } = await readJsonLike(res);
136139
if (!res.ok || !data) {
137140
throw new Error(responseErrorMessage(res, data, raw, '上下文构建失败'));
138141
}
139142
return data;
140143
}
141144

145+
async function requestServerChat(payload) {
146+
let lastError = null;
147+
for (let attempt = 0; attempt <= AI_SERVER_CHAT_RETRIES; attempt += 1) {
148+
try {
149+
const res = await fetchWithTimeout(`${API}/api/chat`, {
150+
method: 'POST',
151+
headers: { 'Content-Type': 'application/json' },
152+
body: JSON.stringify(payload),
153+
}, AI_SERVER_CHAT_TIMEOUT_MS);
154+
const { data, raw } = await readJsonLike(res);
155+
if (!res.ok || !data) {
156+
throw new Error(responseErrorMessage(res, data, raw, 'AI 对话失败'));
157+
}
158+
return data;
159+
} catch (error) {
160+
lastError = error;
161+
if (attempt >= AI_SERVER_CHAT_RETRIES) break;
162+
await new Promise(resolve => setTimeout(resolve, 350));
163+
}
164+
}
165+
throw lastError || new Error('AI 对话失败');
166+
}
167+
142168
function buildClientAIPrompt(userMessage, contextPayload, history) {
143169
const historyText = (contextPayload.history_text || '').trim()
144170
|| history.map(msg => `${msg.role === 'user' ? '用户' : '助手'}: ${msg.content}`).join('\n')
@@ -324,19 +350,11 @@ async function sendAIMessage(userMessage) {
324350
let usedBrowserFallback = false;
325351

326352
try {
327-
const res = await fetchWithTimeout(`${API}/api/chat`, {
328-
method: 'POST',
329-
headers: { 'Content-Type': 'application/json' },
330-
body: JSON.stringify({
331-
query: currentQuery,
332-
user_message: cleanMessage,
333-
history,
334-
}),
353+
const data = await requestServerChat({
354+
query: currentQuery,
355+
user_message: cleanMessage,
356+
history,
335357
});
336-
const { data, raw } = await readJsonLike(res);
337-
if (!res.ok || !data) {
338-
throw new Error(responseErrorMessage(res, data, raw, 'AI 对话失败'));
339-
}
340358
answer = data.answer || '';
341359
contextPayload = data.context || {};
342360
setSearchAIProviderLabel(data.provider || aiProviderLabel);
@@ -353,7 +371,7 @@ async function sendAIMessage(userMessage) {
353371
taskType: 'chat',
354372
thinkingLevel: 'low',
355373
}),
356-
});
374+
}, AI_DIRECT_REQUEST_TIMEOUT_MS);
357375
const { data: aiData, raw: aiRaw } = await readJsonLike(aiRes);
358376
if (!aiRes.ok || !aiData?.answer) {
359377
throw new Error(responseErrorMessage(aiRes, aiData, aiRaw, serverChatError.message || 'AI 服务错误'));
@@ -438,19 +456,17 @@ async function loadFrontendVersion() {
438456
if (!footer) return;
439457

440458
let version = DEFAULT_FRONTEND_VERSION;
441-
let updatedAt = '';
442459
try {
443460
const res = await fetch(`${FRONTEND_VERSION_FILE}?v=${Date.now()}`, { cache: 'no-store' });
444461
if (res.ok) {
445462
const data = await res.json();
446463
version = data.frontend_refactor_version || version;
447-
updatedAt = data.updated_at || '';
448464
}
449465
} catch (_) {
450466
// fallback to default version
451467
}
452468

453-
footer.textContent = `重构版本 ${version}${updatedAt ? ` · ${updatedAt}` : ''}`;
469+
footer.textContent = `重构版本 ${version}`;
454470
}
455471

456472
loadFrontendVersion();
@@ -1850,7 +1866,7 @@ ${crossSubjectContext || '(未找到直接跨学科教材)'}
18501866
taskType: 'chat',
18511867
thinkingLevel: 'low',
18521868
}),
1853-
});
1869+
}, AI_DIRECT_REQUEST_TIMEOUT_MS);
18541870
const { data: aiData, raw: aiRaw } = await readJsonLike(aiRes);
18551871
if (!aiRes.ok || !aiData) {
18561872
throw new Error(responseErrorMessage(aiRes, aiData, aiRaw, 'AI 服务错误'));
@@ -1907,6 +1923,11 @@ function renderMath(el) {
19071923
{ left: '\\[', right: '\\]', display: true }
19081924
],
19091925
throwOnError: false,
1926+
strict: (errorCode) => (
1927+
errorCode === 'unicodeTextInMathMode' || errorCode === 'mathVsTextAccents'
1928+
? 'ignore'
1929+
: 'warn'
1930+
),
19101931
errorColor: '#e74c3c'
19111932
});
19121933
}

frontend/assets/version.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
{
2-
"frontend_refactor_version": "2026.03.06-r11",
2+
"frontend_refactor_version": "2026.03.06-r12",
33
"updated_at": "2026-03-06",
44
"history": [
5+
{
6+
"version": "2026.03.06-r12",
7+
"date": "2026-03-06",
8+
"summary": "AI chat now gives the server path a longer timeout and one retry before browser fallback; footer version text no longer appends the date"
9+
},
510
{
611
"version": "2026.03.06-r11",
712
"date": "2026-03-06",

frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ <h2>🔗 知识图谱</h2>
268268
</div>
269269
</footer>
270270
</div>
271-
<script src="assets/app.js?v=20260306k"></script>
271+
<script src="assets/app.js?v=20260306m"></script>
272272
</body>
273273

274274
</html>

0 commit comments

Comments
 (0)