Merge pull request #8435 from Kilo-Org/catrielmuller/fix-8333

feat(vscode): exponential backoff retry with cancel button for rate limiting
This commit is contained in:
Catriel Müller
2026-04-07 17:10:40 -03:00
committed by GitHub
23 changed files with 286 additions and 26 deletions
+112 -25
View File
@@ -41,6 +41,7 @@ import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
// legacy-migration start
import {
checkAndShowMigrationWizard,
@@ -530,6 +531,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "abort":
this.cancelRetry(message.sessionID ?? "")
await this.handleAbort(message.sessionID)
break
case "revertSession":
@@ -2160,6 +2162,85 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return { sid, dir }
}
/** Abort controllers for active retry loops, keyed by session ID */
private retryAbortControllers = new Map<string, AbortController>()
/**
* Execute an SDK call with exponential backoff on HTTP errors.
* Retries on 429, 5xx, and other retryable status codes.
* When the response includes `Retry-After` / `Retry-After-MS` headers,
* the delay honours that value (capped at 5 min). Otherwise uses the
* predefined backoff schedule: 5s -> 10s -> 30s -> 60s -> 300s.
*
* After MAX_RETRIES (5) attempts, automatically throws the error.
* Users can cancel via the cancel button in the UI which sends an abort
* message — this interrupts the backoff delay and stops the retry loop.
*
* The webview receives `sessionStatus` messages with a countdown so the
* user can see that a retry is in progress.
*/
private async withRetry(fn: () => Promise<{ error?: unknown; response: Response }>, sid: string): Promise<void> {
const abortController = new AbortController()
this.retryAbortControllers.set(sid, abortController)
try {
for (let attempt = 1; ; attempt++) {
if (abortController.signal.aborted) {
// User cancelled — return normally without triggering sendMessageFailed
return
}
const result = await fn()
if (!result.error) return
const status = result.response?.status ?? 0
// Non-retryable status codes fail immediately without retry
if (!retryable(status)) {
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
throw result.error
}
// Stop retrying after MAX_RETRIES attempts
if (attempt >= MAX_RETRIES) {
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
throw result.error
}
const delay = backoff(attempt, result.response?.headers)
console.log(`[Kilo New] KiloProvider: Retry on ${status}, attempt ${attempt}/${MAX_RETRIES}, delay ${delay}ms`)
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: "retry",
attempt,
message: `Error (${status}). Retrying...`,
next: Date.now() + delay,
})
// Wait for delay or until aborted
await new Promise((resolve) => {
const timer = setTimeout(resolve, delay)
abortController.signal.addEventListener("abort", () => {
clearTimeout(timer)
})
})
}
} finally {
this.retryAbortControllers.delete(sid)
}
}
/** Cancel an active retry loop for a session */
private cancelRetry(sid: string): void {
const controller = this.retryAbortControllers.get(sid)
if (controller) {
controller.abort()
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
}
}
private async handleSendMessage(
text: string,
messageID?: string,
@@ -2202,18 +2283,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
}
await this.client.session.promptAsync(
{
sessionID: resolved!.sid,
directory: resolved!.dir,
messageID,
parts,
model: providerID && modelID ? { providerID, modelID } : undefined,
agent,
variant,
editorContext,
},
{ throwOnError: true },
const sid = resolved!.sid
const dir = resolved!.dir
await this.withRetry(
() =>
this.client!.session.promptAsync({
sessionID: sid,
directory: dir,
messageID,
parts,
model: providerID && modelID ? { providerID, modelID } : undefined,
agent,
variant,
editorContext,
}),
sid,
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send message:", error)
@@ -2264,19 +2348,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url }))
await this.client.session.command(
{
sessionID: resolved!.sid,
directory: resolved!.dir,
command,
arguments: args,
messageID,
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
agent,
variant,
parts,
},
{ throwOnError: true },
const sid = resolved!.sid
const dir = resolved!.dir
await this.withRetry(
() =>
this.client!.session.command({
sessionID: sid,
directory: dir,
command,
arguments: args,
messageID,
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
agent,
variant,
parts,
}),
sid,
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send command:", error)
+70
View File
@@ -0,0 +1,70 @@
/**
* Exponential backoff retry utilities for rate-limited API calls.
*
* When the CLI backend (or the upstream AI provider it proxies) returns
* HTTP 429, retries are scheduled with exponential backoff. The delay
* respects `Retry-After` / `Retry-After-MS` headers when present.
*/
/** Backoff delays per attempt: 5s -> 10s -> 30s -> 60s -> 300s */
const BACKOFF_DELAYS_MS = [5_000, 10_000, 30_000, 60_000, 300_000]
/** Maximum backoff delay in ms (5 minutes) */
const MAX_MS = 300_000
/** Maximum number of retry attempts */
const MAX_RETRIES = BACKOFF_DELAYS_MS.length
/** HTTP status codes that are safe to retry */
const RETRYABLE = new Set([408, 409, 425, 429, 500, 502, 503, 504])
/**
* Whether an HTTP status code is retryable.
*/
export function retryable(status: number): boolean {
if (RETRYABLE.has(status)) return true
return status >= 500
}
/**
* Extract a retry delay (in ms) from standard response headers.
*
* Checks `retry-after-ms` first (milliseconds), then `retry-after`
* (seconds or HTTP-date). Returns `null` when no usable header is found.
*/
export function headerDelay(headers: Headers): number | null {
const ms = headers.get("retry-after-ms")
if (ms) {
const parsed = Number.parseFloat(ms)
if (!Number.isNaN(parsed) && parsed > 0) return parsed
}
const after = headers.get("retry-after")
if (after) {
const seconds = Number.parseFloat(after)
if (!Number.isNaN(seconds) && seconds > 0) return Math.ceil(seconds * 1000)
// Try HTTP-date format
const date = Date.parse(after) - Date.now()
if (!Number.isNaN(date) && date > 0) return Math.ceil(date)
}
return null
}
/**
* Calculate backoff delay for a given attempt.
*
* If `headers` are provided and contain a `Retry-After` value, that
* value is used (capped at MAX_MS). Otherwise uses the predefined
* backoff schedule: 5s, 10s, 30s, 60s, 300s.
*/
export function backoff(attempt: number, headers?: Headers): number {
if (headers) {
const fromHeader = headerDelay(headers)
if (fromHeader !== null) return Math.min(fromHeader, MAX_MS)
}
const index = Math.min(attempt - 1, BACKOFF_DELAYS_MS.length - 1)
return BACKOFF_DELAYS_MS[index] ?? MAX_MS
}
export { MAX_RETRIES, MAX_MS }
@@ -4,14 +4,17 @@
* Matches the v1.0.25 working indicator UX.
*/
import { Component, Show, createSignal, createEffect, onCleanup } from "solid-js"
import { type Component, Show, createSignal, createEffect, onCleanup } from "solid-js"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Button } from "@kilocode/kilo-ui/button"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
export const WorkingIndicator: Component = () => {
const session = useSession()
const language = useLanguage()
const vscode = useVSCode()
const [elapsed, setElapsed] = createSignal(0)
const [retryCountdown, setRetryCountdown] = createSignal(0)
@@ -80,6 +83,15 @@ export const WorkingIndicator: Component = () => {
return perms.length > 0 || questions.length > 0
}
const isRetrying = () => session.statusInfo().type === "retry"
const handleCancelRetry = () => {
const sid = session.currentSessionID()
if (sid) {
vscode.postMessage({ type: "abort", sessionID: sid })
}
}
return (
<Show when={session.status() !== "idle" && !blocked()}>
<div class="working-indicator">
@@ -88,6 +100,17 @@ export const WorkingIndicator: Component = () => {
<Show when={elapsed() > 0}>
<span class="working-elapsed">{formatElapsed()}</span>
</Show>
<Show when={isRetrying()}>
<Button
variant="secondary"
size="small"
onClick={handleCancelRetry}
class="working-cancel"
style={{ "font-weight": "600", color: "var(--vscode-errorForeground, #f85149)" }}
>
{language.t("ui.sessionTurn.cancel") || "Cancel"}
</Button>
</Show>
</div>
</Show>
)
@@ -414,6 +414,10 @@ export const SessionProvider: ParentComponent = (props) => {
function selectModel(providerID: string, modelID: string) {
applyModel(selectedAgentName(), { providerID, modelID })
const sid = currentSessionID()
if (sid) {
setStore("messages", sid, (msgs = []) => msgs.filter((m) => !m.error))
}
}
/** The config/default model for the current mode (what settings says). */
+4
View File
@@ -935,6 +935,10 @@ export const dict = {
"session.status.retrying": "...إعادة المحاولة (المحاولة {{ attempt }})… {{ message }}",
"session.status.working": "...جارٍ العمل",
"ui.sessionTurn.cancel": "إلغاء",
"ui.sessionTurn.status.thinking": "...جارٍ التفكير",
"ui.sessionTurn.status.consideringNextSteps": "...جارٍ التفكير في الخطوات التالية",
"dialog.model.noProviders": "لا يوجد موفرون",
"prompt.placeholder.connecting": "جارٍ الاتصال بالخادم...",
+4
View File
@@ -943,6 +943,10 @@ export const dict = {
"session.status.retrying": "Tentando novamente (tentativa {{ attempt }})… {{ message }}",
"session.status.working": "Trabalhando…",
"ui.sessionTurn.cancel": "Cancelar",
"ui.sessionTurn.status.thinking": "Pensando...",
"ui.sessionTurn.status.consideringNextSteps": "Considerando próximos passos...",
"dialog.model.noProviders": "Nenhum provedor",
"prompt.placeholder.connecting": "Conectando ao servidor...",
+4
View File
@@ -948,6 +948,10 @@ export const dict = {
"session.status.retrying": "Ponovni pokušaj (pokušaj {{ attempt }})… {{ message }}",
"session.status.working": "Radim…",
"ui.sessionTurn.cancel": "Otkaži",
"ui.sessionTurn.status.thinking": "Razmišljam...",
"ui.sessionTurn.status.consideringNextSteps": "Razmatram sljedeće korake...",
"dialog.model.noProviders": "Nema pružatelja",
"prompt.placeholder.connecting": "Povezivanje na server...",
+4
View File
@@ -941,6 +941,10 @@ export const dict = {
"session.status.retrying": "Prøver igen (forsøg {{ attempt }})… {{ message }}",
"session.status.working": "Arbejder…",
"ui.sessionTurn.cancel": "Annuller",
"ui.sessionTurn.status.thinking": "Tænker...",
"ui.sessionTurn.status.consideringNextSteps": "Overvejer næste trin...",
"dialog.model.noProviders": "Ingen udbydere",
"prompt.placeholder.connecting": "Opretter forbindelse til server...",
+4
View File
@@ -953,6 +953,10 @@ export const dict = {
"session.status.retrying": "Erneuter Versuch ({{ attempt }})… {{ message }}",
"session.status.working": "Wird bearbeitet…",
"ui.sessionTurn.cancel": "Abbrechen",
"ui.sessionTurn.status.thinking": "Denke nach...",
"ui.sessionTurn.status.consideringNextSteps": "Überlege nächste Schritte...",
"dialog.model.noProviders": "Keine Anbieter",
"prompt.placeholder.connecting": "Verbindung zum Server wird hergestellt...",
@@ -941,6 +941,10 @@ export const dict = {
"session.status.retrying": "Retrying (attempt {{ attempt }})… {{ message }}",
"session.status.working": "Working...",
"ui.sessionTurn.cancel": "Cancel",
"ui.sessionTurn.status.thinking": "Thinking...",
"ui.sessionTurn.status.consideringNextSteps": "Considering next steps...",
"dialog.model.noProviders": "No providers",
"prompt.placeholder.connecting": "Connecting to server...",
+4
View File
@@ -949,6 +949,10 @@ export const dict = {
"session.status.retrying": "Reintentando (intento {{ attempt }})… {{ message }}",
"session.status.working": "Trabajando…",
"ui.sessionTurn.cancel": "Cancelar",
"ui.sessionTurn.status.thinking": "Pensando...",
"ui.sessionTurn.status.consideringNextSteps": "Considerando siguientes pasos...",
"dialog.model.noProviders": "Sin proveedores",
"prompt.placeholder.connecting": "Conectando al servidor...",
+4
View File
@@ -955,6 +955,10 @@ export const dict = {
"session.status.retrying": "Nouvelle tentative (essai {{ attempt }})… {{ message }}",
"session.status.working": "En cours…",
"ui.sessionTurn.cancel": "Annuler",
"ui.sessionTurn.status.thinking": "Réflexion...",
"ui.sessionTurn.status.consideringNextSteps": "Envisage les prochaines étapes...",
"dialog.model.noProviders": "Aucun fournisseur",
"prompt.placeholder.connecting": "Connexion au serveur...",
+4
View File
@@ -940,6 +940,10 @@ export const dict = {
"session.status.retrying": "再試行中({{ attempt }}回目)… {{ message }}",
"session.status.working": "作業中…",
"ui.sessionTurn.cancel": "キャンセル",
"ui.sessionTurn.status.thinking": "考え中...",
"ui.sessionTurn.status.consideringNextSteps": "次のステップを検討中...",
"dialog.model.noProviders": "プロバイダーなし",
"prompt.placeholder.connecting": "サーバーに接続中...",
+4
View File
@@ -940,6 +940,10 @@ export const dict = {
"session.status.retrying": "재시도 중 ({{ attempt }}번째 시도)… {{ message }}",
"session.status.working": "작업 중...",
"ui.sessionTurn.cancel": "취소",
"ui.sessionTurn.status.thinking": "생각 중...",
"ui.sessionTurn.status.consideringNextSteps": "다음 단계 고려 중...",
"dialog.model.noProviders": "공급자 없음",
"prompt.placeholder.connecting": "서버에 연결 중...",
+4
View File
@@ -942,6 +942,10 @@ export const dict = {
"session.status.retrying": "Opnieuw proberen (poging {{ attempt }})... {{ message }}",
"session.status.working": "Bezig...",
"ui.sessionTurn.cancel": "Annuleren",
"ui.sessionTurn.status.thinking": "Denken...",
"ui.sessionTurn.status.consideringNextSteps": "Volgende stappen overwegen...",
"dialog.model.noProviders": "Geen providers",
"prompt.placeholder.connecting": "Verbinden met server...",
+4
View File
@@ -945,6 +945,10 @@ export const dict = {
"session.status.retrying": "Prøver på nytt (forsøk {{ attempt }})… {{ message }}",
"session.status.working": "Arbeider…",
"ui.sessionTurn.cancel": "Avbryt",
"ui.sessionTurn.status.thinking": "Tenker...",
"ui.sessionTurn.status.consideringNextSteps": "Vurderer neste steg...",
"dialog.model.noProviders": "Ingen leverandører",
"prompt.placeholder.connecting": "Kobler til server...",
+4
View File
@@ -945,6 +945,10 @@ export const dict = {
"session.status.retrying": "Ponawiam próbę ({{ attempt }})… {{ message }}",
"session.status.working": "Pracuję…",
"ui.sessionTurn.cancel": "Anuluj",
"ui.sessionTurn.status.thinking": "Myślę...",
"ui.sessionTurn.status.consideringNextSteps": "Rozważam następne kroki...",
"dialog.model.noProviders": "Brak dostawców",
"prompt.placeholder.connecting": "Łączenie z serwerem...",
+4
View File
@@ -948,6 +948,10 @@ export const dict = {
"session.status.retrying": "Повторная попытка ({{ attempt }})… {{ message }}",
"session.status.working": "Работаю…",
"ui.sessionTurn.cancel": "Отмена",
"ui.sessionTurn.status.thinking": "Думаю...",
"ui.sessionTurn.status.consideringNextSteps": "Продумываю следующие шаги...",
"dialog.model.noProviders": "Нет провайдеров",
"prompt.placeholder.connecting": "Подключение к серверу...",
+4
View File
@@ -936,6 +936,10 @@ export const dict = {
"session.status.retrying": "กำลังลองใหม่ (ครั้งที่ {{ attempt }})… {{ message }}",
"session.status.working": "กำลังทำงาน...",
"ui.sessionTurn.cancel": "ยกเลิก",
"ui.sessionTurn.status.thinking": "กำลังคิด...",
"ui.sessionTurn.status.consideringNextSteps": "กำลังพิจารณาขั้นตอนถัดไป...",
"dialog.model.noProviders": "ไม่มีผู้ให้บริการ",
"prompt.placeholder.connecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์...",
+4
View File
@@ -944,6 +944,10 @@ export const dict = {
"session.status.retrying": "Yeniden deneniyor (deneme {{ attempt }})… {{ message }}",
"session.status.working": "Çalışıyor...",
"ui.sessionTurn.cancel": "İptal",
"ui.sessionTurn.status.thinking": "Düşünüyor...",
"ui.sessionTurn.status.consideringNextSteps": "Sonraki adımları değerlendiriyor...",
"dialog.model.noProviders": "Sağlayıcı yok",
"prompt.placeholder.connecting": "Sunucuya bağlanılıyor...",
+4
View File
@@ -945,6 +945,10 @@ export const dict = {
"session.status.retrying": "Повторна спроба (спроба {{ attempt }})… {{ message }}",
"session.status.working": "Працює...",
"ui.sessionTurn.cancel": "Скасувати",
"ui.sessionTurn.status.thinking": "Думаю...",
"ui.sessionTurn.status.consideringNextSteps": "Обдумую наступні кроки...",
"dialog.model.noProviders": "Немає провайдерів",
"prompt.placeholder.connecting": "Підключення до сервера...",
+4
View File
@@ -928,6 +928,10 @@ export const dict = {
"session.status.retrying": "正在重试(第 {{ attempt }} 次)… {{ message }}",
"session.status.working": "处理中…",
"ui.sessionTurn.cancel": "取消",
"ui.sessionTurn.status.thinking": "思考中...",
"ui.sessionTurn.status.consideringNextSteps": "正在考虑下一步...",
"dialog.model.noProviders": "无供应商",
"prompt.placeholder.connecting": "正在连接服务器...",
+4
View File
@@ -930,6 +930,10 @@ export const dict = {
"session.status.retrying": "正在重試(第 {{ attempt }} 次)… {{ message }}",
"session.status.working": "處理中…",
"ui.sessionTurn.cancel": "取消",
"ui.sessionTurn.status.thinking": "思考中...",
"ui.sessionTurn.status.consideringNextSteps": "正在考慮下一步...",
"dialog.model.noProviders": "沒有供應商",
"prompt.placeholder.connecting": "正在連線至伺服器...",