Fix rate limit wait display (#10389)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
Hannes Rudolph 2025-12-30 22:56:32 -07:00 committed by GitHub
parent c37aa02b21
commit ca1bc18a21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 218 additions and 24 deletions

View file

@ -301,6 +301,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
"diff_error",
"condense_context",
"condense_context_error",
"api_req_rate_limit_wait",
"api_req_retry_delayed",
"api_req_retried",
]

View file

@ -129,6 +129,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
* - `api_req_finished`: Indicates an API request has completed successfully
* - `api_req_retried`: Indicates an API request is being retried after a failure
* - `api_req_retry_delayed`: Indicates an API request retry has been delayed
* - `api_req_rate_limit_wait`: Indicates a configured rate-limit wait (not an error)
* - `api_req_deleted`: Indicates an API request has been deleted/cancelled
* - `text`: General text message or assistant response
* - `reasoning`: Assistant's reasoning or thought process (often hidden from user)
@ -155,6 +156,7 @@ export const clineSays = [
"api_req_finished",
"api_req_retried",
"api_req_retry_delayed",
"api_req_rate_limit_wait",
"api_req_deleted",
"text",
"image",

View file

@ -2347,6 +2347,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const modelId = getModelId(this.apiConfiguration)
const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId)
// Respect user-configured provider rate limiting BEFORE we emit api_req_started.
// This prevents the UI from showing an "API Request..." spinner while we are
// intentionally waiting due to the rate limit slider.
//
// NOTE: We also set Task.lastGlobalApiRequestTime here to reserve this slot
// before we build environment details (which can take time).
// This ensures subsequent requests (including subtasks) still honour the
// provider rate-limit window.
await this.maybeWaitForProviderRateLimit(currentItem.retryAttempt ?? 0)
Task.lastGlobalApiRequestTime = performance.now()
await this.say(
"api_req_started",
JSON.stringify({
@ -2554,7 +2565,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Yields only if the first chunk is successful, otherwise will
// allow the user to retry the request (most likely due to rate
// limit error, which gets thrown on the first chunk).
const stream = this.attemptApiRequest()
const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true })
let assistantMessage = ""
let reasoningMessage = ""
let pendingGroundingSources: GroundingSource[] = []
@ -3656,7 +3667,44 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId })
}
public async *attemptApiRequest(retryAttempt: number = 0): ApiStream {
/**
* Enforce the user-configured provider rate limit.
*
* NOTE: This is intentionally treated as expected behavior and is surfaced via
* the `api_req_rate_limit_wait` say type (not an error).
*/
private async maybeWaitForProviderRateLimit(retryAttempt: number): Promise<void> {
const state = await this.providerRef.deref()?.getState()
const rateLimitSeconds =
state?.apiConfiguration?.rateLimitSeconds ?? this.apiConfiguration?.rateLimitSeconds ?? 0
if (rateLimitSeconds <= 0 || !Task.lastGlobalApiRequestTime) {
return
}
const now = performance.now()
const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime
const rateLimitDelay = Math.ceil(
Math.min(rateLimitSeconds, Math.max(0, rateLimitSeconds * 1000 - timeSinceLastRequest) / 1000),
)
// Only show the countdown UX on the first attempt. Retry flows have their own delay messaging.
if (rateLimitDelay > 0 && retryAttempt === 0) {
for (let i = rateLimitDelay; i > 0; i--) {
// Send structured JSON data for i18n-safe transport
const delayMessage = JSON.stringify({ seconds: i })
await this.say("api_req_rate_limit_wait", delayMessage, undefined, true)
await delay(1000)
}
// Finalize the partial message so the UI doesn't keep rendering an in-progress spinner.
await this.say("api_req_rate_limit_wait", undefined, undefined, false)
}
}
public async *attemptApiRequest(
retryAttempt: number = 0,
options: { skipProviderRateLimit?: boolean } = {},
): ApiStream {
const state = await this.providerRef.deref()?.getState()
const {
@ -3693,29 +3741,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
let rateLimitDelay = 0
// Use the shared timestamp so that subtasks respect the same rate-limit
// window as their parent tasks.
if (Task.lastGlobalApiRequestTime) {
const now = performance.now()
const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime
const rateLimit = apiConfiguration?.rateLimitSeconds || 0
rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - timeSinceLastRequest) / 1000))
if (!options.skipProviderRateLimit) {
await this.maybeWaitForProviderRateLimit(retryAttempt)
}
// Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there.
if (rateLimitDelay > 0 && retryAttempt === 0) {
// Show countdown timer
for (let i = rateLimitDelay; i > 0; i--) {
const delayMessage = `Rate limiting for ${i} seconds...`
await this.say("api_req_retry_delayed", delayMessage, undefined, true)
await delay(1000)
}
}
// Update last request time before making the request so that subsequent
// Update last request time right before making the request so that subsequent
// requests — even from new subtasks — will honour the provider's rate-limit.
//
// NOTE: When recursivelyMakeClineRequests handles rate limiting, it sets the
// timestamp earlier to include the environment details build. We still set it
// here for direct callers (tests) and for the case where we didn't rate-limit
// in the caller.
Task.lastGlobalApiRequestTime = performance.now()
const systemPrompt = await this.getSystemPrompt()

View file

@ -1041,6 +1041,9 @@ describe("Cline", () => {
startTask: false,
})
// Spy on child.say to verify the emitted message type
const saySpy = vi.spyOn(child, "say")
// Mock the child's API stream
const childMockStream = {
async *[Symbol.asyncIterator]() {
@ -1067,6 +1070,17 @@ describe("Cline", () => {
// Verify rate limiting was applied
expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
expect(mockDelay).toHaveBeenCalledWith(1000)
// Verify we used the non-error rate-limit wait message type (JSON format)
expect(saySpy).toHaveBeenCalledWith(
"api_req_rate_limit_wait",
expect.stringMatching(/\{"seconds":\d+\}/),
undefined,
true,
)
// Verify the wait message was finalized
expect(saySpy).toHaveBeenCalledWith("api_req_rate_limit_wait", undefined, undefined, false)
}, 10000) // Increase timeout to 10 seconds
it("should not apply rate limiting if enough time has passed", async () => {

View file

@ -298,6 +298,8 @@ export const ChatRowContent = ({
style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: successColor, fontWeight: "bold" }}>{t("chat:taskCompleted")}</span>,
]
case "api_req_rate_limit_wait":
return []
case "api_req_retry_delayed":
return []
case "api_req_started":
@ -327,8 +329,10 @@ export const ChatRowContent = ({
getIconSpan("arrow-swap", normalColor)
) : apiRequestFailedMessage ? (
getIconSpan("error", errorColor)
) : (
) : isLast ? (
<ProgressIndicator />
) : (
getIconSpan("arrow-swap", normalColor)
),
apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
apiReqCancelReason === "user_cancelled" ? (
@ -356,7 +360,17 @@ export const ChatRowContent = ({
default:
return [null, null]
}
}, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t])
}, [
type,
isCommandExecuting,
message,
isMcpServerResponding,
apiReqCancelReason,
cost,
apiRequestFailedMessage,
t,
isLast,
])
const headerStyle: React.CSSProperties = {
display: "flex",
@ -1149,6 +1163,35 @@ export const ChatRowContent = ({
errorDetails={rawError}
/>
)
case "api_req_rate_limit_wait": {
const isWaiting = message.partial === true
const waitSeconds = (() => {
if (!message.text) return undefined
try {
const data = JSON.parse(message.text)
return typeof data.seconds === "number" ? data.seconds : undefined
} catch {
return undefined
}
})()
return isWaiting && waitSeconds !== undefined ? (
<div
className={`group text-sm transition-opacity opacity-100`}
style={{
...headerStyle,
marginBottom: 0,
justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexGrow: 1 }}>
<ProgressIndicator />
<span style={{ color: normalColor }}>{t("chat:apiRequest.rateLimitWait")}</span>
</div>
<span className="text-xs font-light text-vscode-descriptionForeground">{waitSeconds}s</span>
</div>
) : null
}
case "api_req_finished":
return null // we should never see this message type
case "text":

View file

@ -400,6 +400,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// an "ask" while ask is waiting for response.
switch (lastMessage.say) {
case "api_req_retry_delayed":
case "api_req_rate_limit_wait":
setSendingDisabled(true)
break
case "api_req_started":
@ -957,6 +958,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "api_req_deleted":
return false
case "api_req_retry_delayed":
case "api_req_rate_limit_wait":
const last1 = modifiedMessages.at(-1)
const last2 = modifiedMessages.at(-2)
if (last1?.ask === "resume_task" && last2 === message) {

View file

@ -0,0 +1,78 @@
import React from "react"
import { render, screen } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
import { ChatRowContent } from "../ChatRow"
// Mock i18n
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const map: Record<string, string> = {
"chat:apiRequest.rateLimitWait": "Rate limiting",
}
return map[key] ?? key
},
}),
Trans: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
initReactI18next: { type: "3rdParty", init: () => {} },
}))
const queryClient = new QueryClient()
function renderChatRow(message: any) {
return render(
<ExtensionStateContextProvider>
<QueryClientProvider client={queryClient}>
<ChatRowContent
message={message}
isExpanded={false}
isLast={false}
isStreaming={false}
onToggleExpand={() => {}}
onSuggestionClick={() => {}}
onBatchFileResponse={() => {}}
onFollowUpUnmount={() => {}}
isFollowUpAnswered={false}
/>
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
}
describe("ChatRow - rate limit wait", () => {
it("renders a non-error progress row for api_req_rate_limit_wait", () => {
const message: any = {
type: "say",
say: "api_req_rate_limit_wait",
ts: Date.now(),
partial: true,
text: JSON.stringify({ seconds: 1 }),
}
renderChatRow(message)
expect(screen.getByText("Rate limiting")).toBeInTheDocument()
// Should show countdown, but should NOT show the error-details affordance.
expect(screen.getByText("1s")).toBeInTheDocument()
expect(screen.queryByText("Details")).toBeNull()
})
it("renders nothing when rate limit wait is complete", () => {
const message: any = {
type: "say",
say: "api_req_rate_limit_wait",
ts: Date.now(),
partial: false,
text: undefined,
}
const { container } = renderChatRow(message)
// The row should be hidden when rate limiting is complete
expect(screen.queryByText("Rate limiting")).toBeNull()
// Nothing should be rendered
expect(container.firstChild).toBeNull()
})
})

View file

@ -138,6 +138,7 @@
"streaming": "Sol·licitud API...",
"cancelled": "Sol·licitud API cancel·lada",
"streamingFailed": "Transmissió API ha fallat",
"rateLimitWait": "Limitació de taxa",
"errorTitle": "Error de proveïdor {{code}}",
"errorMessage": {
"docs": "Documentació",

View file

@ -138,6 +138,7 @@
"streaming": "API-Anfrage...",
"cancelled": "API-Anfrage abgebrochen",
"streamingFailed": "API-Streaming fehlgeschlagen",
"rateLimitWait": "Ratenbegrenzung",
"errorTitle": "Anbieter-Fehler {{code}}",
"errorMessage": {
"docs": "Dokumentation",

View file

@ -144,6 +144,7 @@
"streaming": "API Request...",
"cancelled": "API Request Cancelled",
"streamingFailed": "API Streaming Failed",
"rateLimitWait": "Rate limiting",
"errorTitle": "Provider Error {{code}}",
"errorMessage": {
"docs": "Docs",

View file

@ -138,6 +138,7 @@
"streaming": "Solicitud API...",
"cancelled": "Solicitud API cancelada",
"streamingFailed": "Transmisión API falló",
"rateLimitWait": "Limitación de tasa",
"errorTitle": "Error del proveedor {{code}}",
"errorMessage": {
"docs": "Documentación",

View file

@ -138,6 +138,7 @@
"streaming": "Requête API...",
"cancelled": "Requête API annulée",
"streamingFailed": "Échec du streaming API",
"rateLimitWait": "Limitation du débit",
"errorTitle": "Erreur du fournisseur {{code}}",
"errorMessage": {
"docs": "Documentation",

View file

@ -138,6 +138,7 @@
"streaming": "API अनुरोध...",
"cancelled": "API अनुरोध रद्द किया गया",
"streamingFailed": "API स्ट्रीमिंग विफल हुई",
"rateLimitWait": "दर सीमा",
"errorTitle": "प्रदाता त्रुटि {{code}}",
"errorMessage": {
"docs": "डॉक्स",

View file

@ -147,6 +147,7 @@
"streaming": "Permintaan API...",
"cancelled": "Permintaan API Dibatalkan",
"streamingFailed": "Streaming API Gagal",
"rateLimitWait": "Pembatasan rate",
"errorTitle": "Kesalahan Penyedia {{code}}",
"errorMessage": {
"docs": "Dokumentasi",

View file

@ -141,6 +141,7 @@
"streaming": "Richiesta API...",
"cancelled": "Richiesta API annullata",
"streamingFailed": "Streaming API fallito",
"rateLimitWait": "Limitazione della frequenza",
"errorTitle": "Errore del fornitore {{code}}",
"errorMessage": {
"docs": "Documentazione",

View file

@ -138,6 +138,7 @@
"streaming": "APIリクエスト...",
"cancelled": "APIリクエストキャンセル",
"streamingFailed": "APIストリーミング失敗",
"rateLimitWait": "レート制限中",
"errorTitle": "プロバイダーエラー {{code}}",
"errorMessage": {
"docs": "ドキュメント",

View file

@ -138,6 +138,7 @@
"streaming": "API 요청...",
"cancelled": "API 요청 취소됨",
"streamingFailed": "API 스트리밍 실패",
"rateLimitWait": "속도 제한",
"errorTitle": "공급자 오류 {{code}}",
"errorMessage": {
"docs": "문서",

View file

@ -133,6 +133,7 @@
"streaming": "API-verzoek...",
"cancelled": "API-verzoek geannuleerd",
"streamingFailed": "API-streaming mislukt",
"rateLimitWait": "Snelheidsbeperking",
"errorTitle": "Fout van provider {{code}}",
"errorMessage": {
"docs": "Documentatie",

View file

@ -138,6 +138,7 @@
"streaming": "Zapytanie API...",
"cancelled": "Zapytanie API anulowane",
"streamingFailed": "Strumieniowanie API nie powiodło się",
"rateLimitWait": "Ograniczenie szybkości",
"errorTitle": "Błąd dostawcy {{code}}",
"errorMessage": {
"docs": "Dokumentacja",

View file

@ -138,6 +138,7 @@
"streaming": "Requisição API...",
"cancelled": "Requisição API cancelada",
"streamingFailed": "Streaming API falhou",
"rateLimitWait": "Limitação de taxa",
"errorTitle": "Erro do provedor {{code}}",
"errorMessage": {
"docs": "Documentação",

View file

@ -133,6 +133,7 @@
"streaming": "API-запрос...",
"cancelled": "API-запрос отменен",
"streamingFailed": "Ошибка потокового API-запроса",
"rateLimitWait": "Ограничение частоты",
"errorTitle": "Ошибка провайдера {{code}}",
"errorMessage": {
"docs": "Документация",

View file

@ -138,6 +138,7 @@
"streaming": "API İsteği...",
"cancelled": "API İsteği İptal Edildi",
"streamingFailed": "API Akışı Başarısız",
"rateLimitWait": "Hız sınırlaması",
"errorTitle": "Sağlayıcı Hatası {{code}}",
"errorMessage": {
"docs": "Belgeler",

View file

@ -138,6 +138,7 @@
"streaming": "Yêu cầu API...",
"cancelled": "Yêu cầu API đã hủy",
"streamingFailed": "Streaming API thất bại",
"rateLimitWait": "Giới hạn tốc độ",
"errorTitle": "Lỗi nhà cung cấp {{code}}",
"errorMessage": {
"docs": "Tài liệu",

View file

@ -138,6 +138,7 @@
"streaming": "API请求...",
"cancelled": "API请求已取消",
"streamingFailed": "API流式传输失败",
"rateLimitWait": "请求频率限制",
"errorTitle": "提供商错误 {{code}}",
"errorMessage": {
"docs": "文档",

View file

@ -144,6 +144,7 @@
"streaming": "正在處理 API 請求...",
"cancelled": "API 請求已取消",
"streamingFailed": "API 串流處理失敗",
"rateLimitWait": "速率限制",
"errorTitle": "提供商錯誤 {{code}}",
"errorMessage": {
"docs": "文件",