Introduce readTimeout and connectTimeout to Jira

Define explicit timeouts for connection and read from Jira
backend. Allows to prevent incoming git-receive-pack threads
to become blocked because of a slowdown of connectivity
or execution times on the Jira side.

Bug: Issue 15702
Change-Id: Id94892a50363d5be3fee4007c1357cf64927ce41
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java
index a5c16ec..ccab69c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.ConfigUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.its.base.its.InitIts;
@@ -30,7 +31,9 @@
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraURL;
 import java.io.IOException;
 import java.net.MalformedURLException;
+import java.time.Duration;
 import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Initialize the GitRepositoryManager configuration section. */
@@ -43,6 +46,8 @@
   private JiraURL jiraUrl;
   private String jiraUsername;
   private String jiraPassword;
+  private String jiraConnectionTimeout;
+  private String jiraReadTimeout;
 
   @Inject
   InitJira(
@@ -123,9 +128,24 @@
   public void enterJiraConnectivity() throws MalformedURLException {
     String jiraUrlString = jira.string("Jira URL (empty to skip)", "url", null);
     if (jiraUrlString != null) {
-      jiraUrl = new JiraURL(jiraUrlString);
       jiraUsername = jira.string("Jira username", "username", "");
       jiraPassword = jira.password("username", "password");
+      jiraConnectionTimeout =
+          jira.string(
+              "Connection timeout",
+              JiraConfig.GERRIT_CONFIG_CONNECT_TIMEOUT,
+              JiraConfig.GERRIT_CONFIG_CONNECT_TIMEOUT_DEFAULT.toMillis() + " ms");
+      jiraReadTimeout =
+          jira.string(
+              "Read timeout",
+              JiraConfig.GERRIT_CONFIG_READ_TIMEOUT,
+              JiraConfig.GERRIT_CONFIG_READ_TIMEOUT_DEFAULT.toMillis() + " ms");
+      jiraUrl =
+          new JiraURL(
+              jiraUrlString,
+              Duration.ofMillis(
+                  ConfigUtil.getTimeUnit(jiraConnectionTimeout, 0, TimeUnit.MILLISECONDS)),
+              Duration.ofMillis(ConfigUtil.getTimeUnit(jiraReadTimeout, 0, TimeUnit.MILLISECONDS)));
     }
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java
index 339d16c..b0e8bcb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.StoredCommentLinkInfo;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
@@ -35,6 +36,8 @@
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraURL;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVisibilityType;
 import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -58,6 +61,10 @@
   private static final String GERRIT_CONFIG_PASSWORD = "password";
   private static final String GERRIT_CONFIG_COMMENT_VISIBILITY_TYPE = "visibilityType";
   private static final String GERRIT_CONFIG_COMMENT_VISIBILITY_VALUE = "visibilityValue";
+  public static final String GERRIT_CONFIG_CONNECT_TIMEOUT = "connectTimeout";
+  public static final Duration GERRIT_CONFIG_CONNECT_TIMEOUT_DEFAULT = Duration.ofMinutes(2);
+  public static final String GERRIT_CONFIG_READ_TIMEOUT = "readTimeout";
+  public static final Duration GERRIT_CONFIG_READ_TIMEOUT_DEFAULT = Duration.ofSeconds(30);
 
   private final String pluginName;
   private final PluginConfigFactory cfgFactory;
@@ -96,9 +103,33 @@
             gerritConfig.getEnum(
                 pluginName, null, GERRIT_CONFIG_COMMENT_VISIBILITY_TYPE, JiraVisibilityType.NOTSET),
             gerritConfig.getString(pluginName, null, GERRIT_CONFIG_COMMENT_VISIBILITY_VALUE))
+        .connectTimeout(
+            getDurationFromConfig(
+                gerritConfig,
+                pluginName,
+                GERRIT_CONFIG_CONNECT_TIMEOUT,
+                GERRIT_CONFIG_CONNECT_TIMEOUT_DEFAULT))
+        .readTimeout(
+            getDurationFromConfig(
+                gerritConfig,
+                pluginName,
+                GERRIT_CONFIG_READ_TIMEOUT,
+                GERRIT_CONFIG_READ_TIMEOUT_DEFAULT))
         .build();
   }
 
+  private static Duration getDurationFromConfig(
+      Config gerritConfig, String pluginName, String setting, Duration defaultDuration) {
+    return Duration.ofMillis(
+        ConfigUtil.getTimeUnit(
+            gerritConfig,
+            pluginName,
+            null,
+            setting,
+            defaultDuration.toMillis(),
+            TimeUnit.MILLISECONDS));
+  }
+
   JiraItsServerInfo getDefaultServerInfo() {
     return defaultJiraServerInfo;
   }
@@ -119,9 +150,24 @@
                 PROJECT_CONFIG_COMMENT_VISIBILITY_TYPE,
                 JiraVisibilityType.NOTSET),
             pluginConfig.getString(PROJECT_CONFIG_COMMENT_VISIBILITY_VALUE, null))
+        .connectTimeout(
+            getDurationFromConfig(
+                pluginConfig, GERRIT_CONFIG_CONNECT_TIMEOUT, GERRIT_CONFIG_CONNECT_TIMEOUT_DEFAULT))
+        .readTimeout(
+            getDurationFromConfig(
+                pluginConfig, GERRIT_CONFIG_READ_TIMEOUT, GERRIT_CONFIG_READ_TIMEOUT_DEFAULT))
         .build();
   }
 
+  private static Duration getDurationFromConfig(
+      PluginConfig pluginConfig, String setting, Duration defaultDuration) {
+    return Duration.ofMillis(
+        ConfigUtil.getTimeUnit(
+            pluginConfig.getString(setting, ""),
+            defaultDuration.toMillis(),
+            TimeUnit.MILLISECONDS));
+  }
+
   void addCommentLinksSection(Project.NameKey projectName, JiraItsServerInfo jiraItsServerInfo) {
     try (Repository git = repoManager.openRepository(projectName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, git)) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java
index 4ffb72d..5a92d84 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java
@@ -15,25 +15,27 @@
 
 package com.googlesource.gerrit.plugins.its.jira;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraURL;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVisibility;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVisibilityType;
 import java.net.MalformedURLException;
+import java.time.Duration;
 
 public class JiraItsServerInfo {
 
   public static class Builder {
     private JiraItsServerInfo instance = new JiraItsServerInfo();
+    private String projectUrl;
+    private Duration connectTimeout;
+    private Duration readTimeout;
 
     private Builder() {}
 
     public Builder url(String projectUrl) {
-      try {
-        instance.url = projectUrl != null ? new JiraURL(projectUrl) : null;
-        return this;
-      } catch (MalformedURLException e) {
-        throw new IllegalArgumentException("Unable to resolve URL", e);
-      }
+      this.projectUrl = projectUrl;
+      return this;
     }
 
     public Builder username(String username) {
@@ -55,8 +57,26 @@
       return this;
     }
 
+    public Builder connectTimeout(Duration timeout) {
+      this.connectTimeout = timeout;
+      return this;
+    }
+
+    public Builder readTimeout(Duration timeout) {
+      this.readTimeout = timeout;
+      return this;
+    }
+
     public JiraItsServerInfo build() {
-      return instance;
+      try {
+        checkNotNull(connectTimeout, "Missing connection timeout");
+        checkNotNull(readTimeout, "Missing read timeout");
+        instance.url =
+            projectUrl != null ? new JiraURL(projectUrl, connectTimeout, readTimeout) : null;
+        return instance;
+      } catch (MalformedURLException e) {
+        throw new IllegalArgumentException("Unable to resolve URL", e);
+      }
     }
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraUrlProjectConfigEntry.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraUrlProjectConfigEntry.java
index 40c248c..247720e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraUrlProjectConfigEntry.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraUrlProjectConfigEntry.java
@@ -37,7 +37,7 @@
   public ConfigValue preUpdate(ConfigValue configValue) {
     if (configValue.value != null && !configValue.value.isEmpty()) {
       try {
-        new JiraURL(configValue.value);
+        JiraURL.validateUrl(configValue.value);
       } catch (MalformedURLException e) {
         configValue.value = INVALID_URL_MSG;
       }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java
index ceec13d..ef4c43f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java
@@ -70,7 +70,7 @@
    * @throws IOException generated if unexpected failCode is returned
    */
   public T doGet(String spec, int passCode, int[] failCodes) throws IOException {
-    HttpURLConnection conn = prepHttpConnection(spec, "GET", false);
+    HttpURLConnection conn = openConnection(spec, "GET", false);
     try {
       if (validateResponse(conn, passCode, failCodes)) {
         readIncomingData(conn);
@@ -99,18 +99,8 @@
     return sendPayload("PUT", spec, jsonInput, passCode);
   }
 
-  private boolean sendPayload(String method, String spec, String jsonInput, int passCode)
-      throws IOException {
-    HttpURLConnection conn = prepHttpConnection(spec, method, true);
-    try {
-      writeBodyData(jsonInput, conn);
-      return validateResponse(conn, passCode, null);
-    } finally {
-      conn.disconnect();
-    }
-  }
-
-  private HttpURLConnection prepHttpConnection(String spec, String method, boolean withPayload)
+  /** Open an HTTP connection with the Jira's endpoint URL. */
+  public HttpURLConnection openConnection(String spec, String method, boolean withPayload)
       throws IOException {
     JiraURL url = baseUrl.withSpec(spec);
     ProxySelector proxySelector = ProxySelector.getDefault();
@@ -125,6 +115,17 @@
     return conn;
   }
 
+  private boolean sendPayload(String method, String spec, String jsonInput, int passCode)
+      throws IOException {
+    HttpURLConnection conn = openConnection(spec, method, true);
+    try {
+      writeBodyData(jsonInput, conn);
+      return validateResponse(conn, passCode, null);
+    } finally {
+      conn.disconnect();
+    }
+  }
+
   /** Write the data to the HTTP connection. */
   private void writeBodyData(String data, HttpURLConnection conn) throws IOException {
     if (data != null) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraURL.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraURL.java
index 0de96d8..6f31f39 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraURL.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraURL.java
@@ -23,6 +23,7 @@
 import java.net.Proxy;
 import java.net.ProxySelector;
 import java.net.URL;
+import java.time.Duration;
 import java.util.Objects;
 import org.eclipse.jgit.util.HttpSupport;
 import org.slf4j.Logger;
@@ -36,18 +37,31 @@
 
   private final URL url;
 
-  public JiraURL(String spec) throws MalformedURLException {
-    this.url = new URL(CharMatcher.is('/').trimFrom(spec) + "/");
+  private final int connectionTimeoutMs;
+
+  private final int readTimeoutMs;
+
+  public static URL validateUrl(String spec) throws MalformedURLException {
+    return new URL(CharMatcher.is('/').trimFrom(spec) + "/");
   }
 
-  private JiraURL(URL url) {
+  public JiraURL(String spec, Duration connectionTimeout, Duration readTimeout)
+      throws MalformedURLException {
+    this.url = validateUrl(spec);
+    this.connectionTimeoutMs = (int) connectionTimeout.toMillis();
+    this.readTimeoutMs = (int) readTimeout.toMillis();
+  }
+
+  private JiraURL(URL url, int connectionTimeoutMs, int readTimeoutMs) {
     this.url = requireNonNull(url);
+    this.connectionTimeoutMs = connectionTimeoutMs;
+    this.readTimeoutMs = readTimeoutMs;
   }
 
   public JiraURL resolveUrl(String... paths) {
     String relativePath = String.join("", paths);
     try {
-      return new JiraURL(new URL(url, relativePath));
+      return new JiraURL(new URL(url, relativePath), connectionTimeoutMs, readTimeoutMs);
     } catch (MalformedURLException e) {
       log.error("Unexpected exception while composing URL {} with path {}", url, relativePath, e);
       throw new IllegalArgumentException(e);
@@ -63,12 +77,15 @@
   }
 
   public JiraURL withSpec(String spec) throws MalformedURLException {
-    return new JiraURL(new URL(url, spec));
+    return new JiraURL(new URL(url, spec), connectionTimeoutMs, readTimeoutMs);
   }
 
   public HttpURLConnection openConnection(ProxySelector proxySelector) throws IOException {
     Proxy proxy = HttpSupport.proxyFor(proxySelector, url);
-    return (HttpURLConnection) url.openConnection(proxy);
+    HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy);
+    conn.setConnectTimeout(connectionTimeoutMs);
+    conn.setReadTimeout(readTimeoutMs);
+    return conn;
   }
 
   public String getPath() {
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index a86b80c..4827dfd 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -101,6 +101,24 @@
 
 Jira credentials and connectivity details are asked and verified during the Gerrit init.
 
+Timeouts
+--------
+
+The HTTP connection and read timeouts are configurable in every place where the Jira URL is specified,
+either in the `gerrit.config` or `project.config`, whichever has the precedence.
+
+`connectTimeout`
+:	Timeout period for making a new HTTP connection to the Jira URL.
+	The value is in the usual time-unit format like "1 s", "100 ms", etc.
+	By default `2 minutes`.
+
+`readTimeout`
+:	The read timeout for the HTTP operation to complete.
+	The value is in the usual time-unit format like "1 s", "100 ms", etc.
+	A timeout can be used to avoid blocking all of the incoming Git receive-packs threads in case
+	Jira server becomes slow.
+	By default `30 seconds`.
+
 Gerrit init integration
 -----------------------
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiTest.java
index d3b4812..27c5fc7 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiTest.java
@@ -22,6 +22,7 @@
 import java.io.ByteArrayOutputStream;
 import java.net.HttpURLConnection;
 import java.net.MalformedURLException;
+import java.time.Duration;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.junit.MockitoJUnitRunner;
@@ -33,17 +34,20 @@
   private static final String JSON_PAYLOAD = "{}";
   private static final String USERNAME = "user";
   private static final String PASSWORD = "pass";
+  private static final Duration CONNECTION_TIMEOUT = Duration.ofSeconds(30);
+  private static final Duration READ_TIMEOUT = Duration.ofSeconds(30);
 
   private JiraURL url;
   private JiraRestApi restApi;
 
-  private void setURL(String jiraUrl) throws MalformedURLException {
-    url = new JiraURL(jiraUrl);
+  private void setURL(String jiraUrl, Duration connectionTimeout, Duration readTimeout)
+      throws MalformedURLException {
+    url = new JiraURL(jiraUrl, connectionTimeout, readTimeout);
   }
 
   @Test
   public void testJiraServerInfoForNonRootJiraUrl() throws Exception {
-    setURL("http://jira.mycompany.com/myroot/");
+    setURL("http://jira.mycompany.com/myroot/", CONNECTION_TIMEOUT, READ_TIMEOUT);
     restApi = new JiraRestApi(url, USERNAME, PASSWORD, JiraIssue.class, ISSUE_CLASS_PREFIX);
     String jiraApiUrl = restApi.getBaseUrl().toString();
     assertThat(jiraApiUrl).startsWith(url.toString());
@@ -51,7 +55,7 @@
 
   @Test
   public void testJiraServerInfoForNonRootJiraUrlNotEndingWithSlash() throws Exception {
-    setURL("http://jira.mycompany.com/myroot");
+    setURL("http://jira.mycompany.com/myroot", CONNECTION_TIMEOUT, READ_TIMEOUT);
     restApi = new JiraRestApi(url, USERNAME, PASSWORD, JiraIssue.class, ISSUE_CLASS_PREFIX);
     String jiraApiUrl = restApi.getBaseUrl().toString();
     assertThat(jiraApiUrl).startsWith(url.toString());
@@ -59,13 +63,24 @@
 
   @Test
   public void testJiraServerInfoForRootJiraUrl() throws Exception {
-    setURL("http://jira.mycompany.com/myroot");
+    setURL("http://jira.mycompany.com/myroot", CONNECTION_TIMEOUT, READ_TIMEOUT);
     restApi = new JiraRestApi(url, USERNAME, PASSWORD, JiraIssue.class, ISSUE_CLASS_PREFIX);
     String jiraApiUrl = restApi.getBaseUrl().toString();
     assertThat(jiraApiUrl).startsWith(url.toString());
   }
 
   @Test
+  public void testJiraServerInfoWithTimeouts() throws Exception {
+    Duration connectionTimeout = Duration.ofSeconds(500L);
+    Duration readTimeout = Duration.ofMillis(10L);
+    setURL("http://jira.mycompany.com/myroot", connectionTimeout, readTimeout);
+    restApi = new JiraRestApi(url, USERNAME, PASSWORD, JiraIssue.class, ISSUE_CLASS_PREFIX);
+    HttpURLConnection httpConnection = restApi.openConnection("/", "GET", false);
+    assertThat(httpConnection.getConnectTimeout()).isEqualTo(connectionTimeout.toMillis());
+    assertThat(httpConnection.getReadTimeout()).isEqualTo(readTimeout.toMillis());
+  }
+
+  @Test
   public void testDoPut() throws Exception {
     JiraURL url = mock(JiraURL.class);
     when(url.resolveUrl(any())).thenReturn(url);