Quellcode durchsuchen

Merge pull request #291 from RooVetGit/retry-request-control

Retry request control
Matt Rubens vor 11 Monaten
Ursprung
Commit
53fd3d17b5

+ 5 - 0
.changeset/slow-ladybugs-invite.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Automatically retry failed API requests with a configurable delay (thanks @RaySinner!)

+ 1 - 0
README.md

@@ -23,6 +23,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
 - Per-tool MCP auto-approval
 - Enable/disable individual MCP servers
 - Enable/disable the MCP feature overall
+- Automatically retry failed API requests with a configurable delay
 - Configurable delay after auto-writes to allow diagnostics to detect potential problems
 - Control the number of terminal output lines to pass to the model when executing commands
 - Runs alongside the original Cline

+ 36 - 12
src/core/Cline.ts

@@ -766,7 +766,7 @@ export class Cline {
 	async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
 		let mcpHub: McpHub | undefined
 
-		const { mcpEnabled } = await this.providerRef.deref()?.getState() ?? {}
+		const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } = await this.providerRef.deref()?.getState() ?? {}
 
 		if (mcpEnabled ?? true) {
 			mcpHub = this.providerRef.deref()?.mcpHub
@@ -810,18 +810,42 @@ export class Cline {
 			yield firstChunk.value
 		} catch (error) {
 			// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
-			const { response } = await this.ask(
-				"api_req_failed",
-				error.message ?? JSON.stringify(serializeError(error), null, 2),
-			)
-			if (response !== "yesButtonClicked") {
-				// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
-				throw new Error("API request failed")
+			if (alwaysApproveResubmit) {
+				const requestDelay = requestDelaySeconds || 5
+				// Automatically retry with delay
+				await this.say(
+					"error",
+					`Error (${
+					  error.message?.toLowerCase().includes("429") ||
+					  error.message?.toLowerCase().includes("rate limit") ||
+					  error.message?.toLowerCase().includes("too many requests") ||
+					  error.message?.toLowerCase().includes("throttled")
+					    ? "rate limit"
+					    : error.message?.includes("500") || error.message?.includes("503")
+					      ? "internal server error"
+					      : "unknown"
+					}). ↺ Retrying in ${requestDelay} seconds...`,
+				)
+				await this.say("api_req_retry_delayed")
+				await delay(requestDelay * 1000)
+				await this.say("api_req_retried")
+				// delegate generator output from the recursive call
+				yield* this.attemptApiRequest(previousApiReqIndex)
+				return
+			} else {
+				const { response } = await this.ask(
+					"api_req_failed",
+					error.message ?? JSON.stringify(serializeError(error), null, 2),
+				)
+				if (response !== "yesButtonClicked") {
+					// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
+					throw new Error("API request failed")
+				}
+				await this.say("api_req_retried")
+				// delegate generator output from the recursive call
+				yield* this.attemptApiRequest(previousApiReqIndex)
+				return
 			}
-			await this.say("api_req_retried")
-			// delegate generator output from the recursive call
-			yield* this.attemptApiRequest(previousApiReqIndex)
-			return
 		}
 
 		// no error, so we can continue to yield all remaining chunks

+ 23 - 3
src/core/webview/ClineProvider.ts

@@ -83,6 +83,8 @@ type GlobalStateKey =
 	| "writeDelayMs"
 	| "terminalOutputLineLimit"
 	| "mcpEnabled"
+	| "alwaysApproveResubmit"
+	| "requestDelaySeconds"
 export const GlobalFileNames = {
 	apiConversationHistory: "api_conversation_history.json",
 	uiMessages: "ui_messages.json",
@@ -675,6 +677,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("fuzzyMatchThreshold", message.value)
 						await this.postStateToWebview()
 						break
+					case "alwaysApproveResubmit":
+						await this.updateGlobalState("alwaysApproveResubmit", message.bool ?? false)
+						await this.postStateToWebview()
+						break
+					case "requestDelaySeconds":
+						await this.updateGlobalState("requestDelaySeconds", message.value ?? 5)
+						await this.postStateToWebview()
+						break
 					case "preferredLanguage":
 						await this.updateGlobalState("preferredLanguage", message.text)
 						await this.postStateToWebview()
@@ -1224,9 +1234,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 	}
 
 	async getStateToPostToWebview() {
-		const { 
-			apiConfiguration, 
-			lastShownAnnouncementId, 
+		const {
+			apiConfiguration,
+			lastShownAnnouncementId,
 			customInstructions,
 			alwaysAllowReadOnly,
 			alwaysAllowWrite,
@@ -1244,6 +1254,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			terminalOutputLineLimit,
 			fuzzyMatchThreshold,
 			mcpEnabled,
+			alwaysApproveResubmit,
+			requestDelaySeconds,
 		} = await this.getState()
 		
 		const allowedCommands = vscode.workspace
@@ -1276,6 +1288,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
 			fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
 			mcpEnabled: mcpEnabled ?? true,
+			alwaysApproveResubmit: alwaysApproveResubmit ?? false,
+			requestDelaySeconds: requestDelaySeconds ?? 5,
 		}
 	}
 
@@ -1381,6 +1395,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			screenshotQuality,
 			terminalOutputLineLimit,
 			mcpEnabled,
+			alwaysApproveResubmit,
+			requestDelaySeconds,
 		] = await Promise.all([
 			this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
 			this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1431,6 +1447,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("screenshotQuality") as Promise<number | undefined>,
 			this.getGlobalState("terminalOutputLineLimit") as Promise<number | undefined>,
 			this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
+			this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
+			this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
 		])
 
 		let apiProvider: ApiProvider
@@ -1525,6 +1543,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				return langMap[vscodeLang.split('-')[0]] ?? 'English';
 			})(),
 			mcpEnabled: mcpEnabled ?? true,
+			alwaysApproveResubmit: alwaysApproveResubmit ?? false,
+			requestDelaySeconds: requestDelaySeconds ?? 5,
 		}
 	}
 

+ 37 - 0
src/core/webview/__tests__/ClineProvider.test.ts

@@ -263,6 +263,7 @@ describe('ClineProvider', () => {
             browserViewportSize: "900x600",
             fuzzyMatchThreshold: 1.0,
             mcpEnabled: true,
+            requestDelaySeconds: 5
         }
         
         const message: ExtensionMessage = { 
@@ -382,6 +383,42 @@ describe('ClineProvider', () => {
         expect(mockPostMessage).toHaveBeenCalled()
     })
 
+    test('requestDelaySeconds defaults to 5 seconds', async () => {
+        // Mock globalState.get to return undefined for requestDelaySeconds
+        (mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
+            if (key === 'requestDelaySeconds') {
+                return undefined
+            }
+            return null
+        })
+
+        const state = await provider.getState()
+        expect(state.requestDelaySeconds).toBe(5)
+    })
+
+    test('alwaysApproveResubmit defaults to false', async () => {
+        // Mock globalState.get to return undefined for alwaysApproveResubmit
+        (mockContext.globalState.get as jest.Mock).mockReturnValue(undefined)
+
+        const state = await provider.getState()
+        expect(state.alwaysApproveResubmit).toBe(false)
+    })
+
+    test('handles request delay settings messages', async () => {
+        provider.resolveWebviewView(mockWebviewView)
+        const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+        // Test alwaysApproveResubmit
+        await messageHandler({ type: 'alwaysApproveResubmit', bool: true })
+        expect(mockContext.globalState.update).toHaveBeenCalledWith('alwaysApproveResubmit', true)
+        expect(mockPostMessage).toHaveBeenCalled()
+
+        // Test requestDelaySeconds
+        await messageHandler({ type: 'requestDelaySeconds', value: 10 })
+        expect(mockContext.globalState.update).toHaveBeenCalledWith('requestDelaySeconds', 10)
+        expect(mockPostMessage).toHaveBeenCalled()
+    })
+
     test('file content includes line numbers', async () => {
         const { extractTextFromFile } = require('../../../integrations/misc/extract-text')
         const result = await extractTextFromFile('test.js')

+ 3 - 0
src/shared/ExtensionMessage.ts

@@ -56,6 +56,8 @@ export interface ExtensionState {
 	alwaysAllowExecute?: boolean
 	alwaysAllowBrowser?: boolean
 	alwaysAllowMcp?: boolean
+	alwaysApproveResubmit?: boolean
+	requestDelaySeconds: number
 	uriScheme?: string
 	allowedCommands?: string[]
 	soundEnabled?: boolean
@@ -103,6 +105,7 @@ export type ClineSay =
 	| "user_feedback"
 	| "user_feedback_diff"
 	| "api_req_retried"
+	| "api_req_retry_delayed"
 	| "command_output"
 	| "tool"
 	| "shell_integration_warning"

+ 2 - 0
src/shared/WebviewMessage.ts

@@ -52,6 +52,8 @@ export interface WebviewMessage {
 		| "terminalOutputLineLimit"
 		| "mcpEnabled"
 		| "searchCommits"
+		| "alwaysApproveResubmit"
+		| "requestDelaySeconds"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse

+ 44 - 2
webview-ui/src/components/settings/SettingsView.tsx

@@ -51,6 +51,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 		terminalOutputLineLimit,
 		setTerminalOutputLineLimit,
 		mcpEnabled,
+		alwaysApproveResubmit,
+		setAlwaysApproveResubmit,
+		requestDelaySeconds,
+		setRequestDelaySeconds,
 	} = useExtensionState()
 	const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
 	const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
@@ -83,6 +87,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 			vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
 			vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
 			vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
+			vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
+			vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
 			onDone()
 		}
 	}
@@ -355,11 +361,47 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 							<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
 						</VSCodeCheckbox>
 						<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
-							Automatically perform browser actions without requiring approval<br/>
+							Automatically perform browser actions without requiring approval<br />
 							Note: Only applies when the model supports computer use
 						</p>
 					</div>
 
+					<div style={{ marginBottom: 5 }}>
+						<VSCodeCheckbox
+							checked={alwaysApproveResubmit}
+							onChange={(e: any) => setAlwaysApproveResubmit(e.target.checked)}>
+							<span style={{ fontWeight: "500" }}>Always retry failed API requests</span>
+						</VSCodeCheckbox>
+						<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
+							Automatically retry failed API requests when server returns an error response
+						</p>
+						{alwaysApproveResubmit && (
+							<div style={{ marginTop: 10 }}>
+								<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
+									<input
+										type="range"
+										min="0"
+										max="100"
+										step="1"
+										value={requestDelaySeconds}
+										onChange={(e) => setRequestDelaySeconds(parseInt(e.target.value))}
+										style={{
+											flex: 1,
+											accentColor: 'var(--vscode-button-background)',
+											height: '2px'
+										}}
+									/>
+									<span style={{ minWidth: '45px', textAlign: 'left' }}>
+										{requestDelaySeconds}s
+									</span>
+								</div>
+								<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
+									Delay before retrying the request
+								</p>
+							</div>
+						)}
+					</div>
+
 					<div style={{ marginBottom: 5 }}>
 						<VSCodeCheckbox
 							checked={alwaysAllowMcp}
@@ -525,7 +567,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 
 					<div style={{ marginBottom: 5 }}>
 						<div style={{ marginBottom: 10 }}>
-						<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Notification Settings</h3>
+							<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Notification Settings</h3>
 							<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
 								<span style={{ fontWeight: "500" }}>Enable sound effects</span>
 							</VSCodeCheckbox>

+ 8 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -46,6 +46,10 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setTerminalOutputLineLimit: (value: number) => void
 	mcpEnabled: boolean
 	setMcpEnabled: (value: boolean) => void
+	alwaysApproveResubmit?: boolean
+	setAlwaysApproveResubmit: (value: boolean) => void
+	requestDelaySeconds: number
+	setRequestDelaySeconds: (value: number) => void
 }
 
 export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -67,6 +71,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		screenshotQuality: 75,
 		terminalOutputLineLimit: 500,
 		mcpEnabled: true,
+		alwaysApproveResubmit: false,
+		requestDelaySeconds: 5
 	})
 	const [didHydrateState, setDidHydrateState] = useState(false)
 	const [showWelcome, setShowWelcome] = useState(false)
@@ -201,6 +207,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
 		setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
 		setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
+		setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })),
+		setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value }))
 	}
 
 	return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>