Skip to content

Commit 53fd3d1

Browse files
authored
Merge pull request #291 from RooVetGit/retry-request-control
Retry request control
2 parents 631d9b9 + f7a98c7 commit 53fd3d1

File tree

9 files changed

+159
-17
lines changed

9 files changed

+159
-17
lines changed

.changeset/slow-ladybugs-invite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Automatically retry failed API requests with a configurable delay (thanks @RaySinner!)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
2323
- Per-tool MCP auto-approval
2424
- Enable/disable individual MCP servers
2525
- Enable/disable the MCP feature overall
26+
- Automatically retry failed API requests with a configurable delay
2627
- Configurable delay after auto-writes to allow diagnostics to detect potential problems
2728
- Control the number of terminal output lines to pass to the model when executing commands
2829
- Runs alongside the original Cline

src/core/Cline.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ export class Cline {
766766
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
767767
let mcpHub: McpHub | undefined
768768

769-
const { mcpEnabled } = await this.providerRef.deref()?.getState() ?? {}
769+
const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } = await this.providerRef.deref()?.getState() ?? {}
770770

771771
if (mcpEnabled ?? true) {
772772
mcpHub = this.providerRef.deref()?.mcpHub
@@ -810,18 +810,42 @@ export class Cline {
810810
yield firstChunk.value
811811
} catch (error) {
812812
// 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.
813-
const { response } = await this.ask(
814-
"api_req_failed",
815-
error.message ?? JSON.stringify(serializeError(error), null, 2),
816-
)
817-
if (response !== "yesButtonClicked") {
818-
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
819-
throw new Error("API request failed")
813+
if (alwaysApproveResubmit) {
814+
const requestDelay = requestDelaySeconds || 5
815+
// Automatically retry with delay
816+
await this.say(
817+
"error",
818+
`Error (${
819+
error.message?.toLowerCase().includes("429") ||
820+
error.message?.toLowerCase().includes("rate limit") ||
821+
error.message?.toLowerCase().includes("too many requests") ||
822+
error.message?.toLowerCase().includes("throttled")
823+
? "rate limit"
824+
: error.message?.includes("500") || error.message?.includes("503")
825+
? "internal server error"
826+
: "unknown"
827+
}). ↺ Retrying in ${requestDelay} seconds...`,
828+
)
829+
await this.say("api_req_retry_delayed")
830+
await delay(requestDelay * 1000)
831+
await this.say("api_req_retried")
832+
// delegate generator output from the recursive call
833+
yield* this.attemptApiRequest(previousApiReqIndex)
834+
return
835+
} else {
836+
const { response } = await this.ask(
837+
"api_req_failed",
838+
error.message ?? JSON.stringify(serializeError(error), null, 2),
839+
)
840+
if (response !== "yesButtonClicked") {
841+
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
842+
throw new Error("API request failed")
843+
}
844+
await this.say("api_req_retried")
845+
// delegate generator output from the recursive call
846+
yield* this.attemptApiRequest(previousApiReqIndex)
847+
return
820848
}
821-
await this.say("api_req_retried")
822-
// delegate generator output from the recursive call
823-
yield* this.attemptApiRequest(previousApiReqIndex)
824-
return
825849
}
826850

827851
// no error, so we can continue to yield all remaining chunks

src/core/webview/ClineProvider.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ type GlobalStateKey =
8383
| "writeDelayMs"
8484
| "terminalOutputLineLimit"
8585
| "mcpEnabled"
86+
| "alwaysApproveResubmit"
87+
| "requestDelaySeconds"
8688
export const GlobalFileNames = {
8789
apiConversationHistory: "api_conversation_history.json",
8890
uiMessages: "ui_messages.json",
@@ -675,6 +677,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
675677
await this.updateGlobalState("fuzzyMatchThreshold", message.value)
676678
await this.postStateToWebview()
677679
break
680+
case "alwaysApproveResubmit":
681+
await this.updateGlobalState("alwaysApproveResubmit", message.bool ?? false)
682+
await this.postStateToWebview()
683+
break
684+
case "requestDelaySeconds":
685+
await this.updateGlobalState("requestDelaySeconds", message.value ?? 5)
686+
await this.postStateToWebview()
687+
break
678688
case "preferredLanguage":
679689
await this.updateGlobalState("preferredLanguage", message.text)
680690
await this.postStateToWebview()
@@ -1224,9 +1234,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
12241234
}
12251235

12261236
async getStateToPostToWebview() {
1227-
const {
1228-
apiConfiguration,
1229-
lastShownAnnouncementId,
1237+
const {
1238+
apiConfiguration,
1239+
lastShownAnnouncementId,
12301240
customInstructions,
12311241
alwaysAllowReadOnly,
12321242
alwaysAllowWrite,
@@ -1244,6 +1254,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
12441254
terminalOutputLineLimit,
12451255
fuzzyMatchThreshold,
12461256
mcpEnabled,
1257+
alwaysApproveResubmit,
1258+
requestDelaySeconds,
12471259
} = await this.getState()
12481260

12491261
const allowedCommands = vscode.workspace
@@ -1276,6 +1288,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
12761288
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
12771289
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
12781290
mcpEnabled: mcpEnabled ?? true,
1291+
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
1292+
requestDelaySeconds: requestDelaySeconds ?? 5,
12791293
}
12801294
}
12811295

@@ -1381,6 +1395,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
13811395
screenshotQuality,
13821396
terminalOutputLineLimit,
13831397
mcpEnabled,
1398+
alwaysApproveResubmit,
1399+
requestDelaySeconds,
13841400
] = await Promise.all([
13851401
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
13861402
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1431,6 +1447,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
14311447
this.getGlobalState("screenshotQuality") as Promise<number | undefined>,
14321448
this.getGlobalState("terminalOutputLineLimit") as Promise<number | undefined>,
14331449
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
1450+
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
1451+
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
14341452
])
14351453

14361454
let apiProvider: ApiProvider
@@ -1525,6 +1543,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
15251543
return langMap[vscodeLang.split('-')[0]] ?? 'English';
15261544
})(),
15271545
mcpEnabled: mcpEnabled ?? true,
1546+
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
1547+
requestDelaySeconds: requestDelaySeconds ?? 5,
15281548
}
15291549
}
15301550

src/core/webview/__tests__/ClineProvider.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ describe('ClineProvider', () => {
263263
browserViewportSize: "900x600",
264264
fuzzyMatchThreshold: 1.0,
265265
mcpEnabled: true,
266+
requestDelaySeconds: 5
266267
}
267268

268269
const message: ExtensionMessage = {
@@ -382,6 +383,42 @@ describe('ClineProvider', () => {
382383
expect(mockPostMessage).toHaveBeenCalled()
383384
})
384385

386+
test('requestDelaySeconds defaults to 5 seconds', async () => {
387+
// Mock globalState.get to return undefined for requestDelaySeconds
388+
(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
389+
if (key === 'requestDelaySeconds') {
390+
return undefined
391+
}
392+
return null
393+
})
394+
395+
const state = await provider.getState()
396+
expect(state.requestDelaySeconds).toBe(5)
397+
})
398+
399+
test('alwaysApproveResubmit defaults to false', async () => {
400+
// Mock globalState.get to return undefined for alwaysApproveResubmit
401+
(mockContext.globalState.get as jest.Mock).mockReturnValue(undefined)
402+
403+
const state = await provider.getState()
404+
expect(state.alwaysApproveResubmit).toBe(false)
405+
})
406+
407+
test('handles request delay settings messages', async () => {
408+
provider.resolveWebviewView(mockWebviewView)
409+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
410+
411+
// Test alwaysApproveResubmit
412+
await messageHandler({ type: 'alwaysApproveResubmit', bool: true })
413+
expect(mockContext.globalState.update).toHaveBeenCalledWith('alwaysApproveResubmit', true)
414+
expect(mockPostMessage).toHaveBeenCalled()
415+
416+
// Test requestDelaySeconds
417+
await messageHandler({ type: 'requestDelaySeconds', value: 10 })
418+
expect(mockContext.globalState.update).toHaveBeenCalledWith('requestDelaySeconds', 10)
419+
expect(mockPostMessage).toHaveBeenCalled()
420+
})
421+
385422
test('file content includes line numbers', async () => {
386423
const { extractTextFromFile } = require('../../../integrations/misc/extract-text')
387424
const result = await extractTextFromFile('test.js')

src/shared/ExtensionMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface ExtensionState {
5656
alwaysAllowExecute?: boolean
5757
alwaysAllowBrowser?: boolean
5858
alwaysAllowMcp?: boolean
59+
alwaysApproveResubmit?: boolean
60+
requestDelaySeconds: number
5961
uriScheme?: string
6062
allowedCommands?: string[]
6163
soundEnabled?: boolean
@@ -103,6 +105,7 @@ export type ClineSay =
103105
| "user_feedback"
104106
| "user_feedback_diff"
105107
| "api_req_retried"
108+
| "api_req_retry_delayed"
106109
| "command_output"
107110
| "tool"
108111
| "shell_integration_warning"

src/shared/WebviewMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export interface WebviewMessage {
5252
| "terminalOutputLineLimit"
5353
| "mcpEnabled"
5454
| "searchCommits"
55+
| "alwaysApproveResubmit"
56+
| "requestDelaySeconds"
5557
text?: string
5658
disabled?: boolean
5759
askResponse?: ClineAskResponse

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
5151
terminalOutputLineLimit,
5252
setTerminalOutputLineLimit,
5353
mcpEnabled,
54+
alwaysApproveResubmit,
55+
setAlwaysApproveResubmit,
56+
requestDelaySeconds,
57+
setRequestDelaySeconds,
5458
} = useExtensionState()
5559
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
5660
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
@@ -83,6 +87,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
8387
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
8488
vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
8589
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
90+
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
91+
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
8692
onDone()
8793
}
8894
}
@@ -355,11 +361,47 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
355361
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
356362
</VSCodeCheckbox>
357363
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
358-
Automatically perform browser actions without requiring approval<br/>
364+
Automatically perform browser actions without requiring approval<br />
359365
Note: Only applies when the model supports computer use
360366
</p>
361367
</div>
362368

369+
<div style={{ marginBottom: 5 }}>
370+
<VSCodeCheckbox
371+
checked={alwaysApproveResubmit}
372+
onChange={(e: any) => setAlwaysApproveResubmit(e.target.checked)}>
373+
<span style={{ fontWeight: "500" }}>Always retry failed API requests</span>
374+
</VSCodeCheckbox>
375+
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
376+
Automatically retry failed API requests when server returns an error response
377+
</p>
378+
{alwaysApproveResubmit && (
379+
<div style={{ marginTop: 10 }}>
380+
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
381+
<input
382+
type="range"
383+
min="0"
384+
max="100"
385+
step="1"
386+
value={requestDelaySeconds}
387+
onChange={(e) => setRequestDelaySeconds(parseInt(e.target.value))}
388+
style={{
389+
flex: 1,
390+
accentColor: 'var(--vscode-button-background)',
391+
height: '2px'
392+
}}
393+
/>
394+
<span style={{ minWidth: '45px', textAlign: 'left' }}>
395+
{requestDelaySeconds}s
396+
</span>
397+
</div>
398+
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
399+
Delay before retrying the request
400+
</p>
401+
</div>
402+
)}
403+
</div>
404+
363405
<div style={{ marginBottom: 5 }}>
364406
<VSCodeCheckbox
365407
checked={alwaysAllowMcp}
@@ -525,7 +567,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
525567

526568
<div style={{ marginBottom: 5 }}>
527569
<div style={{ marginBottom: 10 }}>
528-
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Notification Settings</h3>
570+
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Notification Settings</h3>
529571
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
530572
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
531573
</VSCodeCheckbox>

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export interface ExtensionStateContextType extends ExtensionState {
4646
setTerminalOutputLineLimit: (value: number) => void
4747
mcpEnabled: boolean
4848
setMcpEnabled: (value: boolean) => void
49+
alwaysApproveResubmit?: boolean
50+
setAlwaysApproveResubmit: (value: boolean) => void
51+
requestDelaySeconds: number
52+
setRequestDelaySeconds: (value: number) => void
4953
}
5054

5155
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -67,6 +71,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
6771
screenshotQuality: 75,
6872
terminalOutputLineLimit: 500,
6973
mcpEnabled: true,
74+
alwaysApproveResubmit: false,
75+
requestDelaySeconds: 5
7076
})
7177
const [didHydrateState, setDidHydrateState] = useState(false)
7278
const [showWelcome, setShowWelcome] = useState(false)
@@ -201,6 +207,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
201207
setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
202208
setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
203209
setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
210+
setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })),
211+
setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value }))
204212
}
205213

206214
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

0 commit comments

Comments
 (0)