Retry requests when ChatGPT returns empty data
Occasionally, in stateful mode, ChatGPT may return an empty data field
while fetching run steps. This change implements a mechanism to resubmit
the request after a set interval and for a specified number of attempts.
Change-Id: Ide011146f4d02787b89ea0f21fe647b6dd9d701a
Signed-off-by: Patrizio <patrizio.gelosi@amarulasolutions.com>
diff --git a/pom.xml b/pom.xml
index 6f05725..64f95c5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -158,7 +158,7 @@
</dependency>
<dependency>
<groupId>org.mockito</groupId>
- <artifactId>mockito-core</artifactId>
+ <artifactId>mockito-inline</artifactId>
<version>4.5.1</version>
<scope>test</scope>
</dependency>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java
index 8c391bb..eee376b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java
@@ -66,7 +66,7 @@
isCommentEvent
);
chatGptRun.createRun();
- chatGptRun.pollRun();
+ chatGptRun.pollRunStep();
// Attribute `requestBody` is valued for testing purposes
requestBody = chatGptThreadMessage.getAddMessageRequestBody();
log.debug("ChatGPT request body: {}", requestBody);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptRun.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptRun.java
index 1fa4b0d..7e5ed34 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptRun.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptRun.java
@@ -18,10 +18,13 @@
import java.util.*;
import static com.googlesource.gerrit.plugins.chatgpt.utils.GsonUtils.getGson;
+import static com.googlesource.gerrit.plugins.chatgpt.utils.ThreadUtils.threadSleep;
@Slf4j
public class ChatGptRun extends ClientBase {
private static final int RUN_POLLING_INTERVAL = 1000;
+ private static final int STEP_RETRIEVAL_INTERVAL = 10000;
+ private static final int MAX_STEP_RETRIEVAL_RETRIES = 3;
private static final Set<String> UNCOMPLETED_STATUSES = new HashSet<>(Arrays.asList(
"queued",
"in_progress",
@@ -75,29 +78,23 @@
log.info("Run created: {}", runResponse);
}
- public void pollRun() {
- int pollingCount = 0;
+ public void pollRunStep() {
+ for (int retries = 0; retries < MAX_STEP_RETRIEVAL_RETRIES; retries++) {
+ int pollingCount = pollRun();
- while (UNCOMPLETED_STATUSES.contains(runResponse.getStatus())) {
- pollingCount++;
- log.debug("Polling request #{}", pollingCount);
- try {
- Thread.sleep(RUN_POLLING_INTERVAL);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new RuntimeException("Thread was interrupted", e);
+ Request stepsRequest = getStepsRequest();
+ log.debug("ChatGPT Retrieve Run Steps request: {}", stepsRequest);
+
+ String response = httpClient.execute(stepsRequest);
+ stepResponse = getGson().fromJson(response, ChatGptListResponse.class);
+ log.info("Run executed after {} polling requests: {}", pollingCount, stepResponse);
+ if (stepResponse.getData().isEmpty()) {
+ log.warn("Empty response from ChatGPT");
+ threadSleep(STEP_RETRIEVAL_INTERVAL);
+ continue;
}
- Request pollRequest = getPollRequest();
- log.debug("ChatGPT Poll Run request: {}", pollRequest);
- runResponse = getGson().fromJson(httpClient.execute(pollRequest), ChatGptResponse.class);
- log.debug("ChatGPT Run response: {}", runResponse);
+ return;
}
- Request stepsRequest = getStepsRequest();
- log.debug("ChatGPT Retrieve Run Steps request: {}", stepsRequest);
-
- String response = httpClient.execute(stepsRequest);
- stepResponse = getGson().fromJson(response, ChatGptListResponse.class);
- log.info("Run executed after {} polling requests: {}", pollingCount, stepResponse);
}
public ChatGptResponseMessage getFirstStepDetails() {
@@ -126,6 +123,21 @@
}
}
+ private int pollRun() {
+ int pollingCount = 0;
+
+ while (UNCOMPLETED_STATUSES.contains(runResponse.getStatus())) {
+ pollingCount++;
+ log.debug("Polling request #{}", pollingCount);
+ threadSleep(RUN_POLLING_INTERVAL);
+ Request pollRequest = getPollRequest();
+ log.debug("ChatGPT Poll Run request: {}", pollRequest);
+ runResponse = getGson().fromJson(httpClient.execute(pollRequest), ChatGptResponse.class);
+ log.debug("ChatGPT Run response: {}", runResponse);
+ }
+ return pollingCount;
+ }
+
private ChatGptRunStepsResponse getFirstStep() {
return stepResponse.getData().get(0);
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/utils/ThreadUtils.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/utils/ThreadUtils.java
new file mode 100644
index 0000000..f452649
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/utils/ThreadUtils.java
@@ -0,0 +1,12 @@
+package com.googlesource.gerrit.plugins.chatgpt.utils;
+
+public class ThreadUtils {
+ public static void threadSleep(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Thread was interrupted", e);
+ }
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewStatefulTest.java b/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewStatefulTest.java
index f47db19..995e16d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewStatefulTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewStatefulTest.java
@@ -14,12 +14,14 @@
import com.googlesource.gerrit.plugins.chatgpt.mode.stateful.client.prompt.ChatGptPromptStateful;
import com.googlesource.gerrit.plugins.chatgpt.mode.stateful.model.api.chatgpt.ChatGptListResponse;
import com.googlesource.gerrit.plugins.chatgpt.settings.Settings.MODES;
+import com.googlesource.gerrit.plugins.chatgpt.utils.ThreadUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.entity.ContentType;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.MockitoJUnitRunner;
@@ -139,13 +141,7 @@
.withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
.withBody("{\"status\": " + COMPLETED_STATUS + "}")));
- // Mock the behavior of the ChatGPT retrieve-run-steps request
- WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(URI.create(config.getGptDomain()
- + UriResourceLocatorStateful.runStepsUri(CHAT_GPT_THREAD_ID, CHAT_GPT_RUN_ID)).getPath()))
- .willReturn(WireMock.aResponse()
- .withStatus(HTTP_OK)
- .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
- .withBodyFile("chatGptRunStepsResponse.json")));
+ mockRetrieveRunSteps("chatGptRunStepsResponse.json");
// Mock the behavior of the formatted patch request
formattedPatchContent = readTestFile("__files/stateful/gerritFormattedPatch.txt");
@@ -184,6 +180,16 @@
return captor.getAllValues().get(0).comments.get(filename).get(0).message;
}
+ private void mockRetrieveRunSteps(String bodyFile) {
+ // Mock the behavior of the ChatGPT retrieve-run-steps request
+ WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(URI.create(config.getGptDomain()
+ + UriResourceLocatorStateful.runStepsUri(CHAT_GPT_THREAD_ID, CHAT_GPT_RUN_ID)).getPath()))
+ .willReturn(WireMock.aResponse()
+ .withStatus(HTTP_OK)
+ .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
+ .withBodyFile(bodyFile)));
+ }
+
@Test
public void patchSetCreatedOrUpdated() throws Exception {
String reviewMessageCode = getReviewMessage( "__files/chatGptRunStepsResponse.json", 0);
@@ -200,17 +206,38 @@
}
@Test
+ public void initialEmptyResponse() throws Exception {
+ // To effectively test how an initial empty response from ChatGPT is managed, the following approach is adopted:
+ // 1. the ChatGPT run-steps request is initially mocked to return an empty data field, and
+ // 2. the sleep function is mocked to replace the empty response with a valid one, instead of pausing execution
+ mockRetrieveRunSteps("chatGptRunStepsEmptyResponse.json");
+
+ try (MockedStatic<ThreadUtils> mocked = Mockito.mockStatic(ThreadUtils.class)) {
+ mocked.when(() -> ThreadUtils.threadSleep(Mockito.anyLong())).thenAnswer(invocation -> {
+ mockRetrieveRunSteps("chatGptRunStepsResponse.json");
+ return null;
+ });
+
+ String reviewMessageCode = getReviewMessage("__files/chatGptRunStepsResponse.json", 0);
+ String reviewMessageCommitMessage = getReviewMessage("__files/chatGptRunStepsResponse.json", 1);
+
+ String reviewUserPrompt = chatGptPromptStateful.getDefaultGptThreadReviewMessage(formattedPatchContent);
+
+ handleEventBasedOnType(SupportedEvents.PATCH_SET_CREATED);
+
+ ArgumentCaptor<ReviewInput> captor = testRequestSent();
+ Assert.assertEquals(reviewUserPrompt, requestContent);
+ Assert.assertEquals(reviewMessageCode, getCapturedMessage(captor, "test_file_1.py"));
+ Assert.assertEquals(reviewMessageCommitMessage, getCapturedMessage(captor, GERRIT_PATCH_SET_FILENAME));
+ }
+ }
+
+ @Test
public void gptMentionedInComment() throws RestApiException {
String reviewMessageCommitMessage = getReviewMessage("__files/chatGptResponseRequestStateful.json", 0);
chatGptPromptStateful.setCommentEvent(true);
- // Mock the behavior of the ChatGPT retrieve-run-steps request
- WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(URI.create(config.getGptDomain()
- + UriResourceLocatorStateful.runStepsUri(CHAT_GPT_THREAD_ID, CHAT_GPT_RUN_ID)).getPath()))
- .willReturn(WireMock.aResponse()
- .withStatus(HTTP_OK)
- .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
- .withBodyFile("chatGptResponseRequestStateful.json")));
+ mockRetrieveRunSteps("chatGptResponseRequestStateful.json");
handleEventBasedOnType(SupportedEvents.COMMENT_ADDED);
@@ -224,13 +251,7 @@
String reviewMessageCommitMessage = getReviewMessage("__files/chatGptResponseRequestStateful.json", 0);
chatGptPromptStateful.setCommentEvent(true);
- // Mock the behavior of the ChatGPT retrieve-run-steps request
- WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(URI.create(config.getGptDomain()
- + UriResourceLocatorStateful.runStepsUri(CHAT_GPT_THREAD_ID, CHAT_GPT_RUN_ID)).getPath()))
- .willReturn(WireMock.aResponse()
- .withStatus(HTTP_OK)
- .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
- .withBodyFile("chatGptResponseRequestMessageStateful.json")));
+ mockRetrieveRunSteps("chatGptResponseRequestMessageStateful.json");
WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(URI.create(config.getGptDomain()
+ UriResourceLocatorStateful.threadMessageRetrieveUri(CHAT_GPT_THREAD_ID, CHAT_GPT_MESSAGE_ID)).getPath()))
.willReturn(WireMock.aResponse()
@@ -250,13 +271,7 @@
String reviewMessageCommitMessage = getReviewMessage("__files/chatGptResponseRequestStateful.json", 0);
chatGptPromptStateful.setCommentEvent(true);
- // Mock the behavior of the ChatGPT retrieve-run-steps request
- WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(URI.create(config.getGptDomain()
- + UriResourceLocatorStateful.runStepsUri(CHAT_GPT_THREAD_ID, CHAT_GPT_RUN_ID)).getPath()))
- .willReturn(WireMock.aResponse()
- .withStatus(HTTP_OK)
- .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
- .withBodyFile("chatGptResponseRequestMessageStateful.json")));
+ mockRetrieveRunSteps("chatGptResponseRequestMessageStateful.json");
WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(URI.create(config.getGptDomain()
+ UriResourceLocatorStateful.threadMessageRetrieveUri(CHAT_GPT_THREAD_ID, CHAT_GPT_MESSAGE_ID)).getPath()))
.willReturn(WireMock.aResponse()
diff --git a/src/test/resources/__files/chatGptRunStepsEmptyResponse.json b/src/test/resources/__files/chatGptRunStepsEmptyResponse.json
new file mode 100644
index 0000000..0353d69
--- /dev/null
+++ b/src/test/resources/__files/chatGptRunStepsEmptyResponse.json
@@ -0,0 +1,4 @@
+{
+ "object": "list",
+ "data": []
+}