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

@ -2296,6 +2296,107 @@ describe('useGeminiStream', () => {
});
});
it('should show a retry countdown and update pending history over time', async () => {
vi.useFakeTimers();
try {
let resolveStream: (() => void) | undefined;
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Retry,
retryInfo: {
reason: 'Rate limit exceeded',
attempt: 1,
maxRetries: 3,
delayMs: 3000,
},
};
await new Promise<void>((resolve) => {
resolveStream = resolve;
});
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
),
);
act(() => {
void result.current.submitQuery('Trigger retry');
});
await act(async () => {
await Promise.resolve();
});
// Error line should be rendered as ERROR type
const errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
expect(errorItem?.text).toContain('Rate limit exceeded');
// Countdown line should be rendered as retry_countdown type
const countdownItem = result.current.pendingHistoryItems.find(
(item) => item.type === ('retry_countdown' as MessageType),
);
expect(countdownItem?.text).toContain('Retrying in 3 seconds');
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const countdownAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === ('retry_countdown' as MessageType),
);
expect(countdownAfterOneSecond?.text).toContain(
'Retrying in 2 seconds',
);
resolveStream?.();
await act(async () => {
await Promise.resolve();
await vi.runAllTimersAsync();
});
// Both error and countdown should be cleared after retry succeeds
const remainingError = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
const remainingCountdown = result.current.pendingHistoryItems.find(
(item) => item.type === ('retry_countdown' as MessageType),
);
expect(remainingError).toBeUndefined();
expect(remainingCountdown).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
[],