Skip to content

Commit 052903b

Browse files
committed
Fix logic error in retry delays
1 parent 43d4c5c commit 052903b

File tree

3 files changed

+132
-19
lines changed

3 files changed

+132
-19
lines changed

.changeset/moody-fans-build.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+
Fix logic error in retry delays

src/core/Cline.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -822,29 +822,21 @@ export class Cline {
822822
const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, rateLimitSeconds } =
823823
(await this.providerRef.deref()?.getState()) ?? {}
824824

825-
let finalDelay = 0
825+
let rateLimitDelay = 0
826826

827827
// Only apply rate limiting if this isn't the first request
828828
if (this.lastApiRequestTime) {
829829
const now = Date.now()
830830
const timeSinceLastRequest = now - this.lastApiRequestTime
831831
const rateLimit = rateLimitSeconds || 0
832-
const rateLimitDelay = Math.max(0, rateLimit * 1000 - timeSinceLastRequest)
833-
finalDelay = rateLimitDelay
832+
rateLimitDelay = Math.ceil(Math.max(0, rateLimit * 1000 - timeSinceLastRequest) / 1000)
834833
}
835834

836-
// Add exponential backoff delay for retries
837-
if (retryAttempt > 0) {
838-
const baseDelay = requestDelaySeconds || 5
839-
const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, retryAttempt)) * 1000
840-
finalDelay = Math.max(finalDelay, exponentialDelay)
841-
}
842-
843-
if (finalDelay > 0) {
835+
// Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there.
836+
if (rateLimitDelay > 0 && retryAttempt === 0) {
844837
// Show countdown timer
845-
for (let i = Math.ceil(finalDelay / 1000); i > 0; i--) {
846-
const delayMessage =
847-
retryAttempt > 0 ? `Retrying in ${i} seconds...` : `Rate limiting for ${i} seconds...`
838+
for (let i = rateLimitDelay; i > 0; i--) {
839+
const delayMessage = `Rate limiting for ${i} seconds...`
848840
await this.say("api_req_retry_delayed", delayMessage, undefined, true)
849841
await delay(1000)
850842
}
@@ -959,9 +951,11 @@ export class Cline {
959951
const errorMsg = error.message ?? "Unknown error"
960952
const baseDelay = requestDelaySeconds || 5
961953
const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, retryAttempt))
954+
// Wait for the greater of the exponential delay or the rate limit delay
955+
const finalDelay = Math.max(exponentialDelay, rateLimitDelay)
962956

963957
// Show countdown timer with exponential backoff
964-
for (let i = exponentialDelay; i > 0; i--) {
958+
for (let i = finalDelay; i > 0; i--) {
965959
await this.say(
966960
"api_req_retry_delayed",
967961
`${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying in ${i} seconds...`,

src/core/__tests__/Cline.test.ts

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -772,10 +772,8 @@ describe("Cline", () => {
772772
false,
773773
)
774774

775-
// Calculate expected delay calls based on exponential backoff
776-
const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, 1)) // retryAttempt = 1
777-
const rateLimitDelay = baseDelay // Initial rate limit delay
778-
const totalExpectedDelays = exponentialDelay + rateLimitDelay
775+
// Calculate expected delay calls for countdown
776+
const totalExpectedDelays = baseDelay // One delay per second for countdown
779777
expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays)
780778
expect(mockDelay).toHaveBeenCalledWith(1000)
781779

@@ -786,6 +784,122 @@ describe("Cline", () => {
786784
)
787785
})
788786

787+
it("should not apply retry delay twice", async () => {
788+
const cline = new Cline(mockProvider, mockApiConfig, undefined, false, false, undefined, "test task")
789+
790+
// Mock delay to track countdown timing
791+
const mockDelay = jest.fn().mockResolvedValue(undefined)
792+
jest.spyOn(require("delay"), "default").mockImplementation(mockDelay)
793+
794+
// Mock say to track messages
795+
const saySpy = jest.spyOn(cline, "say")
796+
797+
// Create a stream that fails on first chunk
798+
const mockError = new Error("API Error")
799+
const mockFailedStream = {
800+
async *[Symbol.asyncIterator]() {
801+
throw mockError
802+
},
803+
async next() {
804+
throw mockError
805+
},
806+
async return() {
807+
return { done: true, value: undefined }
808+
},
809+
async throw(e: any) {
810+
throw e
811+
},
812+
async [Symbol.asyncDispose]() {
813+
// Cleanup
814+
},
815+
} as AsyncGenerator<ApiStreamChunk>
816+
817+
// Create a successful stream for retry
818+
const mockSuccessStream = {
819+
async *[Symbol.asyncIterator]() {
820+
yield { type: "text", text: "Success" }
821+
},
822+
async next() {
823+
return { done: true, value: { type: "text", text: "Success" } }
824+
},
825+
async return() {
826+
return { done: true, value: undefined }
827+
},
828+
async throw(e: any) {
829+
throw e
830+
},
831+
async [Symbol.asyncDispose]() {
832+
// Cleanup
833+
},
834+
} as AsyncGenerator<ApiStreamChunk>
835+
836+
// Mock createMessage to fail first then succeed
837+
let firstAttempt = true
838+
jest.spyOn(cline.api, "createMessage").mockImplementation(() => {
839+
if (firstAttempt) {
840+
firstAttempt = false
841+
return mockFailedStream
842+
}
843+
return mockSuccessStream
844+
})
845+
846+
// Set alwaysApproveResubmit and requestDelaySeconds
847+
mockProvider.getState = jest.fn().mockResolvedValue({
848+
alwaysApproveResubmit: true,
849+
requestDelaySeconds: 3,
850+
})
851+
852+
// Mock previous API request message
853+
cline.clineMessages = [
854+
{
855+
ts: Date.now(),
856+
type: "say",
857+
say: "api_req_started",
858+
text: JSON.stringify({
859+
tokensIn: 100,
860+
tokensOut: 50,
861+
cacheWrites: 0,
862+
cacheReads: 0,
863+
request: "test request",
864+
}),
865+
},
866+
]
867+
868+
// Trigger API request
869+
const iterator = cline.attemptApiRequest(0)
870+
await iterator.next()
871+
872+
// Verify delay is only applied for the countdown
873+
const baseDelay = 3 // from requestDelaySeconds
874+
const expectedDelayCount = baseDelay // One delay per second for countdown
875+
expect(mockDelay).toHaveBeenCalledTimes(expectedDelayCount)
876+
expect(mockDelay).toHaveBeenCalledWith(1000) // Each delay should be 1 second
877+
878+
// Verify countdown messages were only shown once
879+
const retryMessages = saySpy.mock.calls.filter(
880+
(call) => call[0] === "api_req_retry_delayed" && call[1]?.includes("Retrying in"),
881+
)
882+
expect(retryMessages).toHaveLength(baseDelay)
883+
884+
// Verify the retry message sequence
885+
for (let i = baseDelay; i > 0; i--) {
886+
expect(saySpy).toHaveBeenCalledWith(
887+
"api_req_retry_delayed",
888+
expect.stringContaining(`Retrying in ${i} seconds`),
889+
undefined,
890+
true,
891+
)
892+
}
893+
894+
// Verify final retry message
895+
expect(saySpy).toHaveBeenCalledWith(
896+
"api_req_retry_delayed",
897+
expect.stringContaining("Retrying now"),
898+
undefined,
899+
false,
900+
)
901+
})
902+
789903
describe("loadContext", () => {
790904
it("should process mentions in task and feedback tags", async () => {
791905
const cline = new Cline(

0 commit comments

Comments
 (0)