diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index c6a7d00ba..b7ff37eb8 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -47,6 +47,7 @@
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/requestsize-factory.adoc[]
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/setrequesthostheader-factory.adoc[]
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/tokenrelay-factory.adoc[]
+*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc[]
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/default-filters.adoc[]
** xref:spring-cloud-gateway-server-webflux/global-filters.adoc[]
** xref:spring-cloud-gateway-server-webflux/httpheadersfilters.adoc[]
diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc
new file mode 100644
index 000000000..69f844196
--- /dev/null
+++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc
@@ -0,0 +1,57 @@
+[[websessionsticky-factory]]
+= `WebSessionSticky` `GatewayFilter` Factory
+
+The `WebSessionSticky` filter enables per-route, server-side sticky load balancing using
+the gateway's own `WebSession` as the affinity store. When this filter is applied to a
+route, every client session is pinned to a specific backend instance for the duration of
+that session. If the pinned instance is deregistered or fails a health check, the
+gateway transparently selects a new instance and updates the session affinity — the
+client sees no error.
+
+This filter is the per-route, composable alternative to enabling sticky routing via the
+`sticky://` URI scheme in the
+xref:spring-cloud-gateway-server-webflux/global-filters.adoc#web-session-sticky-load-balancer-filter[WebSessionStickyLoadBalancerFilter].
+Use this factory when you want `lb://` to remain the URI scheme and need to mix sticky
+and non-sticky routes to the same service.
+
+NOTE: This filter requires `WebSessionStickyLoadBalancerFilter` to be enabled:
+`spring.cloud.gateway.global-filter.web-session-sticky-load-balancer.enabled=true`
+
+The following listing shows how to add the `WebSessionSticky` `GatewayFilter`:
+
+.application.yaml
+[source,yaml]
+----
+spring:
+ cloud:
+ gateway:
+ server:
+ webflux:
+ global-filter:
+ web-session-sticky-load-balancer:
+ enabled: true
+ routes:
+ - id: legacy-service-route
+ uri: lb://legacy-service
+ predicates:
+ - Path=/legacy/**
+ filters:
+ - WebSessionSticky
+ - id: stateless-route
+ uri: lb://legacy-service
+ predicates:
+ - Path=/api/**
+----
+
+In this example, requests matching `/legacy/**` are pinned to a single instance of
+`legacy-service` for each client session. Requests matching `/api/**` use standard
+round-robin load balancing.
+
+NOTE: Session affinity is stored server-side in the gateway `WebSession`. Unlike the
+cookie-based `RequestBasedStickySessionServiceInstanceListSupplier`, clients do not need
+to store or echo any instance-ID cookie. This is particularly useful for legacy backends
+that cannot achieve distributed session state.
+
+NOTE: If you run multiple gateway instances, ensure that the session store is shared
+(e.g. Spring Session backed by Redis) so that a client reaching a different gateway
+replica retains its affinity.
diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc
index 5ee63d466..30fb969ab 100644
--- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc
+++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc
@@ -153,6 +153,94 @@ However, if `GATEWAY_SCHEME_PREFIX_ATTR` is specified for the route in the Gatew
TIP: Gateway supports all the LoadBalancer features. You can read more about them in the https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#spring-cloud-loadbalancer[Spring Cloud Commons documentation].
+[[web-session-sticky-load-balancer-filter]]
+== `WebSessionStickyLoadBalancerFilter`
+
+The `WebSessionStickyLoadBalancerFilter` provides server-side, WebSession-based sticky routing as an opt-in complement to the standard `ReactiveLoadBalancerClientFilter`.
+A route opts in by using `sticky://` as its URI scheme instead of `lb://`.
+
+Unlike the cookie-based approach provided by `LoadBalancerServiceInstanceCookieFilter`, this filter stores the `serviceId -> instanceId` affinity map server-side in the gateway's own `WebSession`.
+Clients do not need to send or manage any cookie.
+
+When a request arrives on a sticky route, the filter:
+
+1. Looks up an existing `serviceId -> instanceId` affinity in the session.
+2. If a pinned instance is found *and* that instance is still registered, routes directly to it.
+3. If no affinity exists, or the pinned instance has been deregistered (e.g. after a health-check failure), selects a new instance via the configured delegate balancer (round-robin by default) and records the new affinity in the session.
+
+This means a failed instance is transparently replaced — the client sees no error and subsequent requests are pinned to the new instance.
+
+=== Enabling the filter
+
+The filter is disabled by default.
+Enable it with:
+
+.application.yml
+[source,yaml]
+----
+spring:
+ cloud:
+ gateway:
+ server:
+ webflux:
+ global-filter:
+ web-session-sticky-load-balancer-filter:
+ enabled: true
+----
+
+=== Route configuration
+
+Use `sticky://` as the URI scheme on any route that should use session affinity:
+
+.application.yml
+[source,yaml]
+----
+spring:
+ cloud:
+ gateway:
+ server:
+ webflux:
+ routes:
+ - id: sticky-api-route
+ uri: sticky://my-service
+ predicates:
+ - Path=/api/**
+ - id: regular-route
+ uri: lb://my-service
+ predicates:
+ - Path=/public/**
+----
+
+In the example above, `/api/**` requests are pinned to a single instance of `my-service` for the life of the session, while `/public/**` requests are distributed across instances using the normal round-robin balancer.
+
+NOTE: The `WebSessionStickyLoadBalancerFilter` handles only `sticky://` URIs.
+Routes using the standard `lb://` scheme continue to be handled by `ReactiveLoadBalancerClientFilter` and are unaffected.
+
+NOTE: Session affinity depends on the gateway maintaining a `WebSession` for each client.
+Ensure that session persistence (e.g. Spring Session with Redis) is configured if you run multiple gateway instances; otherwise, a client that reaches a different gateway replica will lose its affinity until a new instance is selected.
+
+=== Comparison with cookie-based sticky sessions
+
+|===
+| Feature | `LoadBalancerServiceInstanceCookieFilter` | `WebSessionStickyLoadBalancerFilter`
+
+| Affinity storage
+| Client-side HTTP cookie (`sc-lb-instance-id`)
+| Server-side `WebSession` attribute
+
+| Per-route opt-in
+| No — configured per service via `LoadBalancerProperties`
+| Yes — use `sticky://` on individual routes
+
+| Client transparency
+| Client must echo the cookie on each request
+| Fully transparent; no client change required
+
+| Graceful re-pin on instance failure
+| No — cookie retains stale instance id
+| Yes — stale affinity is replaced automatically
+|===
+
[[routetorequesturl-filter]]
== `RouteToRequestUrl` Filter
diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java
index 306b5bc60..1e11e8359 100644
--- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java
+++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java
@@ -22,9 +22,12 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
+import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter;
import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledGlobalFilter;
import org.springframework.cloud.gateway.filter.LoadBalancerServiceInstanceCookieFilter;
import org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter;
+import org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancerFilter;
+import org.springframework.cloud.gateway.filter.factory.WebSessionStickyGatewayFilterFactory;
import org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
import org.springframework.context.annotation.Bean;
@@ -36,6 +39,7 @@
*
* @author Spencer Gibb
* @author Olga Maciaszek-Sharma
+ * @author Beteab Gebru
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ ReactiveLoadBalancer.class, LoadBalancerAutoConfiguration.class, DispatcherHandler.class })
@@ -61,4 +65,21 @@ public LoadBalancerServiceInstanceCookieFilter loadBalancerServiceInstanceCookie
return new LoadBalancerServiceInstanceCookieFilter(loadBalancerClientFactory);
}
+ @Bean
+ @ConditionalOnBean(LoadBalancerClientFactory.class)
+ @ConditionalOnMissingBean(WebSessionStickyLoadBalancerFilter.class)
+ @ConditionalOnEnabledGlobalFilter(WebSessionStickyLoadBalancerFilter.class)
+ public WebSessionStickyLoadBalancerFilter webSessionStickyLoadBalancerFilter(
+ LoadBalancerClientFactory clientFactory) {
+ return new WebSessionStickyLoadBalancerFilter(clientFactory);
+ }
+
+ @Bean
+ @ConditionalOnBean(LoadBalancerClientFactory.class)
+ @ConditionalOnMissingBean(WebSessionStickyGatewayFilterFactory.class)
+ @ConditionalOnEnabledFilter(WebSessionStickyGatewayFilterFactory.class)
+ public WebSessionStickyGatewayFilterFactory webSessionStickyGatewayFilterFactory() {
+ return new WebSessionStickyGatewayFilterFactory();
+ }
+
}
diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancer.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancer.java
new file mode 100644
index 000000000..194de39c4
--- /dev/null
+++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancer.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.cloud.gateway.filter;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import reactor.core.publisher.Mono;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.cloud.client.ServiceInstance;
+import org.springframework.cloud.client.loadbalancer.DefaultResponse;
+import org.springframework.cloud.client.loadbalancer.EmptyResponse;
+import org.springframework.cloud.client.loadbalancer.Request;
+import org.springframework.cloud.client.loadbalancer.Response;
+import org.springframework.cloud.loadbalancer.core.NoopServiceInstanceListSupplier;
+import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;
+import org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer;
+import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * A {@link ReactorServiceInstanceLoadBalancer} that implements server-side,
+ * WebSession-based sticky routing. When the exchange attribute
+ * {@link WebSessionStickyLoadBalancerFilter#IS_STICKY_ATTRIBUTE} is {@code true}, the
+ * balancer reads a {@code serviceId -> instanceId} affinity map from the current
+ * {@link org.springframework.web.server.WebSession} and re-uses the previously selected
+ * instance if it is still available. If no affinity is recorded, or the recorded instance
+ * has been deregistered, a fresh instance is selected via the delegate (round-robin by
+ * default) and the new affinity is written back to the session.
+ *
+ *
+ * When the {@code IS_STICKY_ATTRIBUTE} is absent or {@code false}, or when the request
+ * context is not a {@link ServerWebExchange} (e.g. an internal {@code WebClient} call),
+ * this balancer transparently delegates to the round-robin balancer so that non-sticky
+ * routes are unaffected.
+ *
+ * @author Beteab Gebru
+ * @since 5.0.4
+ * @see WebSessionStickyLoadBalancerFilter
+ */
+public class WebSessionStickyLoadBalancer implements ReactorServiceInstanceLoadBalancer {
+
+ /**
+ * Logger for this class.
+ */
+ private static final Log LOG = LogFactory.getLog(WebSessionStickyLoadBalancer.class);
+
+ /**
+ * Session attribute key under which the {@code serviceId -> instanceId} affinity map
+ * is stored.
+ */
+ public static final String STICKY_MAP_SESSION_ATTR = "spring.cloud.gateway.sticky.service-map";
+
+ private final String serviceId;
+
+ private final ReactorServiceInstanceLoadBalancer delegate;
+
+ private final ObjectProvider serviceInstanceListSupplierProvider;
+
+ /**
+ * Creates a {@code WebSessionStickyLoadBalancer} backed by a
+ * {@link RoundRobinLoadBalancer} delegate.
+ * @param supplierProvider provider of available service instances
+ * @param serviceId the service identifier used to key session affinity
+ */
+ public WebSessionStickyLoadBalancer(ObjectProvider supplierProvider,
+ String serviceId) {
+ this(supplierProvider, serviceId, new RoundRobinLoadBalancer(supplierProvider, serviceId));
+ }
+
+ /**
+ * Creates a {@code WebSessionStickyLoadBalancer} with a custom delegate.
+ * @param supplierProvider provider of available service instances
+ * @param serviceId the service identifier used to key session affinity
+ * @param delegate the fallback balancer used when stickiness does not apply
+ */
+ public WebSessionStickyLoadBalancer(ObjectProvider supplierProvider, String serviceId,
+ ReactorServiceInstanceLoadBalancer delegate) {
+ this.serviceId = serviceId;
+ this.serviceInstanceListSupplierProvider = supplierProvider;
+ this.delegate = delegate;
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Mono> choose(final Request request) {
+ ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider
+ .getIfAvailable(NoopServiceInstanceListSupplier::new);
+ Object context = request.getContext();
+
+ // If the context is not a ServerWebExchange we have no session to pin
+ // against — this happens for internal WebClient calls that originate
+ // within the gateway itself rather than from a routed inbound request.
+ if (!(context instanceof ServerWebExchange)) {
+ return delegate.choose(request);
+ }
+
+ ServerWebExchange exchange = (ServerWebExchange) context;
+ return supplier.get().next().flatMap(instances -> getInstanceResponse(instances, exchange, request));
+ }
+
+ @SuppressWarnings("rawtypes")
+ private Mono> getInstanceResponse(final List instances,
+ final ServerWebExchange exchange, final Request request) {
+ if (instances.isEmpty()) {
+ LOG.warn("No servers available for service: " + this.serviceId);
+ return Mono.just(new EmptyResponse());
+ }
+
+ Boolean isSticky = exchange.getAttributeOrDefault(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE,
+ Boolean.FALSE);
+ if (!isSticky) {
+ return delegate.choose(request);
+ }
+
+ return serviceInstanceFromSession(exchange, instances).flatMap(instance -> {
+ Response response = new DefaultResponse(instance);
+ return writeSessionAffinity(exchange, response);
+ })
+ .switchIfEmpty(Mono
+ .defer(() -> delegate.choose(request).flatMap(response -> writeSessionAffinity(exchange, response))));
+ }
+
+ private Mono serviceInstanceFromSession(final ServerWebExchange exchange,
+ final List instances) {
+ return exchange.getSession().flatMap(session -> {
+ @SuppressWarnings("unchecked")
+ Map stickyMap = (Map) session.getAttribute(STICKY_MAP_SESSION_ATTR);
+ if (stickyMap == null || !stickyMap.containsKey(serviceId)) {
+ LOG.debug("No existing session affinity for service '" + serviceId + "', selecting new instance");
+ return Mono.empty();
+ }
+ String pinnedInstanceId = stickyMap.get(serviceId);
+ Optional match = instances.stream()
+ .filter(i -> Objects.equals(i.getInstanceId(), pinnedInstanceId))
+ .findFirst();
+ if (!match.isPresent()) {
+ LOG.debug("Pinned instance '" + pinnedInstanceId + "' for service '" + serviceId
+ + "' is no longer available, selecting new instance");
+ return Mono.empty();
+ }
+ LOG.debug("Reusing pinned instance '" + pinnedInstanceId + "' for service '" + serviceId + "'");
+ return Mono.just(match.get());
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ private Mono> writeSessionAffinity(final ServerWebExchange exchange,
+ final Response response) {
+ if (!response.hasServer()) {
+ return Mono.just(response);
+ }
+ return exchange.getSession().map(session -> {
+ Map stickyMap = (Map) session.getAttribute(STICKY_MAP_SESSION_ATTR);
+ if (stickyMap == null) {
+ stickyMap = new HashMap<>();
+ session.getAttributes().put(STICKY_MAP_SESSION_ATTR, stickyMap);
+ }
+ stickyMap.put(serviceId, response.getServer().getInstanceId());
+ return response;
+ });
+ }
+
+}
diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilter.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilter.java
new file mode 100644
index 000000000..e19455a6a
--- /dev/null
+++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilter.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.cloud.gateway.filter;
+
+import java.net.URI;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import reactor.core.publisher.Mono;
+
+import org.springframework.cloud.client.ServiceInstance;
+import org.springframework.cloud.client.loadbalancer.DefaultRequest;
+import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools;
+import org.springframework.cloud.client.loadbalancer.Request;
+import org.springframework.cloud.client.loadbalancer.Response;
+import org.springframework.cloud.gateway.support.DelegatingServiceInstance;
+import org.springframework.cloud.gateway.support.NotFoundException;
+import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;
+import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
+import org.springframework.core.Ordered;
+import org.springframework.web.server.ServerWebExchange;
+
+import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;
+import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR;
+import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl;
+
+/**
+ * A {@link GlobalFilter} that provides WebSession-based sticky load balancing as an
+ * opt-in complement to the standard {@link ReactiveLoadBalancerClientFilter}.
+ *
+ *
+ * Routes opt in to sticky routing by using {@code sticky://} as the URI scheme (e.g.
+ * {@code sticky://myservice}). Routes using the normal {@code lb://} scheme are handled
+ * by {@link ReactiveLoadBalancerClientFilter} and are not affected by this filter.
+ *
+ *
+ * This filter sets the exchange attribute {@link #IS_STICKY_ATTRIBUTE} to {@code true}
+ * for sticky routes, then delegates instance selection to a
+ * {@link WebSessionStickyLoadBalancer}. The load balancer uses the gateway's own
+ * {@link org.springframework.web.server.WebSession} to pin a client to a specific service
+ * instance. If the previously pinned instance is no longer available, the balancer picks
+ * a new instance and updates the session affinity transparently.
+ *
+ *
+ * This filter runs at order
+ * {@link ReactiveLoadBalancerClientFilter#LOAD_BALANCER_CLIENT_FILTER_ORDER}. Enable it
+ * with:
+ *
+ * @author Beteab Gebru
+ * @since 5.0.4
+ * @see WebSessionStickyLoadBalancer
+ */
+public class WebSessionStickyLoadBalancerFilter implements GlobalFilter, Ordered {
+
+ /**
+ * Logger for this class.
+ */
+ private static final Log LOG = LogFactory.getLog(WebSessionStickyLoadBalancerFilter.class);
+
+ /**
+ * Filter order — matches
+ * {@link ReactiveLoadBalancerClientFilter#LOAD_BALANCER_CLIENT_FILTER_ORDER}.
+ */
+ public static final int LOAD_BALANCER_CLIENT_FILTER_ORDER = ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER;
+
+ /**
+ * URI scheme that triggers WebSession-based sticky routing.
+ */
+ public static final String STICKY_SCHEME = "sticky";
+
+ /**
+ * Exchange attribute set to {@code true} when the route uses the
+ * {@link #STICKY_SCHEME} scheme. Consulted by {@link WebSessionStickyLoadBalancer} to
+ * decide whether to apply session affinity.
+ */
+ public static final String IS_STICKY_ATTRIBUTE = "spring.cloud.gateway.sticky.is-sticky";
+
+ private final LoadBalancerClientFactory clientFactory;
+
+ /**
+ * Creates a new {@code WebSessionStickyLoadBalancerFilter}.
+ * @param clientFactory the factory used to resolve per-service load balancers
+ */
+ public WebSessionStickyLoadBalancerFilter(final LoadBalancerClientFactory clientFactory) {
+ this.clientFactory = clientFactory;
+ }
+
+ @Override
+ public int getOrder() {
+ return LOAD_BALANCER_CLIENT_FILTER_ORDER;
+ }
+
+ @Override
+ public Mono filter(final ServerWebExchange exchange, final GatewayFilterChain chain) {
+ URI url = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
+ String schemePrefix = exchange.getAttribute(GATEWAY_SCHEME_PREFIX_ATTR);
+
+ if (url == null || (!isStickyScheme(url.getScheme()) && !isStickyScheme(schemePrefix))) {
+ return chain.filter(exchange);
+ }
+
+ boolean isSticky = isStickyScheme(url.getScheme()) || isStickyScheme(schemePrefix);
+ exchange.getAttributes().put(IS_STICKY_ATTRIBUTE, isSticky);
+
+ addOriginalRequestUrl(exchange, url);
+
+ if (LOG.isTraceEnabled()) {
+ LOG.trace(WebSessionStickyLoadBalancerFilter.class.getSimpleName() + " url before: " + url + ", sticky="
+ + isSticky);
+ }
+
+ return choose(exchange).doOnNext(response -> {
+ if (!response.hasServer()) {
+ throw NotFoundException.create(false, "Unable to find instance for " + url.getHost());
+ }
+
+ URI uri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
+ String overrideScheme = null;
+ if (schemePrefix != null) {
+ overrideScheme = url.getScheme();
+ }
+
+ DelegatingServiceInstance serviceInstance = new DelegatingServiceInstance(response.getServer(),
+ overrideScheme);
+ URI requestUrl = reconstructURI(serviceInstance, uri);
+
+ if (LOG.isTraceEnabled()) {
+ LOG.trace(WebSessionStickyLoadBalancerFilter.class.getSimpleName() + " url chosen: " + requestUrl);
+ }
+ exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, requestUrl);
+ }).then(chain.filter(exchange));
+ }
+
+ private static boolean isStickyScheme(final String scheme) {
+ return STICKY_SCHEME.equals(scheme);
+ }
+
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ private Mono> choose(final ServerWebExchange exchange) {
+ URI uri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
+ ReactorServiceInstanceLoadBalancer loadBalancer = this.clientFactory.getInstance(uri.getHost(),
+ ReactorServiceInstanceLoadBalancer.class);
+ if (loadBalancer == null) {
+ throw new NotFoundException("No loadbalancer available for " + uri.getHost());
+ }
+ return loadBalancer.choose(createRequest(exchange));
+ }
+
+ private static Request createRequest(final ServerWebExchange exchange) {
+ return new DefaultRequest<>(exchange);
+ }
+
+ /**
+ * Reconstructs the target URI using the chosen {@link ServiceInstance}. Protected to
+ * allow overriding in subclasses or tests.
+ * @param serviceInstance the chosen instance
+ * @param original the original request URI
+ * @return the rewritten URI pointing at the chosen instance
+ */
+ protected URI reconstructURI(final ServiceInstance serviceInstance, final URI original) {
+ return LoadBalancerUriTools.reconstructURI(serviceInstance, original);
+ }
+
+}
diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactory.java
new file mode 100644
index 000000000..a08105400
--- /dev/null
+++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactory.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.cloud.gateway.filter.factory;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.cloud.gateway.filter.GatewayFilter;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancerFilter;
+import org.springframework.web.server.ServerWebExchange;
+
+import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator;
+
+/**
+ * A {@link GatewayFilterFactory} that enables WebSession-based sticky load balancing on a
+ * per-route basis without requiring a custom URI scheme.
+ *
+ *
+ * When applied to a route, this filter sets the
+ * {@link WebSessionStickyLoadBalancerFilter#IS_STICKY_ATTRIBUTE} exchange attribute to
+ * {@code true} before the load-balancer filter runs. The
+ * {@link org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancer} reads
+ * this attribute to apply session affinity: the first request from a client selects an
+ * instance via the delegate (round-robin by default) and records that selection in the
+ * gateway {@link org.springframework.web.server.WebSession}; all subsequent requests from
+ * that client session are routed to the same instance. If the instance is deregistered or
+ * fails a health check, a fresh instance is chosen transparently and the session affinity
+ * is updated.
+ *
+ *
+ * Unlike the {@code sticky://} scheme approach, this factory works with the standard
+ * {@code lb://} URI scheme and composes naturally with other {@link GatewayFilterFactory}
+ * filters on the same route. It requires {@link WebSessionStickyLoadBalancerFilter} to be
+ * enabled:
+ *
+ *
+ *
+ * In the example above, {@code /legacy/**} requests are pinned to a single instance of
+ * {@code legacy-service} for the life of each client session, while {@code /api/**}
+ * requests are distributed via normal round-robin.
+ *
+ * @author Beteab Gebru
+ * @since 5.0.4
+ * @see WebSessionStickyLoadBalancerFilter
+ * @see org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancer
+ */
+public class WebSessionStickyGatewayFilterFactory extends AbstractGatewayFilterFactory