feat(core, cli): add rate limit throttling retry with countdown UI

- Refactor retry utility to support GLM rate limit errors (code 1302) and TPM throttling
- Add getRateLimitRetryInfo() for unified rate-limit error detection
- Add exponential backoff for non-TPM rate limit errors
- Extend StreamEventType.RETRY with RetryInfo payload for UI feedback
- Add RetryCountdownMessage component for visual retry countdown
- Update useGeminiStream hook to handle retry events with countdown timer
- Add i18n support for rate limit messages (en/zh)
This commit is contained in:
yiliang114 2026-02-12 16:21:10 +08:00
parent 2394d732c3
commit 3fb641ca1a
12 changed files with 796 additions and 42 deletions

View file

@ -65,6 +65,7 @@ import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
const debugLogger = createDebugLogger('GEMINI_STREAM');
@ -125,6 +126,13 @@ export const useGeminiStream = (
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [pendingRetryErrorItem, setPendingRetryErrorItem] =
useState<HistoryItemWithoutId | null>(null);
const [pendingRetryCountdownItem, setPendingRetryCountdownItem] =
useState<HistoryItemWithoutId | null>(null);
const retryCountdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const {
startNewPrompt,
@ -189,6 +197,67 @@ export const useGeminiStream = (
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const stopRetryCountdownTimer = useCallback(() => {
if (retryCountdownTimerRef.current) {
clearInterval(retryCountdownTimerRef.current);
retryCountdownTimerRef.current = null;
}
}, []);
const clearRetryCountdown = useCallback(() => {
stopRetryCountdownTimer();
setPendingRetryErrorItem(null);
setPendingRetryCountdownItem(null);
}, [stopRetryCountdownTimer]);
const startRetryCountdown = useCallback(
(retryInfo: {
reason: string;
attempt: number;
maxRetries: number;
delayMs: number;
}) => {
stopRetryCountdownTimer();
const startTime = Date.now();
const { reason, attempt, maxRetries, delayMs } = retryInfo;
// Error line stays static (red with ✕ prefix)
setPendingRetryErrorItem({
type: MessageType.ERROR,
text: t('Rate limit error: {{reason}}', { reason }),
});
// Countdown line updates every second (dim/secondary color)
const updateCountdown = () => {
const elapsedMs = Date.now() - startTime;
const remainingMs = Math.max(0, delayMs - elapsedMs);
const remainingSec = Math.ceil(remainingMs / 1000);
setPendingRetryCountdownItem({
type: 'retry_countdown',
text: t(
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
{
seconds: String(remainingSec),
attempt: String(attempt),
maxRetries: String(maxRetries),
},
),
} as HistoryItemWithoutId);
if (remainingMs <= 0) {
stopRetryCountdownTimer();
}
};
updateCountdown();
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
},
[stopRetryCountdownTimer],
);
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
await done;
@ -295,6 +364,7 @@ export const useGeminiStream = (
Date.now(),
);
setPendingHistoryItem(null);
clearRetryCountdown();
onCancelSubmit();
setIsResponding(false);
setShellInputFocused(false);
@ -305,6 +375,7 @@ export const useGeminiStream = (
onCancelSubmit,
pendingHistoryItemRef,
setShellInputFocused,
clearRetryCountdown,
config,
getPromptCount,
]);
@ -609,10 +680,17 @@ export const useGeminiStream = (
{ type: MessageType.INFO, text: 'User cancelled the request.' },
userMessageTimestamp,
);
clearRetryCountdown();
setIsResponding(false);
setThought(null); // Reset thought when user cancels
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setThought],
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
clearRetryCountdown,
],
);
const handleErrorEvent = useCallback(
@ -631,9 +709,17 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
clearRetryCountdown();
setThought(null); // Reset thought when there's an error
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought],
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
config,
setThought,
clearRetryCountdown,
],
);
const handleCitationEvent = useCallback(
@ -693,8 +779,9 @@ export const useGeminiStream = (
userMessageTimestamp,
);
}
clearRetryCountdown();
},
[addItem],
[addItem, clearRetryCountdown],
);
const handleChatCompressionEvent = useCallback(
@ -853,7 +940,16 @@ export const useGeminiStream = (
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// Will add the missing logic later
// Clear any pending partial content from the failed attempt
if (pendingHistoryItemRef.current) {
setPendingHistoryItem(null);
}
// Show retry info if available (rate-limit / throttling errors)
if (event.retryInfo) {
startRetryCountdown(event.retryInfo);
} else {
clearRetryCountdown();
}
break;
default: {
// enforces exhaustive switch-case
@ -878,7 +974,11 @@ export const useGeminiStream = (
handleMaxSessionTurnsEvent,
handleSessionTokenLimitExceededEvent,
handleCitationEvent,
startRetryCountdown,
clearRetryCountdown,
setThought,
pendingHistoryItemRef,
setPendingHistoryItem,
],
);
@ -1216,10 +1316,18 @@ export const useGeminiStream = (
const pendingHistoryItems = useMemo(
() =>
[pendingHistoryItem, pendingToolCallGroupDisplay].filter(
(i) => i !== undefined && i !== null,
),
[pendingHistoryItem, pendingToolCallGroupDisplay],
[
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
pendingToolCallGroupDisplay,
].filter((i) => i !== undefined && i !== null),
[
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
pendingToolCallGroupDisplay,
],
);
useEffect(() => {