Skip to content

fix: add null check for regex match to prevent "Cannot read properties of undefined" error #6097

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1832,7 +1832,7 @@ export class Task extends EventEmitter<ClineEvents> {
)
if (geminiRetryDetails) {
const match = geminiRetryDetails?.retryDelay?.match(/^(\d+)s$/)
if (match) {
if (match && match[1]) {
exponentialDelay = Number(match[1]) + 1
}
}
Expand Down
359 changes: 359 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,5 +1493,364 @@ describe("Cline", () => {
expect(noModelTask.apiConfiguration.apiProvider).toBe("openai")
})
})

describe("429 Retry with Gemini Error Details", () => {
it("should handle 429 errors with Gemini retry details safely", async () => {
const [cline, task] = Task.create({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
})

// Mock delay to track countdown timing
const mockDelay = vi.fn().mockResolvedValue(undefined)
vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay)

// Mock say to track messages
const saySpy = vi.spyOn(cline, "say")

// Create a 429 error with Gemini retry details
const mockError = {
status: 429,
message: "Rate limit exceeded",
errorDetails: [
{
"@type": "type.googleapis.com/google.rpc.RetryInfo",
retryDelay: "10s", // Valid format
},
],
}

// Create a stream that fails on first chunk
const mockFailedStream = {
// eslint-disable-next-line require-yield
async *[Symbol.asyncIterator]() {
throw mockError
},
async next() {
throw mockError
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
async [Symbol.asyncDispose]() {
// Cleanup
},
} as AsyncGenerator<ApiStreamChunk>

// Create a successful stream for retry
const mockSuccessStream = {
async *[Symbol.asyncIterator]() {
yield { type: "text", text: "Success" }
},
async next() {
return { done: true, value: { type: "text", text: "Success" } }
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
async [Symbol.asyncDispose]() {
// Cleanup
},
} as AsyncGenerator<ApiStreamChunk>

// Mock createMessage to fail first then succeed
let firstAttempt = true
vi.spyOn(cline.api, "createMessage").mockImplementation(() => {
if (firstAttempt) {
firstAttempt = false
return mockFailedStream
}
return mockSuccessStream
})

// Set alwaysApproveResubmit
mockProvider.getState = vi.fn().mockResolvedValue({
alwaysApproveResubmit: true,
requestDelaySeconds: 5,
})

// Mock previous API request message
cline.clineMessages = [
{
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
tokensIn: 100,
tokensOut: 50,
cacheWrites: 0,
cacheReads: 0,
request: "test request",
}),
},
]

// Trigger API request
const iterator = cline.attemptApiRequest(0)
await iterator.next()

// Verify the delay was calculated correctly (10 + 1 = 11 seconds)
const expectedDelay = 11
expect(mockDelay).toHaveBeenCalledTimes(expectedDelay)

// Verify countdown messages
for (let i = expectedDelay; i > 0; i--) {
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining(`Retrying in ${i} seconds`),
undefined,
true,
)
}

await cline.abortTask(true)
await task.catch(() => {})
})

it("should handle 429 errors with invalid Gemini retry details", async () => {
const [cline, task] = Task.create({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
})

// Mock delay to track countdown timing
const mockDelay = vi.fn().mockResolvedValue(undefined)
vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay)

// Mock say to track messages
const saySpy = vi.spyOn(cline, "say")

// Create a 429 error with invalid Gemini retry details
const mockError = {
status: 429,
message: "Rate limit exceeded",
errorDetails: [
{
"@type": "type.googleapis.com/google.rpc.RetryInfo",
retryDelay: "invalid", // Invalid format - should not match regex
},
],
}

// Create a stream that fails on first chunk
const mockFailedStream = {
// eslint-disable-next-line require-yield
async *[Symbol.asyncIterator]() {
throw mockError
},
async next() {
throw mockError
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
async [Symbol.asyncDispose]() {
// Cleanup
},
} as AsyncGenerator<ApiStreamChunk>

// Create a successful stream for retry
const mockSuccessStream = {
async *[Symbol.asyncIterator]() {
yield { type: "text", text: "Success" }
},
async next() {
return { done: true, value: { type: "text", text: "Success" } }
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
async [Symbol.asyncDispose]() {
// Cleanup
},
} as AsyncGenerator<ApiStreamChunk>

// Mock createMessage to fail first then succeed
let firstAttempt = true
vi.spyOn(cline.api, "createMessage").mockImplementation(() => {
if (firstAttempt) {
firstAttempt = false
return mockFailedStream
}
return mockSuccessStream
})

// Set alwaysApproveResubmit
mockProvider.getState = vi.fn().mockResolvedValue({
alwaysApproveResubmit: true,
requestDelaySeconds: 5,
})

// Mock previous API request message
cline.clineMessages = [
{
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
tokensIn: 100,
tokensOut: 50,
cacheWrites: 0,
cacheReads: 0,
request: "test request",
}),
},
]

// Trigger API request
const iterator = cline.attemptApiRequest(0)
await iterator.next()

// Should fall back to exponential backoff (5 seconds base delay)
const expectedDelay = 5
expect(mockDelay).toHaveBeenCalledTimes(expectedDelay)

// Verify countdown messages
for (let i = expectedDelay; i > 0; i--) {
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining(`Retrying in ${i} seconds`),
undefined,
true,
)
}

await cline.abortTask(true)
await task.catch(() => {})
})

it("should handle 429 errors with missing Gemini retry details", async () => {
const [cline, task] = Task.create({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
})

// Mock delay to track countdown timing
const mockDelay = vi.fn().mockResolvedValue(undefined)
vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay)

// Mock say to track messages
const saySpy = vi.spyOn(cline, "say")

// Create a 429 error without retryDelay property
const mockError = {
status: 429,
message: "Rate limit exceeded",
errorDetails: [
{
"@type": "type.googleapis.com/google.rpc.RetryInfo",
// No retryDelay property
},
],
}

// Create a stream that fails on first chunk
const mockFailedStream = {
// eslint-disable-next-line require-yield
async *[Symbol.asyncIterator]() {
throw mockError
},
async next() {
throw mockError
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
async [Symbol.asyncDispose]() {
// Cleanup
},
} as AsyncGenerator<ApiStreamChunk>

// Create a successful stream for retry
const mockSuccessStream = {
async *[Symbol.asyncIterator]() {
yield { type: "text", text: "Success" }
},
async next() {
return { done: true, value: { type: "text", text: "Success" } }
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
async [Symbol.asyncDispose]() {
// Cleanup
},
} as AsyncGenerator<ApiStreamChunk>

// Mock createMessage to fail first then succeed
let firstAttempt = true
vi.spyOn(cline.api, "createMessage").mockImplementation(() => {
if (firstAttempt) {
firstAttempt = false
return mockFailedStream
}
return mockSuccessStream
})

// Set alwaysApproveResubmit
mockProvider.getState = vi.fn().mockResolvedValue({
alwaysApproveResubmit: true,
requestDelaySeconds: 5,
})

// Mock previous API request message
cline.clineMessages = [
{
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
tokensIn: 100,
tokensOut: 50,
cacheWrites: 0,
cacheReads: 0,
request: "test request",
}),
},
]

// Trigger API request
const iterator = cline.attemptApiRequest(0)
await iterator.next()

// Should fall back to exponential backoff (5 seconds base delay)
const expectedDelay = 5
expect(mockDelay).toHaveBeenCalledTimes(expectedDelay)

// Verify countdown messages
for (let i = expectedDelay; i > 0; i--) {
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining(`Retrying in ${i} seconds`),
undefined,
true,
)
}

await cline.abortTask(true)
await task.catch(() => {})
})
})
})
})
Loading