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

Retry request control
This commit is contained in:
Matt Rubens
2025-01-07 10:50:12 -05:00
committed by GitHub
9 changed files with 159 additions and 17 deletions

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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')

View File

@@ -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"

View File

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

View File

@@ -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>

View File

@@ -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>