mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
Feat: Display API auto-retry status in chat UI
This commit enhances user experience by providing real-time feedback on automatic API request retries directly within the chat interface. When an API request encounters a retriable error (e.g., 429), the UI will now indicate that a retry is in progress, showing the current attempt, maximum attempts, and delay until the next attempt. Key changes: - Modified the `withRetry` decorator in `src/api/retry.ts` to accept an `onRetryAttempt` callback. This callback is invoked before each retry, passing details like attempt number, max retries, delay, and the error that triggered the retry. - `Task` (`src/core/task/index.ts`) now provides this callback to API handlers. It updates the `api_req_started` message in `clineMessages` with `retryStatus` information and posts the updated state to the webview. It also clears retry status if retries are exhausted. - The `ChatRow.tsx` component in the webview UI has been updated to display this retry status (e.g., "Retrying (attempt X of Y, next in Zs)..."). If retries are exhausted, the standard error display is shown. - Data structures in `src/shared/` (ExtensionMessage, api, proto/file) were updated to include `retryStatus` and the `onRetryAttempt` callback. - Added test code to `GeminiHandler` (`src/api/providers/gemini.ts`) to simulate 429 errors, allowing for easier testing and verification of the retry feedback mechanism.
This commit is contained in:
@@ -73,8 +73,25 @@ export class GeminiHandler implements ApiHandler {
|
||||
* @param messages The conversation history to include in the message
|
||||
* @returns An async generator that yields chunks of the response with accurate immediate costs
|
||||
*/
|
||||
@withRetry()
|
||||
@withRetry({
|
||||
maxRetries: 4,
|
||||
baseDelay: 2000,
|
||||
maxDelay: 15000,
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// --- Cline: Modified for testing 429 error (50% chance) ---
|
||||
const shouldThrowFakeError = Math.random() < 1
|
||||
if (shouldThrowFakeError && systemPrompt.length > 100) {
|
||||
// systemPrompt.length > 100 was auto-added, can be removed if not the desired condition
|
||||
const fakeError: any = new Error("Fake 429 Too Many Requests for testing (50% chance).")
|
||||
fakeError.status = 429
|
||||
fakeError.name = "ClientError" // Mimicking Gemini's error structure
|
||||
// fakeError.headers = { "retry-after": "5" }; // Optional: to test retry-after header
|
||||
console.warn("GEMINI_HANDLER: INTENTIONALLY THROWING FAKE 429 ERROR FOR TESTING (50% chance)")
|
||||
throw fakeError
|
||||
}
|
||||
// --- End Cline: Modified for testing 429 error ---
|
||||
|
||||
const { id: modelId, info } = this.getModel()
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
|
||||
|
||||
@@ -54,6 +54,15 @@ export function withRetry(options: RetryOptions = {}) {
|
||||
delay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt))
|
||||
}
|
||||
|
||||
const handlerInstance = this as any
|
||||
if (handlerInstance.options?.onRetryAttempt) {
|
||||
try {
|
||||
handlerInstance.options.onRetryAttempt(attempt + 1, maxRetries, delay, error)
|
||||
} catch (e) {
|
||||
console.error("Error in onRetryAttempt callback:", e)
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
+49
-1
@@ -232,6 +232,37 @@ export class Task {
|
||||
let effectiveApiConfiguration: ApiConfiguration = {
|
||||
...apiConfiguration,
|
||||
taskId: this.taskId,
|
||||
onRetryAttempt: (attempt: number, maxRetries: number, delay: number, error: any) => {
|
||||
const lastApiReqStartedIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
if (lastApiReqStartedIndex !== -1) {
|
||||
try {
|
||||
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(
|
||||
this.clineMessages[lastApiReqStartedIndex].text || "{}",
|
||||
)
|
||||
currentApiReqInfo.retryStatus = {
|
||||
attempt: attempt, // attempt is already 1-indexed from retry.ts
|
||||
maxAttempts: maxRetries, // total attempts
|
||||
delaySec: Math.round(delay / 1000),
|
||||
errorSnippet: error?.message ? `${String(error.message).substring(0, 50)}...` : undefined,
|
||||
}
|
||||
// Clear previous cancelReason and streamingFailedMessage if we are retrying
|
||||
delete currentApiReqInfo.cancelReason
|
||||
delete currentApiReqInfo.streamingFailedMessage
|
||||
this.clineMessages[lastApiReqStartedIndex].text = JSON.stringify(currentApiReqInfo)
|
||||
|
||||
// Post the updated state to the webview so the UI reflects the retry attempt
|
||||
this.postStateToWebview().catch((e) =>
|
||||
console.error("Error posting state to webview in onRetryAttempt:", e),
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[Task ${this.taskId}] API Auto-Retry Status Update: Attempt ${attempt}/${maxRetries}, Delay: ${delay}ms`,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("[Task ${this.taskId}] Error updating api_req_started with retryStatus:", e)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if (apiConfiguration.apiProvider === "openai" || apiConfiguration.apiProvider === "openai-native") {
|
||||
@@ -1656,6 +1687,20 @@ export class Task {
|
||||
|
||||
const errorMessage = this.formatErrorWithStatusCode(error)
|
||||
|
||||
// Update the 'api_req_started' message to reflect final failure before asking user to manually retry
|
||||
const lastApiReqStartedIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
if (lastApiReqStartedIndex !== -1) {
|
||||
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(this.clineMessages[lastApiReqStartedIndex].text || "{}")
|
||||
delete currentApiReqInfo.retryStatus
|
||||
|
||||
this.clineMessages[lastApiReqStartedIndex].text = JSON.stringify({
|
||||
...currentApiReqInfo, // Spread the modified info (with retryStatus removed)
|
||||
cancelReason: "retries_exhausted", // Indicate that automatic retries failed
|
||||
streamingFailedMessage: errorMessage,
|
||||
} satisfies ClineApiReqInfo)
|
||||
// this.ask will trigger postStateToWebview, so this change should be picked up.
|
||||
}
|
||||
|
||||
const { response } = await this.ask("api_req_failed", errorMessage)
|
||||
|
||||
if (response !== "yesButtonClicked") {
|
||||
@@ -3799,8 +3844,11 @@ export class Task {
|
||||
// fortunately api_req_finished was always parsed out for the gui anyways, so it remains solely for legacy purposes to keep track of prices in tasks from history
|
||||
// (it's worth removing a few months from now)
|
||||
const updateApiReqMsg = (cancelReason?: ClineApiReqCancelReason, streamingFailedMessage?: string) => {
|
||||
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}")
|
||||
delete currentApiReqInfo.retryStatus // Clear retry status when request is finalized
|
||||
|
||||
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
|
||||
...JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}"),
|
||||
...currentApiReqInfo, // Spread the modified info (with retryStatus removed)
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
cacheWrites: cacheWriteTokens,
|
||||
|
||||
@@ -208,6 +208,7 @@ export type ClineSay =
|
||||
| "clineignore_error"
|
||||
| "checkpoint_created"
|
||||
| "load_mcp_documentation"
|
||||
| "info" // Added for general informational messages like retry status
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
@@ -276,8 +277,14 @@ export interface ClineApiReqInfo {
|
||||
cost?: number
|
||||
cancelReason?: ClineApiReqCancelReason
|
||||
streamingFailedMessage?: string
|
||||
retryStatus?: {
|
||||
attempt: number
|
||||
maxAttempts: number
|
||||
delaySec: number
|
||||
errorSnippet?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
|
||||
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" | "retries_exhausted"
|
||||
|
||||
export const COMPLETION_RESULT_CHANGES_FLAG = "HAS_CHANGES"
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface ApiHandlerOptions {
|
||||
reasoningEffort?: string
|
||||
sambanovaApiKey?: string
|
||||
requestTimeoutMs?: number
|
||||
onRetryAttempt?: (attempt: number, maxRetries: number, delay: number, error: any) => void
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
|
||||
@@ -915,15 +915,6 @@ export const FileServiceDefinition = {
|
||||
responseStream: false,
|
||||
options: {},
|
||||
},
|
||||
/** Select images from the file system and return as data URLs */
|
||||
selectImages: {
|
||||
name: "selectImages",
|
||||
requestType: EmptyRequest,
|
||||
requestStream: false,
|
||||
responseType: StringArray,
|
||||
responseStream: false,
|
||||
options: {},
|
||||
},
|
||||
/** Opens an image in the system viewer */
|
||||
openImage: {
|
||||
name: "openImage",
|
||||
@@ -960,6 +951,15 @@ export const FileServiceDefinition = {
|
||||
responseStream: false,
|
||||
options: {},
|
||||
},
|
||||
/** Select images from the file system and return as data URLs */
|
||||
selectImages: {
|
||||
name: "selectImages",
|
||||
requestType: EmptyRequest,
|
||||
requestStream: false,
|
||||
responseType: StringArray,
|
||||
responseStream: false,
|
||||
options: {},
|
||||
},
|
||||
/** Convert URIs to workspace-relative paths */
|
||||
getRelativePaths: {
|
||||
name: "getRelativePaths",
|
||||
|
||||
@@ -20,11 +20,11 @@ import { checkpointRestore } from "../core/controller/checkpoints/checkpointRest
|
||||
|
||||
// File Service
|
||||
import { openFile } from "../core/controller/file/openFile"
|
||||
import { selectImages } from "../core/controller/file/selectImages"
|
||||
import { openImage } from "../core/controller/file/openImage"
|
||||
import { deleteRuleFile } from "../core/controller/file/deleteRuleFile"
|
||||
import { createRuleFile } from "../core/controller/file/createRuleFile"
|
||||
import { searchCommits } from "../core/controller/file/searchCommits"
|
||||
import { selectImages } from "../core/controller/file/selectImages"
|
||||
import { getRelativePaths } from "../core/controller/file/getRelativePaths"
|
||||
import { searchFiles } from "../core/controller/file/searchFiles"
|
||||
|
||||
@@ -96,11 +96,11 @@ export function addServices(
|
||||
// File Service
|
||||
server.addService(proto.cline.FileService.service, {
|
||||
openFile: wrapper(openFile, controller),
|
||||
selectImages: wrapper(selectImages, controller),
|
||||
openImage: wrapper(openImage, controller),
|
||||
deleteRuleFile: wrapper(deleteRuleFile, controller),
|
||||
createRuleFile: wrapper(createRuleFile, controller),
|
||||
searchCommits: wrapper(searchCommits, controller),
|
||||
selectImages: wrapper(selectImages, controller),
|
||||
getRelativePaths: wrapper(getRelativePaths, controller),
|
||||
searchFiles: wrapper(searchFiles, controller),
|
||||
})
|
||||
|
||||
@@ -208,12 +208,12 @@ export const ChatRowContent = ({
|
||||
selectedText: "",
|
||||
})
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
|
||||
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage, retryStatus] = useMemo(() => {
|
||||
if (message.text != null && message.say === "api_req_started") {
|
||||
const info: ClineApiReqInfo = JSON.parse(message.text)
|
||||
return [info.cost, info.cancelReason, info.streamingFailedMessage]
|
||||
return [info.cost, info.cancelReason, info.streamingFailedMessage, info.retryStatus]
|
||||
}
|
||||
return [undefined, undefined, undefined]
|
||||
return [undefined, undefined, undefined, undefined]
|
||||
}, [message.text, message.say])
|
||||
|
||||
// when resuming task last won't be api_req_failed but a resume_task message so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
|
||||
@@ -445,6 +445,17 @@ export const ChatRowContent = ({
|
||||
if (apiRequestFailedMessage) {
|
||||
return <span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
|
||||
}
|
||||
// New: Check for retryStatus to modify the title
|
||||
if (retryStatus && cost == null && !apiReqCancelReason) {
|
||||
const retryOperations = retryStatus.maxAttempts > 0 ? retryStatus.maxAttempts - 1 : 0
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color: normalColor,
|
||||
fontWeight: "bold",
|
||||
}}>{`API Request (Retrying failed attempt ${retryStatus.attempt}/${retryOperations})...`}</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span style={{ color: normalColor, fontWeight: "bold" }}>API Request...</span>
|
||||
})(),
|
||||
|
||||
Reference in New Issue
Block a user