You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kyuubi.apache.org by ul...@apache.org on 2022/06/15 06:40:25 UTC

[incubator-kyuubi] branch master updated: [KYUUBI #2881] [SUB-TASK][KPIP-4] Rest client supports retry request if catch net exception

This is an automated email from the ASF dual-hosted git repository.

ulyssesyou pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-kyuubi.git


The following commit(s) were added to refs/heads/master by this push:
     new dea68bc0e [KYUUBI #2881] [SUB-TASK][KPIP-4] Rest client supports retry request if catch net exception
dea68bc0e is described below

commit dea68bc0e557662f4596483a41b0b94f1553bf13
Author: ulysses-you <ul...@gmail.com>
AuthorDate: Wed Jun 15 14:40:13 2022 +0800

    [KYUUBI #2881] [SUB-TASK][KPIP-4] Rest client supports retry request if catch net exception
    
    ### _Why are the changes needed?_
    
    close #2880
    
    The main changes:
    - Add a interface IRestClient for the underlying rest client
    - Add KyuubiRetryableException to make call side aware whether attempts
    - Add RetryableRestClient which delegates the request to the real RestClient and provide a functionality of request attempts
    - Make KyuubiRestClient supports multi-hostUrls
    
    ### _How was this patch tested?_
    - [x] Add some test cases that check the changes thoroughly including negative and positive cases if possible
    
    - [ ] Add screenshots for manual tests if appropriate
    
    - [ ] [Run test](https://kyuubi.apache.org/docs/latest/develop_tools/testing.html#running-tests) locally before make a pull request
    
    Closes #2881 from ulysses-you/rest-retry.
    
    Closes #2881
    
    edd3934a [ulysses-you] comment
    df94e718 [ulysses-you] fix
    5e163433 [ulysses-you] Rest client supports retry request if catch net exception
    
    Authored-by: ulysses-you <ul...@gmail.com>
    Signed-off-by: ulysses-you <ul...@apache.org>
---
 .../org/apache/kyuubi/client/BatchRestApi.java     |   2 +-
 .../apache/kyuubi/client/HttpClientFactory.java    |  56 ++++++++++
 .../java/org/apache/kyuubi/client/IRestClient.java |  35 +++++++
 .../org/apache/kyuubi/client/KyuubiRestClient.java | 114 +++++++++++----------
 .../java/org/apache/kyuubi/client/RestClient.java  |  14 ++-
 .../org/apache/kyuubi/client/RestClientConf.java   |  57 +++++++++++
 .../apache/kyuubi/client/RetryableRestClient.java  | 107 +++++++++++++++++++
 .../client/exception/KyuubiRetryableException.java |  28 +++++
 .../apache/kyuubi/client/BatchRestClientTest.java  |   4 +-
 9 files changed, 361 insertions(+), 56 deletions(-)

diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java
index a9c064477..5219d51b9 100644
--- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java
@@ -89,7 +89,7 @@ public class BatchRestApi {
     return this.getClient().delete(path, params, CloseBatchResponse.class, client.getAuthHeader());
   }
 
-  private RestClient getClient() {
+  private IRestClient getClient() {
     return this.client.getHttpClient();
   }
 }
diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/HttpClientFactory.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/HttpClientFactory.java
new file mode 100644
index 000000000..2ec308c6d
--- /dev/null
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/HttpClientFactory.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.kyuubi.client;
+
+import javax.net.ssl.SSLContext;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.conn.ssl.TrustStrategy;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.ssl.SSLContexts;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class HttpClientFactory {
+  private static final Logger LOG = LoggerFactory.getLogger(HttpClientFactory.class);
+
+  public static CloseableHttpClient createHttpClient(RestClientConf conf) {
+    RequestConfig requestConfig =
+        RequestConfig.custom()
+            .setSocketTimeout(conf.getSocketTimeout())
+            .setConnectTimeout(conf.getConnectTimeout())
+            .build();
+    SSLConnectionSocketFactory sslSocketFactory;
+    try {
+      TrustStrategy acceptingTrustStrategy = (cert, authType) -> true;
+      SSLContext sslContext =
+          SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
+      sslSocketFactory = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
+    } catch (Exception e) {
+      LOG.error("Error: ", e);
+      throw new RuntimeException(e);
+    }
+
+    return HttpClientBuilder.create()
+        .setDefaultRequestConfig(requestConfig)
+        .setSSLSocketFactory(sslSocketFactory)
+        .build();
+  }
+}
diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/IRestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/IRestClient.java
new file mode 100644
index 000000000..5f6a0ec9a
--- /dev/null
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/IRestClient.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.kyuubi.client;
+
+import java.util.Map;
+
+/** A underlying http client interface for common rest request. */
+public interface IRestClient extends AutoCloseable {
+  public <T> T get(String path, Map<String, Object> params, Class<T> type, String authHeader);
+
+  public String get(String path, Map<String, Object> params, String authHeader);
+
+  public <T> T post(String path, String body, Class<T> type, String authHeader);
+
+  public String post(String path, String body, String authHeader);
+
+  public <T> T delete(String path, Map<String, Object> params, Class<T> type, String authHeader);
+
+  public String delete(String path, Map<String, Object> params, String authHeader);
+}
diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java
index af8d8556c..309e3dbec 100644
--- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java
@@ -18,24 +18,15 @@
 package org.apache.kyuubi.client;
 
 import java.net.URI;
-import javax.net.ssl.SSLContext;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
 import org.apache.commons.lang3.StringUtils;
-import org.apache.http.client.config.RequestConfig;
-import org.apache.http.conn.ssl.NoopHostnameVerifier;
-import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
-import org.apache.http.conn.ssl.TrustStrategy;
-import org.apache.http.impl.client.CloseableHttpClient;
-import org.apache.http.impl.client.HttpClientBuilder;
-import org.apache.http.ssl.SSLContexts;
 import org.apache.kyuubi.client.auth.*;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class KyuubiRestClient implements AutoCloseable {
 
-  private static final Logger LOG = LoggerFactory.getLogger(KyuubiRestClient.class);
-
-  private RestClient httpClient;
+  private IRestClient httpClient;
 
   private AuthHeaderGenerator authHeaderGenerator;
 
@@ -64,13 +55,21 @@ public class KyuubiRestClient implements AutoCloseable {
   private KyuubiRestClient() {}
 
   private KyuubiRestClient(Builder builder) {
-    // Remove the trailing "/" from the hostUrl if present
-    String hostUrl = builder.hostUrl.replaceAll("/$", "");
-    String baseUrl = String.format("%s/%s", hostUrl, builder.version.getApiNamespace());
+    List<String> baseUrls = new LinkedList<>();
+    for (String hostUrl : builder.hostUrls) {
+      // Remove the trailing "/" from the hostUrl if present
+      String baseUrl =
+          String.format("%s/%s", hostUrl.replaceAll("/$", ""), builder.version.getApiNamespace());
+      baseUrls.add(baseUrl);
+    }
 
-    CloseableHttpClient httpclient = initHttpClient(builder);
+    RestClientConf conf = new RestClientConf();
+    conf.setConnectTimeout(builder.connectTimeout);
+    conf.setSocketTimeout(builder.socketTimeout);
+    conf.setMaxAttempts(builder.maxAttempts);
+    conf.setAttemptWaitTime(builder.attemptWaitTime);
 
-    this.httpClient = new RestClient(baseUrl, httpclient);
+    this.httpClient = RetryableRestClient.getRestClient(baseUrls, conf);
 
     switch (builder.authHeaderMethod) {
       case BASIC:
@@ -94,42 +93,25 @@ public class KyuubiRestClient implements AutoCloseable {
     return authHeaderGenerator.generateAuthHeader();
   }
 
-  public RestClient getHttpClient() {
+  public IRestClient getHttpClient() {
     return httpClient;
   }
 
-  private CloseableHttpClient initHttpClient(Builder builder) {
-    RequestConfig requestConfig =
-        RequestConfig.custom()
-            .setSocketTimeout(builder.socketTimeout)
-            .setConnectTimeout(builder.connectTimeout)
-            .build();
-    SSLConnectionSocketFactory sslSocketFactory;
-    try {
-      TrustStrategy acceptingTrustStrategy = (cert, authType) -> true;
-      SSLContext sslContext =
-          SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
-      sslSocketFactory = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
-    } catch (Exception e) {
-      LOG.error("Error: ", e);
-      throw new RuntimeException(e);
-    }
-
-    CloseableHttpClient httpclient =
-        HttpClientBuilder.create()
-            .setDefaultRequestConfig(requestConfig)
-            .setSSLSocketFactory(sslSocketFactory)
-            .build();
-    return httpclient;
-  }
-
   public static Builder builder(String hostUrl) {
     return new Builder(hostUrl);
   }
 
+  public static Builder builder(String... hostUrls) {
+    return new Builder(Arrays.asList(hostUrls));
+  }
+
+  public static Builder builder(List<String> hostUrls) {
+    return new Builder(hostUrls);
+  }
+
   public static class Builder {
 
-    private String hostUrl;
+    private List<String> hostUrls;
 
     private String spnegoHost;
 
@@ -147,8 +129,23 @@ public class KyuubiRestClient implements AutoCloseable {
 
     private int connectTimeout = 3000;
 
+    private int maxAttempts = 3;
+
+    private int attemptWaitTime = 3000;
+
     public Builder(String hostUrl) {
-      this.hostUrl = hostUrl;
+      if (StringUtils.isBlank(hostUrl)) {
+        throw new IllegalArgumentException("hostUrl cannot be blank.");
+      }
+      this.hostUrls = new LinkedList<>();
+      this.hostUrls.add(hostUrl);
+    }
+
+    public Builder(List<String> hostUrls) {
+      if (hostUrls.isEmpty()) {
+        throw new IllegalArgumentException("hostUrls cannot be blank.");
+      }
+      this.hostUrls = hostUrls;
     }
 
     public Builder spnegoHost(String host) {
@@ -193,16 +190,27 @@ public class KyuubiRestClient implements AutoCloseable {
       return this;
     }
 
-    public KyuubiRestClient build() {
-      if (StringUtils.isBlank(hostUrl)) {
-        throw new IllegalArgumentException("hostUrl cannot be blank.");
-      }
+    public Builder maxAttempts(int maxAttempts) {
+      this.maxAttempts = maxAttempts;
+      return this;
+    }
+
+    public Builder attemptWaitTime(int attemptWaitTime) {
+      this.attemptWaitTime = attemptWaitTime;
+      return this;
+    }
 
+    public KyuubiRestClient build() {
       if (authHeaderMethod == AuthHeaderMethod.SPNEGO && StringUtils.isBlank(spnegoHost)) {
-        try {
-          this.spnegoHost = new URI(hostUrl).getHost();
-        } catch (Exception e) {
+        if (hostUrls.size() > 1) {
           throw new IllegalArgumentException("spnegoHost is invalid.");
+        } else {
+          // follow the behavior of curl, use host url by default
+          try {
+            this.spnegoHost = new URI(hostUrls.get(0)).getHost();
+          } catch (Exception e) {
+            throw new IllegalArgumentException("spnegoHost is invalid.", e);
+          }
         }
       }
       return new KyuubiRestClient(this);
diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java
index 0e3058d16..5de920e36 100644
--- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java
@@ -18,6 +18,7 @@
 package org.apache.kyuubi.client;
 
 import java.io.IOException;
+import java.net.ConnectException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.nio.charset.StandardCharsets;
@@ -32,15 +33,17 @@ import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpUriRequest;
 import org.apache.http.client.methods.RequestBuilder;
 import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.conn.ConnectTimeoutException;
 import org.apache.http.entity.StringEntity;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.util.EntityUtils;
 import org.apache.kyuubi.client.exception.KyuubiRestException;
+import org.apache.kyuubi.client.exception.KyuubiRetryableException;
 import org.apache.kyuubi.client.util.JsonUtil;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class RestClient implements AutoCloseable {
+public class RestClient implements IRestClient {
 
   private static final Logger LOG = LoggerFactory.getLogger(RestClient.class);
 
@@ -60,31 +63,37 @@ public class RestClient implements AutoCloseable {
     }
   }
 
+  @Override
   public <T> T get(String path, Map<String, Object> params, Class<T> type, String authHeader) {
     String responseJson = get(path, params, authHeader);
     return JsonUtil.toObject(responseJson, type);
   }
 
+  @Override
   public String get(String path, Map<String, Object> params, String authHeader) {
     return doRequest(buildURI(path, params), authHeader, RequestBuilder.get());
   }
 
+  @Override
   public <T> T post(String path, String body, Class<T> type, String authHeader) {
     String responseJson = post(path, body, authHeader);
     return JsonUtil.toObject(responseJson, type);
   }
 
+  @Override
   public String post(String path, String body, String authHeader) {
     RequestBuilder postRequestBuilder =
         RequestBuilder.post().setEntity(new StringEntity(body, StandardCharsets.UTF_8));
     return doRequest(buildURI(path), authHeader, postRequestBuilder);
   }
 
+  @Override
   public <T> T delete(String path, Map<String, Object> params, Class<T> type, String authHeader) {
     String responseJson = delete(path, params, authHeader);
     return JsonUtil.toObject(responseJson, type);
   }
 
+  @Override
   public String delete(String path, Map<String, Object> params, String authHeader) {
     return doRequest(buildURI(path, params), authHeader, RequestBuilder.delete());
   }
@@ -118,6 +127,9 @@ public class RestClient implements AutoCloseable {
 
       response = httpclient.execute(httpRequest, responseHandler);
       LOG.info("Response: {}", response);
+    } catch (ConnectException | ConnectTimeoutException e) {
+      // net exception can be retried by connecting to other Kyuubi server
+      throw new KyuubiRetryableException("Api request failed for " + uri.toString(), e);
     } catch (Exception e) {
       LOG.error("Error: ", e);
       throw new KyuubiRestException("Api request failed for " + uri.toString(), e);
diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClientConf.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClientConf.java
new file mode 100644
index 000000000..6094ecff7
--- /dev/null
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClientConf.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.kyuubi.client;
+
+public class RestClientConf {
+  private int maxAttempts;
+  private int attemptWaitTime;
+  private int socketTimeout;
+  private int connectTimeout;
+
+  public int getMaxAttempts() {
+    return maxAttempts;
+  }
+
+  public void setMaxAttempts(int maxAttempts) {
+    this.maxAttempts = maxAttempts;
+  }
+
+  public int getAttemptWaitTime() {
+    return attemptWaitTime;
+  }
+
+  public void setAttemptWaitTime(int attemptWaitTime) {
+    this.attemptWaitTime = attemptWaitTime;
+  }
+
+  public int getSocketTimeout() {
+    return socketTimeout;
+  }
+
+  public void setSocketTimeout(int socketTimeout) {
+    this.socketTimeout = socketTimeout;
+  }
+
+  public int getConnectTimeout() {
+    return connectTimeout;
+  }
+
+  public void setConnectTimeout(int connectTimeout) {
+    this.connectTimeout = connectTimeout;
+  }
+}
diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RetryableRestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RetryableRestClient.java
new file mode 100644
index 000000000..a144245e1
--- /dev/null
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RetryableRestClient.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.kyuubi.client;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.Random;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.kyuubi.client.exception.KyuubiRetryableException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A retryable rest client that catches the {@link KyuubiRetryableException} which is thrown by
+ * underlying rest client and use a new server uri to the next attempt.
+ */
+public class RetryableRestClient implements InvocationHandler {
+
+  private Logger LOG = LoggerFactory.getLogger(RetryableRestClient.class);
+
+  private final RestClientConf conf;
+  private final List<String> uris;
+  private int currentUriIndex;
+  private IRestClient restClient;
+
+  private RetryableRestClient(List<String> uris, RestClientConf conf) {
+    this.conf = conf;
+    this.uris = uris;
+    this.currentUriIndex = new Random(System.currentTimeMillis()).nextInt(uris.size());
+    newRestClient();
+  }
+
+  public static IRestClient getRestClient(List<String> uris, RestClientConf conf) {
+    RetryableRestClient client = new RetryableRestClient(uris, conf);
+    return (IRestClient)
+        Proxy.newProxyInstance(
+            Thread.currentThread().getContextClassLoader(),
+            new Class[] {IRestClient.class},
+            client);
+  }
+
+  private void newRestClient() {
+    if (restClient != null) {
+      try {
+        restClient.close();
+        restClient = null;
+      } catch (Exception e) {
+        LOG.warn("Failed to close rest client", e);
+      }
+    }
+
+    CloseableHttpClient httpclient = HttpClientFactory.createHttpClient(conf);
+    assert currentUriIndex < uris.size();
+    this.restClient = new RestClient(uris.get(currentUriIndex), httpclient);
+    LOG.info("Current connect server uri {}", uris.get(currentUriIndex));
+  }
+
+  @Override
+  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
+    int retryTimes = 0;
+    while (true) {
+      try {
+        return method.invoke(restClient, args);
+      } catch (IllegalAccessException ignored) {
+      } catch (InvocationTargetException e) {
+        if (e.getCause() == null) {
+          throw e;
+        } else if (e.getCause() instanceof KyuubiRetryableException) {
+          // the remote server has some issues or the client machine has some issues
+          retryTimes++;
+          if (retryTimes <= conf.getMaxAttempts()) {
+            Thread.sleep(conf.getAttemptWaitTime());
+          } else {
+            LOG.error("Attempt over {} times", conf.getMaxAttempts(), e.getCause());
+            throw e.getCause();
+          }
+          if (currentUriIndex == uris.size() - 1) {
+            currentUriIndex = 0;
+          } else {
+            currentUriIndex++;
+          }
+          newRestClient();
+        } else {
+          throw e.getCause();
+        }
+      }
+    }
+  }
+}
diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/exception/KyuubiRetryableException.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/exception/KyuubiRetryableException.java
new file mode 100644
index 000000000..cecf15722
--- /dev/null
+++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/exception/KyuubiRetryableException.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.kyuubi.client.exception;
+
+/**
+ * A retryable exception which is thrown by underlying rest client. The call side can do retry by
+ * catching this exception.
+ */
+public class KyuubiRetryableException extends KyuubiRestException {
+  public KyuubiRetryableException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/BatchRestClientTest.java b/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/BatchRestClientTest.java
index e38520006..55557be01 100644
--- a/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/BatchRestClientTest.java
+++ b/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/BatchRestClientTest.java
@@ -55,8 +55,10 @@ public class BatchRestClientTest {
             .build();
     spnegoBatchRestApi = new BatchRestApi(spnegoClient);
 
+    // https://localhost:8442 is a fake server url and it is used to test retryable rest client
+    // the retryable rest client will shuffle the input host urls
     basicClient =
-        KyuubiRestClient.builder("https://localhost:8443")
+        KyuubiRestClient.builder("https://localhost:8443", "https://localhost:8442")
             .authHeaderMethod(KyuubiRestClient.AuthHeaderMethod.BASIC)
             .username(TEST_USERNAME)
             .password(TEST_PASSWORD)