fix(connection): use lightweight heartbeat with single-flight and fail threshold
This commit is contained in:
parent
971f29b059
commit
4e7da0de23
@ -69,6 +69,28 @@ def _is_host_mode_request() -> bool:
|
|||||||
return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host"
|
return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host"
|
||||||
|
|
||||||
|
|
||||||
|
@status_bp.route('/api/health')
|
||||||
|
@api_login_required
|
||||||
|
def get_health():
|
||||||
|
"""轻量健康检查:用于前端连接心跳,避免触发重型 status 计算。"""
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip()
|
||||||
|
username = session.get("username") or "-"
|
||||||
|
payload = {
|
||||||
|
"success": True,
|
||||||
|
"status": "ok",
|
||||||
|
"server_time_ms": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
if request.args.get("diag") == "1" or elapsed_ms >= 800:
|
||||||
|
log_conn_diag(
|
||||||
|
f"health hb={heartbeat_id or '-'} user={username} "
|
||||||
|
f"elapsed_ms={elapsed_ms:.1f} host_mode={bool(session.get('host_mode'))} "
|
||||||
|
f"workspace_id={session.get('workspace_id') or '-'}"
|
||||||
|
)
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
@status_bp.route('/api/status')
|
@status_bp.route('/api/status')
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
|
|||||||
@ -2025,18 +2025,32 @@ export const uiMethods = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async checkConnectionHealth() {
|
async checkConnectionHealth() {
|
||||||
|
if (this.connectionHeartbeatInFlight) {
|
||||||
|
connectionDiag('log', 'health-skip-inflight', {
|
||||||
|
seq: this.connectionHeartbeatSeq,
|
||||||
|
isConnected: !!this.isConnected,
|
||||||
|
failCount: this.connectionHeartbeatFailCount
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connectionHeartbeatInFlight = true;
|
||||||
const seq = Number(this.connectionHeartbeatSeq || 0) + 1;
|
const seq = Number(this.connectionHeartbeatSeq || 0) + 1;
|
||||||
this.connectionHeartbeatSeq = seq;
|
this.connectionHeartbeatSeq = seq;
|
||||||
const requestId = `${Date.now()}-${seq}`;
|
const requestId = `${Date.now()}-${seq}`;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const diagEnabled = isConnectionDiagEnabled();
|
const diagEnabled = isConnectionDiagEnabled();
|
||||||
const statusUrl = diagEnabled ? '/api/status?diag=1' : '/api/status';
|
const healthUrl = diagEnabled ? '/api/health?diag=1' : '/api/health';
|
||||||
const wasConnected = !!this.isConnected;
|
const wasConnected = !!this.isConnected;
|
||||||
let responseStatus: number | null = null;
|
let responseStatus: number | null = null;
|
||||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||||
const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null;
|
const timeoutMs =
|
||||||
|
typeof this.connectionHeartbeatRequestTimeoutMs === 'number' &&
|
||||||
|
this.connectionHeartbeatRequestTimeoutMs > 0
|
||||||
|
? this.connectionHeartbeatRequestTimeoutMs
|
||||||
|
: 5000;
|
||||||
|
const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(statusUrl, {
|
const response = await fetch(healthUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
@ -2066,6 +2080,7 @@ export const uiMethods = {
|
|||||||
failCountBeforeRecover,
|
failCountBeforeRecover,
|
||||||
status: responseStatus,
|
status: responseStatus,
|
||||||
diagEnabled,
|
diagEnabled,
|
||||||
|
endpoint: healthUrl,
|
||||||
conversationId: this.currentConversationId || null,
|
conversationId: this.currentConversationId || null,
|
||||||
taskInProgress: !!this.taskInProgress,
|
taskInProgress: !!this.taskInProgress,
|
||||||
streamingMessage: !!this.streamingMessage,
|
streamingMessage: !!this.streamingMessage,
|
||||||
@ -2083,6 +2098,7 @@ export const uiMethods = {
|
|||||||
seq,
|
seq,
|
||||||
elapsedMs: this.connectionHeartbeatLastLatencyMs,
|
elapsedMs: this.connectionHeartbeatLastLatencyMs,
|
||||||
status: responseStatus,
|
status: responseStatus,
|
||||||
|
endpoint: healthUrl,
|
||||||
visibility: document?.visibilityState || 'unknown'
|
visibility: document?.visibilityState || 'unknown'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -2094,12 +2110,20 @@ export const uiMethods = {
|
|||||||
const errName = error?.name || 'Error';
|
const errName = error?.name || 'Error';
|
||||||
const errMessage = error?.message || String(error);
|
const errMessage = error?.message || String(error);
|
||||||
this.connectionHeartbeatLastError = `${errName}: ${errMessage}`;
|
this.connectionHeartbeatLastError = `${errName}: ${errMessage}`;
|
||||||
// 一次失败就先置灰,避免“服务已断但常亮绿色”的误导
|
const failThreshold =
|
||||||
this.isConnected = false;
|
typeof this.connectionHeartbeatFailThreshold === 'number' &&
|
||||||
if (wasConnected) {
|
this.connectionHeartbeatFailThreshold > 0
|
||||||
|
? this.connectionHeartbeatFailThreshold
|
||||||
|
: 3;
|
||||||
|
const shouldDisconnect = nextFailCount >= failThreshold;
|
||||||
|
// 改为连续失败阈值后再置灰,避免偶发请求超时导致“假断连”
|
||||||
|
if (shouldDisconnect) {
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
if (wasConnected && shouldDisconnect) {
|
||||||
this.connectionHeartbeatLastChangeAt = Date.now();
|
this.connectionHeartbeatLastChangeAt = Date.now();
|
||||||
}
|
}
|
||||||
const shouldLogFail = wasConnected || nextFailCount <= 3 || nextFailCount % 10 === 0;
|
const shouldLogFail = wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0;
|
||||||
connectionDiag(
|
connectionDiag(
|
||||||
shouldLogFail ? 'warn' : 'log',
|
shouldLogFail ? 'warn' : 'log',
|
||||||
'health-failed',
|
'health-failed',
|
||||||
@ -2109,9 +2133,12 @@ export const uiMethods = {
|
|||||||
elapsedMs: this.connectionHeartbeatLastLatencyMs,
|
elapsedMs: this.connectionHeartbeatLastLatencyMs,
|
||||||
failCount: nextFailCount,
|
failCount: nextFailCount,
|
||||||
status: responseStatus,
|
status: responseStatus,
|
||||||
|
endpoint: healthUrl,
|
||||||
diagEnabled,
|
diagEnabled,
|
||||||
wasConnected,
|
wasConnected,
|
||||||
nowConnected: !!this.isConnected,
|
nowConnected: !!this.isConnected,
|
||||||
|
failThreshold,
|
||||||
|
shouldDisconnect,
|
||||||
errorName: errName,
|
errorName: errName,
|
||||||
errorMessage: errMessage,
|
errorMessage: errMessage,
|
||||||
timeout: errName === 'AbortError',
|
timeout: errName === 'AbortError',
|
||||||
@ -2125,6 +2152,7 @@ export const uiMethods = {
|
|||||||
{ force: shouldLogFail }
|
{ force: shouldLogFail }
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
this.connectionHeartbeatInFlight = false;
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,6 +144,9 @@ export function dataState() {
|
|||||||
connectionHeartbeatLastError: '',
|
connectionHeartbeatLastError: '',
|
||||||
connectionHeartbeatLastStatusCode: null,
|
connectionHeartbeatLastStatusCode: null,
|
||||||
connectionHeartbeatLastChangeAt: 0,
|
connectionHeartbeatLastChangeAt: 0,
|
||||||
|
connectionHeartbeatInFlight: false,
|
||||||
|
connectionHeartbeatFailThreshold: 3,
|
||||||
|
connectionHeartbeatRequestTimeoutMs: 5000,
|
||||||
connectionHeartbeatIntervalMs: 8000,
|
connectionHeartbeatIntervalMs: 8000,
|
||||||
connectionHeartbeatDisconnectedIntervalMs: 1000,
|
connectionHeartbeatDisconnectedIntervalMs: 1000,
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user