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

hanicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/master by this push:
     new 2beb45b33 KNOX-3041: Eliminate load-balancing race condition in HA 
dispatch (#1317)
2beb45b33 is described below

commit 2beb45b33c09d5957fc47dd8a1e7a9e951391967
Author: hanicz <[email protected]>
AuthorDate: Thu Jul 23 19:21:11 2026 +0200

    KNOX-3041: Eliminate load-balancing race condition in HA dispatch (#1317)
---
 .../knox/gateway/ha/dispatch/CommonHaDispatch.java | 145 ++++++--
 .../ha/dispatch/ConfigurableHADispatch.java        |   5 +-
 .../knox/gateway/ha/dispatch/SSEHaDispatch.java    |  11 -
 .../knox/gateway/ha/provider/HaProvider.java       |  11 +
 .../knox/gateway/ha/provider/URLManager.java       |   2 +
 .../ha/provider/impl/BaseZookeeperURLManager.java  |  13 +
 .../ha/provider/impl/DefaultHaProvider.java        |  22 ++
 .../ha/provider/impl/DefaultURLManager.java        |  10 +
 .../ha/dispatch/ConfigurableHADispatchTest.java    | 385 +++++++++++++++++++++
 .../gateway/ha/dispatch/SSEHaDispatchTest.java     |  37 +-
 .../org/apache/knox/gateway/sse/SSEDispatch.java   |   5 -
 11 files changed, 570 insertions(+), 76 deletions(-)

diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/CommonHaDispatch.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/CommonHaDispatch.java
index 2918c099e..66e9f5e13 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/CommonHaDispatch.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/CommonHaDispatch.java
@@ -135,7 +135,7 @@ public interface CommonHaDispatch {
                     // Make sure that the url provided is actually a valid 
backend url
                     if 
(getHaConfigurations().getHaProvider().getURLs(getServiceRole()).contains(backendURL))
 {
                         try {
-                            return 
Optional.of(updateHostURL(outboundRequest.getURI(), backendURL));
+                            return 
Optional.of(updateBackendURL(outboundRequest.getURI(), backendURL));
                         } catch (URISyntaxException ignore) {
                             // The cookie was invalid so we just don't set it. 
Knox will pick a backend automatically
                         }
@@ -169,6 +169,79 @@ public interface CommonHaDispatch {
         return uriBuilder.build();
     }
 
+    /**
+     * Re-targets a rewritten URI at a different backend, carrying over the 
backend's base
+     * path in addition to scheme, host and port.
+     */
+    default URI updateBackendURL(final URI source, final String newBackend) 
throws URISyntaxException {
+        final URI newUri = new URI(newBackend);
+        final URIBuilder uriBuilder = new URIBuilder(source);
+        uriBuilder.setScheme(newUri.getScheme());
+        uriBuilder.setHost(newUri.getHost());
+        uriBuilder.setPort(newUri.getPort());
+        final String newBasePath = normalizeBasePath(newUri.getPath());
+        final String oldBasePath = matchConfiguredBasePath(source);
+        if (oldBasePath != null && !oldBasePath.equals(newBasePath)) {
+            final String sourcePath = source.getPath() == null ? "" : 
source.getPath();
+            uriBuilder.setPath(newBasePath + 
sourcePath.substring(oldBasePath.length()));
+        }
+        return uriBuilder.build();
+    }
+
+    default String matchConfiguredBasePath(final URI source) {
+        if (source.getHost() == null) {
+            return null;
+        }
+        final String sourcePath = source.getPath() == null ? "" : 
source.getPath();
+        String best = null;
+        for (String url : 
getHaConfigurations().getHaProvider().getURLs(getServiceRole())) {
+            try {
+                final URI poolUri = new URI(url);
+                if (!source.getHost().equals(poolUri.getHost()) || 
effectivePort(source) != effectivePort(poolUri)) {
+                    continue;
+                }
+                final String basePath = normalizeBasePath(poolUri.getPath());
+                if (isPathPrefix(sourcePath, basePath) && (best == null || 
basePath.length() > best.length())) {
+                    best = basePath;
+                }
+            } catch (URISyntaxException ignore) {
+                // a malformed pool entry cannot be the URL the rewriter used
+            }
+        }
+        return best;
+    }
+
+    static int effectivePort(final URI uri) {
+        final int port = uri.getPort();
+        if (port != -1) {
+            return port;
+        }
+        final String scheme = uri.getScheme();
+        if ("http".equalsIgnoreCase(scheme)) {
+            return 80;
+        }
+        if ("https".equalsIgnoreCase(scheme)) {
+            return 443;
+        }
+        return -1;
+    }
+
+    static String normalizeBasePath(final String path) {
+        if (path == null) {
+            return "";
+        }
+        String normalized = path;
+        while (normalized.endsWith("/")) {
+            normalized = normalized.substring(0, normalized.length() - 1);
+        }
+        return normalized;
+    }
+
+    static boolean isPathPrefix(final String path, final String prefix) {
+        return path.startsWith(prefix)
+                && (path.length() == prefix.length() || 
path.charAt(prefix.length()) == '/');
+    }
+
     default void setupUrlHashLookup() {
         for (String url : 
getHaConfigurations().getHaProvider().getURLs(getServiceRole())) {
             String urlHash = hash(url);
@@ -199,46 +272,33 @@ public interface CommonHaDispatch {
             backendURI.ifPresent(uri -> ((HttpRequestBase) 
outboundRequest).setURI(uri));
         }
 
-        /**
-         * case where loadbalancing is enabled
-         * and we have a HTTP request configured not to use LB
-         * use the activeURL
-         */
-        if (getHaConfigurations().isLoadBalancingEnabled() && 
userAgentDisabled) {
-            try {
-                ((HttpRequestBase) 
outboundRequest).setURI(updateHostURL(outboundRequest.getURI(), 
getActiveURL().get()));
-            } catch (final URISyntaxException e) {
-                LOG.errorSettingActiveUrl();
+        if (getHaConfigurations().isLoadBalancingEnabled()) {
+            if (userAgentDisabled) {
+                /**
+                 * case where loadbalancing is enabled
+                 * and we have a HTTP request configured not to use LB
+                 * use the activeURL
+                 */
+                try {
+                    ((HttpRequestBase) 
outboundRequest).setURI(updateHostURL(outboundRequest.getURI(), 
getActiveURL().get()));
+                } catch (final URISyntaxException e) {
+                    LOG.errorSettingActiveUrl();
+                }
+            } else if (!backendURI.isPresent()) {
+                String nextURL = 
getHaConfigurations().getHaProvider().getActiveURLAndAdvance(getServiceRole());
+                if (nextURL != null) {
+                    try {
+                        ((HttpRequestBase) 
outboundRequest).setURI(updateBackendURL(outboundRequest.getURI(), nextURL));
+                    } catch (final URISyntaxException e) {
+                        LOG.errorSettingActiveUrl();
+                    }
+                }
             }
         }
 
         return backendURI;
     }
 
-    default void shiftActiveURL(boolean userAgentDisabled, Optional<URI> 
backendURI) {
-        /**
-         * 1. Load balance when loadbalancing is enabled and there are no 
overrides (disableLB)
-         * 2. Loadbalance only when sticky session is enabled but cookie not 
detected
-         *    i.e. when loadbalancing is enabled every request that does not 
have BACKEND cookie
-         *    needs to be loadbalanced. If a request has BACKEND coookie and 
Loadbalance=on then
-         *    there should be no loadbalancing.
-         */
-        if (getHaConfigurations().isLoadBalancingEnabled() && 
!userAgentDisabled) {
-            /* check sticky session enabled */
-            if (getHaConfigurations().isStickySessionEnabled()) {
-                /* loadbalance only when sticky session enabled and no backend 
url cookie */
-                if (!backendURI.isPresent()) {
-                    
getHaConfigurations().getHaProvider().makeNextActiveURLAvailable(getServiceRole());
-                } else {
-                    /* sticky session enabled and backend url cookie is valid 
no need to loadbalance */
-                    /* do nothing */
-                }
-            } else {
-                
getHaConfigurations().getHaProvider().makeNextActiveURLAvailable(getServiceRole());
-            }
-        }
-    }
-
     /**
      * A helper method that marks an endpoint failed.
      * Changes HA Provider state.
@@ -271,8 +331,19 @@ public interface CommonHaDispatch {
         
inboundRequest.setAttribute(AbstractGatewayFilter.TARGET_REQUEST_URL_ATTRIBUTE_NAME,
 null);
         // Make sure to remove the ha cookie from the request
         inboundRequest = new 
StickySessionCookieRemovedRequest(getHaConfigurations().getStickySessionCookieName(),
 inboundRequest);
-        URI uri = getDispatchUrl(inboundRequest);
-        ((HttpRequestBase) outboundRequest).setURI(uri);
+        ((HttpRequestBase) 
outboundRequest).setURI(getDispatchUrl(inboundRequest));
+
+        if (getHaConfigurations().isLoadBalancingEnabled() && 
!isUserAgentDisabled(inboundRequest)) {
+            final String nextURL = 
getHaConfigurations().getHaProvider().getActiveURLAndAdvance(getServiceRole());
+            if (nextURL != null) {
+                try {
+                    ((HttpRequestBase) 
outboundRequest).setURI(updateBackendURL(outboundRequest.getURI(), nextURL));
+                } catch (final URISyntaxException e) {
+                    LOG.errorSettingActiveUrl();
+                }
+            }
+        }
+
         if (getHaConfigurations().getFailoverSleep() > 0) {
             try {
                 Thread.sleep(getHaConfigurations().getFailoverSleep());
diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatch.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatch.java
index 5c24f81a6..a4b7df94c 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatch.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatch.java
@@ -31,8 +31,6 @@ import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
-import java.net.URI;
-import java.util.Optional;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -92,9 +90,8 @@ public class ConfigurableHADispatch extends 
ConfigurableDispatch implements Comm
   @Override
   protected void executeRequestWrapper(HttpUriRequest outboundRequest, 
HttpServletRequest inboundRequest, HttpServletResponse outboundResponse) throws 
IOException {
       boolean userAgentDisabled = isUserAgentDisabled(inboundRequest);
-      Optional<URI> backendURI = setBackendUri(outboundRequest, 
inboundRequest, userAgentDisabled);
+      setBackendUri(outboundRequest, inboundRequest, userAgentDisabled);
       executeRequest(outboundRequest, inboundRequest, outboundResponse);
-      shiftActiveURL(userAgentDisabled, backendURI);
   }
 
   @Override
diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatch.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatch.java
index 136a1721f..40e7f8c18 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatch.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatch.java
@@ -39,7 +39,6 @@ import javax.servlet.FilterConfig;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
-import java.util.Optional;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -144,14 +143,4 @@ public class SSEHaDispatch extends SSEDispatch implements 
CommonHaDispatch {
     protected void outboundResponseWrapper(final HttpUriRequest 
outboundRequest, final HttpServletRequest inboundRequest, final 
HttpServletResponse outboundResponse) {
         setKnoxHaCookie(outboundRequest, inboundRequest, outboundResponse, 
sslEnabled);
     }
-
-    @Override
-    protected void shiftCallback(HttpUriRequest outboundRequest, 
HttpServletRequest inboundRequest) {
-        /*
-            Due to the async behavior shifting has to take place after a 
successful response-received event
-            and not in the executeRequest method. This is the same as in a 
sync dispatch.
-        */
-        boolean userAgentDisabled = isUserAgentDisabled(inboundRequest);
-        shiftActiveURL(userAgentDisabled, userAgentDisabled ? Optional.empty() 
: getBackendFromHaCookie(outboundRequest, inboundRequest));
-    }
 }
diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/HaProvider.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/HaProvider.java
index ff543f68a..228141be3 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/HaProvider.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/HaProvider.java
@@ -72,6 +72,17 @@ public interface HaProvider {
    */
   void makeNextActiveURLAvailable(String serviceName);
 
+  /**
+   * Returns the current active URL for the service and advances the rotation 
to the next
+   * URL, as a single atomic operation: concurrent callers are guaranteed to 
observe
+   * different values as long as more than one URL is configured.
+   *
+   * @param serviceName the name of the service
+   * @return the URL that was active at the time of the call, or {@code null} 
if no URLs
+   *         are configured for the service
+   */
+  String getActiveURLAndAdvance(String serviceName);
+
   /**
    * This method puts gets all the currently
    * available URLs for the service.
diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/URLManager.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/URLManager.java
index 383cd8d26..53c84c21b 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/URLManager.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/URLManager.java
@@ -35,4 +35,6 @@ public interface URLManager {
   void markFailed(String url);
 
   void makeNextActiveURLAvailable();
+
+  String getActiveURLAndAdvance();
 }
diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/BaseZookeeperURLManager.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/BaseZookeeperURLManager.java
index d3e61abcc..590b09bd1 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/BaseZookeeperURLManager.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/BaseZookeeperURLManager.java
@@ -121,6 +121,19 @@ public abstract class BaseZookeeperURLManager implements 
URLManager {
     urls.offer(head);
   }
 
+  @Override
+  public synchronized String getActiveURLAndAdvance() {
+    if (urls.isEmpty()) {
+      setURLs(lookupURLs());
+    }
+    String head = urls.poll();
+    if (head == null) {
+      return null;
+    }
+    urls.offer(head);
+    return head;
+  }
+
   @Override
   public synchronized void setURLs(List<String> urls) {
     if ((urls != null) && (!(urls.isEmpty()))) {
diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultHaProvider.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultHaProvider.java
index f3ddc929d..c4c7c204e 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultHaProvider.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultHaProvider.java
@@ -125,6 +125,28 @@ public class DefaultHaProvider implements HaProvider {
     }
   }
 
+  /*
+   * The pick-and-advance methods deliberately take the READ lock even though 
they rotate
+   * the URL queue: their atomicity is provided by the URLManager's own 
synchronization,
+   * and the provider-level rwl only guards cross-method consistency with 
markFailedURL /
+   * setActiveURL. Taking the write lock here would serialize every 
load-balanced request
+   * across all services behind a single gateway-wide lock — and hold it while 
ZooKeeper-
+   * backed managers refresh their URL list (a blocking remote call).
+   */
+  @Override
+  public String getActiveURLAndAdvance(String serviceName) {
+    rwl.readLock().lock();
+    try {
+      if (haServices.containsKey(serviceName)) {
+        return haServices.get(serviceName).getActiveURLAndAdvance();
+      }
+      LOG.noActiveUrlFound(serviceName);
+      return null;
+    } finally {
+      rwl.readLock().unlock();
+    }
+  }
+
   @Override
   public List<String> getURLs(String serviceName) {
     if ( haServices.containsKey(serviceName) ) {
diff --git 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultURLManager.java
 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultURLManager.java
index 34b2cc8eb..78662fcb1 100644
--- 
a/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultURLManager.java
+++ 
b/gateway-provider-ha/src/main/java/org/apache/knox/gateway/ha/provider/impl/DefaultURLManager.java
@@ -102,4 +102,14 @@ public class DefaultURLManager implements URLManager {
     String head = urls.poll();
     urls.offer(head);
   }
+
+  @Override
+  public synchronized String getActiveURLAndAdvance() {
+    String head = urls.poll();
+    if (head == null) {
+      return null;
+    }
+    urls.offer(head);
+    return head;
+  }
 }
diff --git 
a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatchTest.java
 
b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatchTest.java
index 92f1942dc..404d60111 100644
--- 
a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatchTest.java
+++ 
b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/ConfigurableHADispatchTest.java
@@ -24,6 +24,13 @@ import java.io.IOException;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import javax.servlet.FilterConfig;
@@ -36,13 +43,19 @@ import org.apache.commons.codec.digest.DigestUtils;
 import org.apache.http.Header;
 import org.apache.http.HeaderElement;
 import org.apache.http.HttpEntity;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpRequest;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
 import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
 import org.apache.http.client.methods.HttpRequestBase;
 import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.conn.ClientConnectionManager;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.HttpParams;
+import org.apache.http.protocol.HttpContext;
 import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.ha.provider.HaDescriptor;
 import org.apache.knox.gateway.ha.provider.HaProvider;
@@ -51,6 +64,7 @@ import 
org.apache.knox.gateway.ha.provider.impl.DefaultHaProvider;
 import org.apache.knox.gateway.ha.provider.impl.HaDescriptorFactory;
 import org.apache.knox.gateway.servlet.SynchronousServletOutputStreamAdapter;
 import org.easymock.Capture;
+import org.easymock.CaptureType;
 import org.easymock.EasyMock;
 import org.easymock.IAnswer;
 import org.junit.Assert;
@@ -284,4 +298,375 @@ public class ConfigurableHADispatchTest {
     Assert.assertEquals(uri1.toString(), provider.getActiveURL(serviceName));
   }
 
+  @Test
+  public void testConcurrentRequestsDoNotShareBackendDuringSlowCall() throws 
Exception {
+    String serviceName = "HIVE";
+    HaDescriptor descriptor = HaDescriptorFactory.createDescriptor();
+    descriptor.addServiceConfig(HaDescriptorFactory.createServiceConfig(
+        serviceName, "true", "1", "1000", null, null, "true", "true", null, 
null, null));
+    HaProvider provider = new DefaultHaProvider(descriptor);
+    URI uri1 = new URI("http://host1.valid";);
+    URI uri2 = new URI("http://host2.valid";);
+    ArrayList<String> urlList = new ArrayList<>();
+    urlList.add(uri1.toString());
+    urlList.add(uri2.toString());
+    provider.addHaService(serviceName, urlList);
+    final HttpGet outboundRequest1 = new HttpGet(uri1);
+    final HttpGet outboundRequest2 = new HttpGet(uri1);
+
+    ServletContext context = EasyMock.createNiceMock(ServletContext.class);
+    GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
+    
EasyMock.expect(context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(config).anyTimes();
+
+    HttpServletRequest inboundRequest1 = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    HttpServletRequest inboundRequest2 = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    
EasyMock.expect(inboundRequest1.getServletContext()).andReturn(context).anyTimes();
+    
EasyMock.expect(inboundRequest2.getServletContext()).andReturn(context).anyTimes();
+    
EasyMock.expect(inboundRequest1.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).anyTimes();
+    
EasyMock.expect(inboundRequest2.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).anyTimes();
+
+    CloseableHttpResponse inboundResponse = 
EasyMock.createNiceMock(CloseableHttpResponse.class);
+    StatusLine statusLine = EasyMock.createNiceMock(StatusLine.class);
+    HttpEntity entity = EasyMock.createNiceMock(HttpEntity.class);
+    Header header = EasyMock.createNiceMock(Header.class);
+    
EasyMock.expect(inboundResponse.getStatusLine()).andReturn(statusLine).anyTimes();
+    
EasyMock.expect(statusLine.getStatusCode()).andReturn(HttpStatus.SC_OK).anyTimes();
+    EasyMock.expect(inboundResponse.getEntity()).andReturn(entity).anyTimes();
+    EasyMock.expect(inboundResponse.getAllHeaders()).andReturn(new 
Header[0]).anyTimes();
+    EasyMock.expect(entity.getContent()).andAnswer(() ->
+        new 
ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8))).anyTimes();
+    EasyMock.expect(entity.getContentType()).andReturn(header).anyTimes();
+    EasyMock.expect(header.getElements()).andReturn(new 
HeaderElement[]{}).anyTimes();
+    EasyMock.expect(entity.getContentLength()).andReturn(1L).anyTimes();
+
+    HttpServletResponse outboundResponse1 = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    HttpServletResponse outboundResponse2 = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.expect(outboundResponse1.getOutputStream()).andAnswer(
+        () -> new SynchronousServletOutputStreamAdapter() {
+          @Override public void write(int b) { /* do nothing */ }
+        }).anyTimes();
+    EasyMock.expect(outboundResponse2.getOutputStream()).andAnswer(
+        () -> new SynchronousServletOutputStreamAdapter() {
+          @Override public void write(int b) { /* do nothing */ }
+        }).anyTimes();
+
+    final CountDownLatch firstCallEntered = new CountDownLatch(1);
+    final CountDownLatch releaseFirstCall = new CountDownLatch(1);
+    CloseableHttpClient httpClient = new CloseableHttpClient() {
+      @Override
+      protected CloseableHttpResponse doExecute(HttpHost target, HttpRequest 
request, HttpContext context) throws IOException {
+        if (request == outboundRequest1) {
+          firstCallEntered.countDown();
+          try {
+            if (!releaseFirstCall.await(5, TimeUnit.SECONDS)) {
+              throw new IOException("Slow call was not released in time");
+            }
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IOException(e);
+          }
+        }
+        return inboundResponse;
+      }
+      @Override public void close() { /* no-op */ }
+      @Override public HttpParams getParams() { return new BasicHttpParams(); }
+      @Override public ClientConnectionManager getConnectionManager() { return 
null; }
+    };
+
+    EasyMock.replay(inboundRequest1, inboundRequest2, context, config,
+        inboundResponse, statusLine, entity, header,
+        outboundResponse1, outboundResponse2);
+
+    Assert.assertEquals(uri1.toString(), provider.getActiveURL(serviceName));
+
+    ConfigurableHADispatch dispatch = new ConfigurableHADispatch();
+    dispatch.setHttpClient(httpClient);
+    dispatch.setHaProvider(provider);
+    dispatch.setServiceRole(serviceName);
+    dispatch.init();
+    ExecutorService pool = Executors.newSingleThreadExecutor();
+    try {
+      Future<?> slowRequest = pool.submit(() -> {
+        try {
+          dispatch.executeRequestWrapper(outboundRequest1, inboundRequest1, 
outboundResponse1);
+        } catch (IOException e) {
+          throw new RuntimeException(e);
+        }
+      });
+
+      Assert.assertTrue("Slow request did not reach the HTTP call in time",
+          firstCallEntered.await(5, TimeUnit.SECONDS));
+
+      dispatch.executeRequestWrapper(outboundRequest2, inboundRequest2, 
outboundResponse2);
+
+      Assert.assertNotEquals(
+          "Concurrent second request landed on the same backend as the slow 
one — race not fixed",
+          outboundRequest1.getURI().getHost(),
+          outboundRequest2.getURI().getHost());
+      Assert.assertEquals(uri1.getHost(), outboundRequest1.getURI().getHost());
+      Assert.assertEquals(uri2.getHost(), outboundRequest2.getURI().getHost());
+
+      releaseFirstCall.countDown();
+      slowRequest.get(5, TimeUnit.SECONDS);
+    } finally {
+      releaseFirstCall.countDown();
+      pool.shutdownNow();
+    }
+  }
+
+  @Test
+  public void testSetBackendUriCarriesPickedBackendBasePath() throws Exception 
{
+    String serviceName = "HIVE";
+    HaDescriptor descriptor = HaDescriptorFactory.createDescriptor();
+    descriptor.addServiceConfig(HaDescriptorFactory.createServiceConfig(
+        serviceName, "true", "1", "0", null, null, "true", "false", null, 
null, null));
+    HaProvider provider = new DefaultHaProvider(descriptor);
+    ArrayList<String> urlList = new ArrayList<>();
+    urlList.add("http://host1.valid:8443/cliservice";);
+    urlList.add("http://host2.valid:8443/cliservice2";);
+    provider.addHaService(serviceName, urlList);
+
+    ConfigurableHADispatch dispatch = new ConfigurableHADispatch();
+    dispatch.setHaProvider(provider);
+    dispatch.setServiceRole(serviceName);
+    dispatch.init();
+
+    HttpGet outboundRequest = new HttpGet(new 
URI("http://host1.valid:8443/cliservice/query?op=EXECUTE";));
+    provider.makeNextActiveURLAvailable(serviceName);
+
+    HttpServletRequest inboundRequest = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.replay(inboundRequest);
+
+    dispatch.setBackendUri(outboundRequest, inboundRequest, false);
+
+    Assert.assertEquals("Picked backend's base path must replace the peeked 
backend's",
+        new URI("http://host2.valid:8443/cliservice2/query?op=EXECUTE";), 
outboundRequest.getURI());
+  }
+
+  @Test
+  public void testSetBackendUriCarriesPickedBackendBasePath_EmptyFirst() 
throws Exception {
+    String serviceName = "HIVE";
+    HaDescriptor descriptor = HaDescriptorFactory.createDescriptor();
+    descriptor.addServiceConfig(HaDescriptorFactory.createServiceConfig(
+            serviceName, "true", "1", "0", null, null, "true", "false", null, 
null, null));
+    HaProvider provider = new DefaultHaProvider(descriptor);
+    ArrayList<String> urlList = new ArrayList<>();
+    urlList.add("http://host1.valid:8443";);
+    urlList.add("http://host2.valid:8443/cliservice2";);
+    provider.addHaService(serviceName, urlList);
+
+    ConfigurableHADispatch dispatch = new ConfigurableHADispatch();
+    dispatch.setHaProvider(provider);
+    dispatch.setServiceRole(serviceName);
+    dispatch.init();
+
+    HttpGet outboundRequest = new HttpGet(new URI("http://host1.valid:8443";));
+    provider.makeNextActiveURLAvailable(serviceName);
+
+    HttpServletRequest inboundRequest = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.replay(inboundRequest);
+
+    dispatch.setBackendUri(outboundRequest, inboundRequest, false);
+
+    Assert.assertEquals("Picked backend's base path must replace the peeked 
backend's",
+            new URI("http://host2.valid:8443/cliservice2";), 
outboundRequest.getURI());
+  }
+
+  /*
+   * markEndpointFailed stores the FULL failed request URI (path and query 
included) in the
+   * dispatch-level activeURL. The user-agent-disabled branch of setBackendUri 
routes against
+   * activeURL via updateBackendURL, which interprets its argument's path as 
the backend's
+   * base path — so a stale full request URI must not leak its path into later 
requests.
+   */
+  @Test
+  public void testUserAgentDisabledRequestAfterFailoverKeepsRequestPath() 
throws Exception {
+    String serviceName = "HIVE";
+    HaDescriptor descriptor = HaDescriptorFactory.createDescriptor();
+    descriptor.addServiceConfig(HaDescriptorFactory.createServiceConfig(
+        serviceName, "true", "1", "0", null, null, "true", "true", null, null, 
"agentX"));
+    HaProvider provider = new DefaultHaProvider(descriptor);
+    ArrayList<String> urlList = new ArrayList<>();
+    urlList.add("http://host1.valid";);
+    urlList.add("http://host2.valid";);
+    provider.addHaService(serviceName, urlList);
+
+    ConfigurableHADispatch dispatch = new ConfigurableHADispatch();
+    dispatch.setHaProvider(provider);
+    dispatch.setServiceRole(serviceName);
+    dispatch.init();
+
+    /* a failover on any request stores the full failed request URI in 
activeURL */
+    HttpGet failedRequest = new HttpGet(new 
URI("http://host1.valid/svc/path?op=EXECUTE";));
+    HttpServletRequest failoverInbound = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.replay(failoverInbound);
+    dispatch.markEndpointFailed(failedRequest, failoverInbound);
+    Assert.assertEquals("http://host1.valid/svc/path?op=EXECUTE";, 
dispatch.getActiveURL().get());
+
+    /* a later request from a user agent configured to bypass load balancing */
+    HttpGet outboundRequest = new HttpGet(new 
URI("http://host2.valid/other?op=LIST";));
+    HttpServletRequest inboundRequest = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    
EasyMock.expect(inboundRequest.getHeader("User-Agent")).andReturn("agentX").anyTimes();
+    EasyMock.replay(inboundRequest);
+    Assert.assertTrue(dispatch.isUserAgentDisabled(inboundRequest));
+
+    dispatch.setBackendUri(outboundRequest, inboundRequest, true);
+
+    Assert.assertEquals("Stale activeURL's request path leaked into the 
outbound URI as a base path",
+        "/other", outboundRequest.getURI().getPath());
+    Assert.assertEquals(new URI("http://host1.valid/other?op=LIST";), 
outboundRequest.getURI());
+  }
+
+  /*
+   * With three backends and maxFailoverAttempts=2, a request whose first two 
attempts both throw
+   * a connection error must walk the whole rotation host1 -> host2 -> host3 
and succeed on the
+   * third, never revisiting a host it already tried. This exercises the real 
selection path end
+   * to end: setBackendUri's pick-and-advance for the first attempt, and 
markFailedURL rotating
+   * the dead backend to the tail so the re-run rewriter (simulated here via 
getRequestURL, which
+   * mirrors the $serviceUrl function peeking provider.getActiveURL) targets 
the next healthy one.
+   */
+  @Test
+  public void testRequestRotatesAcrossBackendsOnMultipleFailovers() throws 
Exception {
+    final String serviceName = "HIVE";
+    HaDescriptor descriptor = HaDescriptorFactory.createDescriptor();
+    descriptor.addServiceConfig(HaDescriptorFactory.createServiceConfig(
+        serviceName, "true", "2", "0", null, null, "true", "true", null, null, 
null));
+    final HaProvider provider = new DefaultHaProvider(descriptor);
+    URI uri1 = new URI("http://host1.valid";);
+    URI uri2 = new URI("http://host2.valid";);
+    URI uri3 = new URI("http://host3.valid";);
+    ArrayList<String> urlList = new ArrayList<>();
+    urlList.add(uri1.toString());
+    urlList.add(uri2.toString());
+    urlList.add(uri3.toString());
+    provider.addHaService(serviceName, urlList);
+
+    /* a real request object so setBackendUri / prepareForFailover actually 
mutate the URI */
+    final HttpGet outboundRequest = new HttpGet(uri1);
+
+    /*
+     * Stand in for the URL-rewrite filter: on each (re)dispatch the 
rewriter's $serviceUrl
+     * function re-peeks the provider's current active URL. prepareForFailover 
nulls the cached
+     * target so getDispatchUrl -> getRequestURL is what re-targets the 
failed-over request.
+     */
+    HttpServletRequest inboundRequest = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(inboundRequest.getRequestURL())
+        .andAnswer(() -> new 
StringBuffer(provider.getActiveURL(serviceName))).anyTimes();
+    
EasyMock.expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
+    
EasyMock.expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(1)).once();
+
+    /* backend response for the successful third attempt */
+    CloseableHttpResponse inboundResponse = 
EasyMock.createNiceMock(CloseableHttpResponse.class);
+    StatusLine statusLine = EasyMock.createNiceMock(StatusLine.class);
+    HttpEntity entity = EasyMock.createNiceMock(HttpEntity.class);
+    Header header = EasyMock.createNiceMock(Header.class);
+    ServletContext context = EasyMock.createNiceMock(ServletContext.class);
+    GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
+
+    
EasyMock.expect(inboundResponse.getStatusLine()).andReturn(statusLine).anyTimes();
+    
EasyMock.expect(statusLine.getStatusCode()).andReturn(HttpStatus.SC_OK).anyTimes();
+    EasyMock.expect(inboundResponse.getEntity()).andReturn(entity).anyTimes();
+    EasyMock.expect(inboundResponse.getAllHeaders()).andReturn(new 
Header[0]).anyTimes();
+    
EasyMock.expect(inboundRequest.getServletContext()).andReturn(context).anyTimes();
+    EasyMock.expect(entity.getContent())
+        .andAnswer(() -> new 
ByteArrayInputStream("knox-backend".getBytes(StandardCharsets.UTF_8))).anyTimes();
+    EasyMock.expect(entity.getContentType()).andReturn(header).anyTimes();
+    EasyMock.expect(header.getElements()).andReturn(new 
HeaderElement[]{}).anyTimes();
+    EasyMock.expect(entity.getContentLength()).andReturn(4L).anyTimes();
+    
EasyMock.expect(context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(config).anyTimes();
+
+    HttpServletResponse outboundResponse = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    Capture<Integer> statusCodeCapture = 
EasyMock.newCapture(CaptureType.FIRST);
+    outboundResponse.setStatus(EasyMock.captureInt(statusCodeCapture));
+    EasyMock.expectLastCall().anyTimes();
+    EasyMock.expect(outboundResponse.getOutputStream())
+        .andAnswer((IAnswer<SynchronousServletOutputStreamAdapter>) () -> new 
SynchronousServletOutputStreamAdapter() {
+          @Override
+          public void write(int b) { /* do nothing */ }
+        }).anyTimes();
+
+    /* record the backend each attempt actually targeted; fail the first two, 
succeed on the third */
+    final List<String> attemptedHosts = new ArrayList<>();
+    CloseableHttpClient httpClient = new CloseableHttpClient() {
+      @Override
+      protected CloseableHttpResponse doExecute(HttpHost target, HttpRequest 
request, HttpContext ctx) throws IOException {
+        attemptedHosts.add(outboundRequest.getURI().getHost());
+        if (attemptedHosts.size() <= 2) {
+          throw new IOException("unreachable-host");
+        }
+        return inboundResponse;
+      }
+      @Override public void close() { /* no-op */ }
+      @Override public HttpParams getParams() { return new BasicHttpParams(); }
+      @Override public ClientConnectionManager getConnectionManager() { return 
null; }
+    };
+
+    EasyMock.replay(inboundRequest, outboundResponse, inboundResponse,
+        statusLine, entity, header, context, config);
+
+    Assert.assertEquals(uri1.toString(), provider.getActiveURL(serviceName));
+    ConfigurableHADispatch dispatch = new ConfigurableHADispatch();
+    dispatch.setHttpClient(httpClient);
+    dispatch.setHaProvider(provider);
+    dispatch.setServiceRole(serviceName);
+    dispatch.init();
+
+    dispatch.executeRequestWrapper(outboundRequest, inboundRequest, 
outboundResponse);
+
+    /* both counter reads were consumed => exactly two failovers happened */
+    EasyMock.verify(inboundRequest);
+    Assert.assertEquals("Request must walk the full rotation without 
revisiting a backend.",
+        Arrays.asList(uri1.getHost(), uri2.getHost(), uri3.getHost()), 
attemptedHosts);
+    Assert.assertEquals("The finally-successful backend must be the outbound 
target.",
+        uri3.getHost(), outboundRequest.getURI().getHost());
+    Assert.assertEquals("Each attempt advances the rotation, so after serving 
on the last backend the head is the next one.",
+        uri1.toString(), provider.getActiveURL(serviceName));
+    Assert.assertEquals("Expected the request to succeed after failing over 
twice.",
+        HttpStatus.SC_OK, statusCodeCapture.getValue().intValue());
+  }
+
+  /*
+   * Failover-path counterpart to the initial-selection reconciliation: if the 
URL queue is
+   * rotated (by a concurrent request) between the rewrite's getActiveURL peek 
and our
+   * getActiveURLAndAdvance in prepareForFailover, updateBackendURL must 
re-target the outbound
+   * request at the backend we actually picked, carrying THAT backend's base 
path — not the
+   * stale peeked one. Here the rewrite peeks host1 (/cliservice) but the head 
has already
+   * advanced to host2 (/cliservice2) by the time we pick.
+   */
+  @Test
+  public void 
testPrepareForFailoverReTargetsWhenQueueRotatedBetweenPeekAndAdvance() throws 
Exception {
+    String serviceName = "HIVE";
+    HaDescriptor descriptor = HaDescriptorFactory.createDescriptor();
+    descriptor.addServiceConfig(HaDescriptorFactory.createServiceConfig(
+        serviceName, "true", "1", "0", null, null, "true", "false", null, 
null, null));
+    HaProvider provider = new DefaultHaProvider(descriptor);
+    ArrayList<String> urlList = new ArrayList<>();
+    urlList.add("http://host1.valid:8443/cliservice";);
+    urlList.add("http://host2.valid:8443/cliservice2";);
+    provider.addHaService(serviceName, urlList);
+
+    ConfigurableHADispatch dispatch = new ConfigurableHADispatch();
+    dispatch.setHaProvider(provider);
+    dispatch.setServiceRole(serviceName);
+    dispatch.init();
+
+    /* the outbound URI as the rewrite produced it after peeking host1 (host1 
base path + tail) */
+    HttpGet outboundRequest = new HttpGet(new 
URI("http://host1.valid:8443/cliservice/query?op=EXECUTE";));
+    HttpServletRequest inboundRequest = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    /* getDispatchUrl re-derives the peeked target from these on the retry */
+    EasyMock.expect(inboundRequest.getRequestURL())
+        .andReturn(new 
StringBuffer("http://host1.valid:8443/cliservice/query";)).anyTimes();
+    
EasyMock.expect(inboundRequest.getQueryString()).andReturn("op=EXECUTE").anyTimes();
+    EasyMock.replay(inboundRequest);
+
+    /* a concurrent request advances the head host1 -> host2 between the peek 
and our advance */
+    provider.makeNextActiveURLAvailable(serviceName);
+    Assert.assertEquals("http://host2.valid:8443/cliservice2";, 
provider.getActiveURL(serviceName));
+
+    dispatch.prepareForFailover(outboundRequest, inboundRequest);
+
+    Assert.assertEquals("Retried request must target the advanced pick with 
its own base path, not the peeked backend's",
+        new URI("http://host2.valid:8443/cliservice2/query?op=EXECUTE";), 
outboundRequest.getURI());
+    /* selection advanced the queue past the picked backend */
+    Assert.assertEquals("http://host1.valid:8443/cliservice";, 
provider.getActiveURL(serviceName));
+  }
 }
diff --git 
a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java
 
b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java
index c0ca0f2e9..97028a076 100644
--- 
a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java
+++ 
b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java
@@ -86,7 +86,6 @@ public class SSEHaDispatchTest {
 
     @Test
     public void testHADispatchURL() throws Exception {
-        String serviceName = "SSE";
         HaDescriptor descriptor = HaDescriptorFactory.createDescriptor();
         
descriptor.addServiceConfig(HaDescriptorFactory.createServiceConfig(serviceName,
 "true", "1", "1000", null, null, "true", "true", null, null, null, null));
         HaProvider provider = new DefaultHaProvider(descriptor);
@@ -344,7 +343,7 @@ public class SSEHaDispatchTest {
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         Capture<Cookie> capturedArgument = Capture.newInstance();
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("unknown").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, 
capturedArgument);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -358,7 +357,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doGet(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doGet(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
@@ -376,7 +375,7 @@ public class SSEHaDispatchTest {
         AsyncContext asyncContext = this.getAsyncContext(latch, 
outboundResponse);
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("unknown").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, null);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -390,7 +389,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doGet(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doGet(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
@@ -408,7 +407,7 @@ public class SSEHaDispatchTest {
         AsyncContext asyncContext = this.getAsyncContext(latch, 
outboundResponse);
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("unknown").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, null);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -422,7 +421,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doGet(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doGet(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
@@ -454,7 +453,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doGet(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doGet(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest);
@@ -473,7 +472,7 @@ public class SSEHaDispatchTest {
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         Capture<Cookie> capturedArgument = Capture.newInstance();
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("unknown").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, 
capturedArgument);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -487,7 +486,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doGet(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doGet(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
@@ -506,7 +505,7 @@ public class SSEHaDispatchTest {
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         Capture<Cookie> capturedArgument = Capture.newInstance();
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("unknown").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, 
capturedArgument);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -520,7 +519,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doPost(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doPost(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
@@ -539,7 +538,7 @@ public class SSEHaDispatchTest {
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         Capture<Cookie> capturedArgument = Capture.newInstance();
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("unknown").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, 
capturedArgument);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -553,7 +552,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doPost(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doPost(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
@@ -564,7 +563,7 @@ public class SSEHaDispatchTest {
     @Test
     public void testFailoverDisabledWithStickySession() throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
-        HaServiceConfig haServiceConfig = 
HaDescriptorFactory.createServiceConfig(serviceName, "true", "1", "1000", null, 
null, "true", "true", null, null, "agentX,user1,agentY", null);
+        HaServiceConfig haServiceConfig = 
HaDescriptorFactory.createServiceConfig(serviceName, "true", "1", "1000", null, 
null, "true", "true", null, "true", "agentX,user1,agentY", null);
         SSEHaDispatch sseHaDispatch = this.createDispatch(true, 
haServiceConfig);
         PrintWriter printWriter = EasyMock.createNiceMock(PrintWriter.class);
         HttpServletResponse outboundResponse = 
this.getServletResponse(HttpStatus.SC_OK);
@@ -572,7 +571,7 @@ public class SSEHaDispatchTest {
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         Capture<Cookie> capturedArgument = Capture.newInstance();
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("unknown").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, 
capturedArgument);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -586,7 +585,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doGet(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doGet(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
@@ -625,7 +624,7 @@ public class SSEHaDispatchTest {
         HttpServletRequest inboundRequest = 
this.getHttpServletRequest(asyncContext);
         Capture<Cookie> capturedArgument = Capture.newInstance();
         
expect(inboundRequest.getHeader("User-Agent")).andReturn("agentX").anyTimes();
-        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).once();
+        expect(inboundRequest.getRequestURL()).andReturn(new 
StringBuffer(URL.toString())).anyTimes();
         
expect(inboundRequest.getAttribute("dispatch.ha.failover.counter")).andReturn(new
 AtomicInteger(0)).once();
         this.expectResponseBodyAndHeader(printWriter, outboundResponse, 
capturedArgument);
         replay(inboundRequest, asyncContext, outboundResponse, printWriter);
@@ -639,7 +638,7 @@ public class SSEHaDispatchTest {
                 
.content("id:1\ndata:data1\nevent:event1\n\ndata:data2\nevent:event2\nid:2\nretry:1\n:testing\n\n",
 StandardCharsets.UTF_8)
                 .header("response", "header")
                 .contentType("text/event-stream");
-        sseHaDispatch.doGet(new URI("http://unknown-host.invalid";), 
inboundRequest, outboundResponse);
+        sseHaDispatch.doGet(new URI("http://unknown-host.invalid/sse";), 
inboundRequest, outboundResponse);
 
         latch.await(2L, TimeUnit.SECONDS);
         EasyMock.verify(asyncContext, outboundResponse, inboundRequest, 
printWriter);
diff --git 
a/gateway-spi/src/main/java/org/apache/knox/gateway/sse/SSEDispatch.java 
b/gateway-spi/src/main/java/org/apache/knox/gateway/sse/SSEDispatch.java
index 247536d71..5e8ec8e56 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/sse/SSEDispatch.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/sse/SSEDispatch.java
@@ -163,10 +163,6 @@ public class SSEDispatch extends ConfigurableDispatch 
implements AsyncDispatch {
         return (statusCode >= HttpStatus.SC_OK && statusCode < 300);
     }
 
-    protected void shiftCallback(HttpUriRequest outboundRequest, 
HttpServletRequest inboundRequest) {
-        // No need to shift the URL for non-HA SSE requests
-    }
-
     protected class SSECharConsumer extends AsyncCharConsumer<SSEResponse> {
         private SSEResponse sseResponse;
         private final HttpServletResponse outboundResponse;
@@ -192,7 +188,6 @@ public class SSEDispatch extends ConfigurableDispatch 
implements AsyncDispatch {
             } else {
                 handleErrorResponse(outboundResponse, url, inboundResponse);
             }
-            shiftCallback(outboundRequest, inboundRequest);
         }
 
         @Override

Reply via email to