Refactor chat history loading to implement retry logic for gateway startup errors. Update retry delays and enhance error classification for improved handling of initialization issues.

This commit is contained in:
Haze
2026-04-13 18:09:52 +08:00
parent 5b8a35391d
commit 09aa7c2e52
5 changed files with 111 additions and 62 deletions

View File

@@ -79,7 +79,6 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",

View File

@@ -1129,11 +1129,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
} }
// Background: fetch first user message for every non-main session to populate labels upfront. // Background: fetch first user message for every non-main session to populate labels upfront.
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything. // Retries on "gateway startup" errors since the gateway may still be initializing.
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main')); const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
if (sessionsToLabel.length > 0) { if (sessionsToLabel.length > 0) {
void Promise.all( const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
sessionsToLabel.map(async (session) => { void (async () => {
let pending = sessionsToLabel;
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
const failed: typeof pending = [];
await Promise.all(
pending.map(async (session) => {
try { try {
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>( const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history', 'chat.history',
@@ -1156,11 +1161,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
} }
return next; return next;
}); });
} catch { } catch (err) {
// ignore per-session errors if (classifyHistoryStartupRetryError(err) === 'gateway_startup') {
failed.push(session);
}
} }
}), }),
); );
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
await sleep(LABEL_RETRY_DELAYS[attempt]!);
pending = failed;
}
})();
} }
} }
} catch (err) { } catch (err) {

View File

@@ -253,10 +253,12 @@ export function createHistoryActions(
return; return;
} }
if (isCurrentSession() && isInitialForegroundLoad && classifyHistoryStartupRetryError(lastError)) { const errorKind = classifyHistoryStartupRetryError(lastError);
if (isCurrentSession() && isInitialForegroundLoad && errorKind) {
console.warn('[chat.history] startup retry exhausted', { console.warn('[chat.history] startup retry exhausted', {
sessionKey: currentSessionKey, sessionKey: currentSessionKey,
gatewayState: useGatewayStore.getState().status.state, gatewayState: useGatewayStore.getState().status.state,
errorKind,
error: String(lastError), error: String(lastError),
}); });
} }
@@ -267,6 +269,11 @@ export function createHistoryActions(
if (applied && isInitialForegroundLoad) { if (applied && isInitialForegroundLoad) {
foregroundHistoryLoadSeen.add(currentSessionKey); foregroundHistoryLoadSeen.add(currentSessionKey);
} }
} else if (errorKind === 'gateway_startup') {
// Suppress error UI for gateway startup -- the history will load
// once the gateway finishes initializing (via sidebar refresh or
// the next session switch).
set({ loading: false });
} else { } else {
applyLoadFailure( applyLoadFailure(
result?.error result?.error

View File

@@ -1,8 +1,8 @@
import type { GatewayStatus } from '@/types/gateway'; import type { GatewayStatus } from '@/types/gateway';
export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000; export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000;
export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [600] as const; export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [800, 2_000, 4_000, 8_000] as const;
export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 15_000; export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 30_000;
export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS = export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS =
CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS; CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS;
export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000; export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000;
@@ -11,11 +11,20 @@ export const CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS =
+ CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0) + CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0)
+ 2_000; + 2_000;
export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable'; export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable' | 'gateway_startup';
export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null { export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null {
const message = String(error).toLowerCase(); const message = String(error).toLowerCase();
if (
message.includes('unavailable during gateway startup')
|| message.includes('unavailable during startup')
|| message.includes('not yet ready')
|| message.includes('service not initialized')
) {
return 'gateway_startup';
}
if ( if (
message.includes('rpc timeout: chat.history') message.includes('rpc timeout: chat.history')
|| message.includes('gateway rpc timeout: chat.history') || message.includes('gateway rpc timeout: chat.history')
@@ -47,6 +56,11 @@ export function shouldRetryStartupHistoryLoad(
): boolean { ): boolean {
if (!gatewayStatus || !errorKind) return false; if (!gatewayStatus || !errorKind) return false;
// The gateway explicitly told us it's still initializing -- always retry
if (errorKind === 'gateway_startup') {
return true;
}
if (gatewayStatus.state === 'starting') { if (gatewayStatus.state === 'starting') {
return true; return true;
} }

View File

@@ -1,5 +1,6 @@
import { invokeIpc } from '@/lib/api-client'; import { invokeIpc } from '@/lib/api-client';
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'; import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { classifyHistoryStartupRetryError, sleep } from './history-startup-retry';
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types'; import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
@@ -111,18 +112,29 @@ export function createSessionActions(
} }
// Background: fetch first user message for every non-main session to populate labels upfront. // Background: fetch first user message for every non-main session to populate labels upfront.
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything. // Retries on "gateway startup" errors since the gateway may still be initializing.
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main')); const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
if (sessionsToLabel.length > 0) { if (sessionsToLabel.length > 0) {
void Promise.all( const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
sessionsToLabel.map(async (session) => { void (async () => {
let pending = sessionsToLabel;
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
const failed: typeof pending = [];
await Promise.all(
pending.map(async (session) => {
try { try {
const r = await invokeIpc( const r = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'chat.history', 'chat.history',
{ sessionKey: session.key, limit: 1000 }, { sessionKey: session.key, limit: 1000 },
) as { success: boolean; result?: Record<string, unknown> }; ) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (!r.success || !r.result) return; if (!r.success) {
if (classifyHistoryStartupRetryError(r.error) === 'gateway_startup') {
failed.push(session);
}
return;
}
if (!r.result) return;
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : []; const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
const firstUser = msgs.find((m) => m.role === 'user'); const firstUser = msgs.find((m) => m.role === 'user');
const lastMsg = msgs[msgs.length - 1]; const lastMsg = msgs[msgs.length - 1];
@@ -143,6 +155,11 @@ export function createSessionActions(
} catch { /* ignore per-session errors */ } } catch { /* ignore per-session errors */ }
}), }),
); );
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
await sleep(LABEL_RETRY_DELAYS[attempt]!);
pending = failed;
}
})();
} }
} }
} catch (err) { } catch (err) {