Compare commits

...
Author SHA1 Message Date
Cline Evaluation a84c9c830b 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.
2025-05-17 03:31:32 +04:00
8 changed files with 110 additions and 17 deletions
+18 -1
View File
@@ -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)
+9
View File
@@ -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
View File
@@ -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,
+8 -1
View File
@@ -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"
+1
View File
@@ -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 & {
+9 -9
View File
@@ -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",
+2 -2
View File
@@ -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),
})
+14 -3
View File
@@ -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>
})(),