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": []
+}