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

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


The following commit(s) were added to refs/heads/master by this push:
     new a599e7b8a1 feature(plugin-divide): add validated label-based canary 
routing. (#7963)
a599e7b8a1 is described below

commit a599e7b8a13132f1a703349f286cfe5b54a14d2a
Author: SouthwestAsiaFloat <[email protected]>
AuthorDate: Fri Oct 23 11:22:44 2026 +0800

    feature(plugin-divide): add validated label-based canary routing. (#7963)
---
 .../shenyu/admin/service/impl/RuleServiceImpl.java |  48 +++-
 .../admin/service/ConfigsImportDuplicateTest.java  |   3 +-
 .../shenyu/admin/service/ConfigsServiceTest.java   |   3 +-
 .../shenyu/admin/service/RuleServiceTest.java      | 159 ++++++++++-
 shenyu-common/pom.xml                              |   5 +
 .../dto/convert/rule/canary/CanaryConfig.java      | 265 +++++++++++++++++
 .../convert/rule/canary/CanaryConfigValidator.java | 208 ++++++++++++++
 .../dto/convert/rule/canary/StickyKeyConfig.java   |  98 +++++++
 .../dto/convert/rule/impl/DivideRuleHandle.java    |  32 ++-
 .../rule/canary/CanaryConfigValidatorTest.java     | 142 +++++++++
 .../convert/rule/impl/DivideRuleHandleTest.java    |  44 +++
 .../loadbalancer/cache/UpstreamCacheManager.java   |  21 ++
 .../shenyu/loadbalancer/entity/Upstream.java       |   2 +-
 .../plugin/base/utils/UpstreamLabelUtils.java      |  54 ++++
 .../apache/shenyu/plugin/divide/DividePlugin.java  |  43 ++-
 .../plugin/divide/canary/CanaryDecision.java       |  49 ++++
 .../divide/canary/CanaryDecisionService.java       |  37 +++
 .../canary/DefaultCanaryDecisionService.java       | 102 +++++++
 .../divide/handler/DividePluginDataHandler.java    |  10 +-
 .../divide/handler/DivideUpstreamDataHandler.java  |  12 +
 .../plugin/divide/DivideCanaryRoutingTest.java     | 317 +++++++++++++++++++++
 .../canary/DefaultCanaryDecisionServiceTest.java   | 248 ++++++++++++++++
 .../canary/fixture/AttributeParameterData.java     |  35 +++
 .../handler/DividePluginDataHandlerTest.java       |  84 ++++++
 .../handler/DivideUpstreamDataHandlerTest.java     |  36 +++
 ...shenyu.plugin.base.condition.data.ParameterData |  18 ++
 .../plugin/divide/DividePluginConfiguration.java   |  19 +-
 .../divide/DividePluginConfigurationTest.java      |  19 ++
 28 files changed, 2100 insertions(+), 13 deletions(-)

diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
index a7e2838b4e..8dc4524ffe 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java
@@ -30,12 +30,14 @@ import org.apache.shenyu.admin.mapper.PluginMapper;
 import org.apache.shenyu.admin.mapper.RuleConditionMapper;
 import org.apache.shenyu.admin.mapper.RuleMapper;
 import org.apache.shenyu.admin.mapper.SelectorMapper;
+import org.apache.shenyu.admin.mapper.ShenyuDictMapper;
 import org.apache.shenyu.admin.model.dto.RuleConditionDTO;
 import org.apache.shenyu.admin.model.dto.RuleDTO;
 import org.apache.shenyu.admin.model.entity.PluginDO;
 import org.apache.shenyu.admin.model.entity.RuleConditionDO;
 import org.apache.shenyu.admin.model.entity.RuleDO;
 import org.apache.shenyu.admin.model.entity.SelectorDO;
+import org.apache.shenyu.admin.model.entity.ShenyuDictDO;
 import org.apache.shenyu.admin.model.event.rule.RuleCreatedEvent;
 import org.apache.shenyu.admin.model.event.selector.BatchSelectorDeletedEvent;
 import org.apache.shenyu.admin.model.page.CommonPager;
@@ -58,6 +60,8 @@ import 
org.apache.shenyu.admin.validation.validator.RewriteRuleHandleValidator;
 import org.apache.shenyu.common.constant.AdminConstants;
 import org.apache.shenyu.common.dto.ConditionData;
 import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfigValidator;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
 import org.apache.shenyu.common.enums.MatchModeEnum;
 import org.apache.shenyu.common.enums.PluginEnum;
 import org.apache.shenyu.common.utils.JsonUtils;
@@ -97,16 +101,20 @@ public class RuleServiceImpl implements RuleService {
 
     private final RuleEventPublisher ruleEventPublisher;
 
+    private final ShenyuDictMapper shenyuDictMapper;
+
     public RuleServiceImpl(final RuleMapper ruleMapper,
                            final RuleConditionMapper ruleConditionMapper,
                            final SelectorMapper selectorMapper,
                            final PluginMapper pluginMapper,
-                           final RuleEventPublisher ruleEventPublisher) {
+                           final RuleEventPublisher ruleEventPublisher,
+                           final ShenyuDictMapper shenyuDictMapper) {
         this.ruleMapper = ruleMapper;
         this.ruleConditionMapper = ruleConditionMapper;
         this.selectorMapper = selectorMapper;
         this.pluginMapper = pluginMapper;
         this.ruleEventPublisher = ruleEventPublisher;
+        this.shenyuDictMapper = shenyuDictMapper;
     }
 
     @Override
@@ -157,6 +165,7 @@ public class RuleServiceImpl implements RuleService {
             return "";
         }
         validateRuleHandle(ruleDTO);
+        validateCanaryConfig(ruleDTO.getSelectorId(), ruleDTO.getHandle());
         RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
         if (StringUtils.isEmpty(ruleDTO.getId())) {
             ruleMapper.insertSelective(ruleDO);
@@ -182,6 +191,7 @@ public class RuleServiceImpl implements RuleService {
     @Override
     public int create(final RuleDTO ruleDTO) {
         validateRuleHandle(ruleDTO);
+        validateCanaryConfig(ruleDTO.getSelectorId(), ruleDTO.getHandle());
         RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
         final int ruleCount = ruleMapper.insertSelective(ruleDO);
         addCondition(ruleDO, ruleDTO.getRuleConditions());
@@ -196,6 +206,7 @@ public class RuleServiceImpl implements RuleService {
         validateRuleHandle(ruleDTO);
         final RuleDO before = ruleMapper.selectById(ruleDTO.getId());
         Assert.notNull(before, "the updated rule is not found");
+        
validateCanaryConfig(StringUtils.defaultIfBlank(ruleDTO.getSelectorId(), 
before.getSelectorId()), ruleDTO.getHandle());
         RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
         final int ruleCount = ruleMapper.updateSelective(ruleDO);
 
@@ -231,6 +242,38 @@ public class RuleServiceImpl implements RuleService {
         return ruleCount;
     }
 
+    private void validateCanaryConfig(final String selectorId, final String 
handle) {
+        if (StringUtils.isBlank(handle)) {
+            return;
+        }
+        SelectorDO selector = selectorMapper.selectById(selectorId);
+        if (Objects.isNull(selector)) {
+            return;
+        }
+        PluginDO plugin = pluginMapper.selectById(selector.getPluginId());
+        if (Objects.isNull(plugin) || 
!PluginEnum.DIVIDE.getName().equals(plugin.getName())) {
+            return;
+        }
+        try {
+            DivideRuleHandle divide = 
CanaryConfigValidator.parseHandle(handle);
+            if (Objects.nonNull(divide) && 
Objects.nonNull(divide.getCanary())) {
+                CanaryConfigValidator.validateExtensions(divide.getCanary(),
+                        name -> validateCanaryDictionary("paramType", name), 
name -> validateCanaryDictionary("operator", name));
+            }
+        } catch (IllegalArgumentException ex) {
+            throw new ShenyuAdminException(ex.getMessage(), ex);
+        }
+    }
+
+    private void validateCanaryDictionary(final String type, final String 
name) {
+        boolean supported = shenyuDictMapper.findByType(type).stream()
+                .filter(dict -> Boolean.TRUE.equals(dict.getEnabled()))
+                .map(ShenyuDictDO::getDictValue).anyMatch(name::equals);
+        if (!supported) {
+            throw new IllegalArgumentException("Unsupported Canary " + type + 
": " + name);
+        }
+    }
+
     /**
      * find rule by id and namespaceId.
      *
@@ -303,6 +346,7 @@ public class RuleServiceImpl implements RuleService {
             return ConfigImportResult.success();
         }
 
+        ruleList.forEach(rule -> validateCanaryConfig(rule.getSelectorId(), 
rule.getHandle()));
         Map<String, List<RuleDO>> selectorRuleMap = ruleMapper
                 .selectAll()
                 .stream()
@@ -348,6 +392,8 @@ public class RuleServiceImpl implements RuleService {
             return ConfigImportResult.success();
         }
         Map<String, String> selectorIdMapping = context.getSelectorIdMapping();
+        ruleList.stream().filter(rule -> 
selectorIdMapping.containsKey(rule.getSelectorId()))
+                .forEach(rule -> 
validateCanaryConfig(selectorIdMapping.get(rule.getSelectorId()), 
rule.getHandle()));
         Map<String, Set<String>> selectorRuleMap = ruleMapper
                 .selectAllByNamespaceId(namespace)
                 .stream()
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
index 7442bffcc9..9528e36049 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsImportDuplicateTest.java
@@ -160,7 +160,8 @@ public final class ConfigsImportDuplicateTest {
     @ParameterizedTest
     @ValueSource(booleans = {false, true})
     public void testRulesDeduplicateAfterSelectorRemapping(final boolean 
existing) {
-        final RuleServiceImpl service = new RuleServiceImpl(ruleMapper, 
mock(RuleConditionMapper.class), selectorMapper, pluginMapper, 
mock(RuleEventPublisher.class));
+        final RuleServiceImpl service = new RuleServiceImpl(ruleMapper, 
mock(RuleConditionMapper.class), selectorMapper, pluginMapper,
+                mock(RuleEventPublisher.class), dictMapper);
         final ConfigsImportContext context = new ConfigsImportContext();
         context.getSelectorIdMapping().put("source", "target");
         context.getSelectorIdMapping().put("alias", "target");
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
index 47e55830a6..46ef5c4196 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ConfigsServiceTest.java
@@ -22,6 +22,7 @@ import org.apache.shenyu.admin.exception.ShenyuAdminException;
 import org.apache.shenyu.admin.mapper.PluginMapper;
 import org.apache.shenyu.admin.mapper.RuleConditionMapper;
 import org.apache.shenyu.admin.mapper.RuleMapper;
+import org.apache.shenyu.admin.mapper.ShenyuDictMapper;
 import org.apache.shenyu.admin.mapper.SelectorMapper;
 import org.apache.shenyu.admin.model.dto.RuleDTO;
 import org.apache.shenyu.admin.model.entity.PluginDO;
@@ -170,7 +171,7 @@ public final class ConfigsServiceTest {
         SelectorMapper selectorMapper = mock(SelectorMapper.class);
         PluginMapper pluginMapper = mock(PluginMapper.class);
         RuleService ruleServiceImpl = new RuleServiceImpl(ruleMapper, 
mock(RuleConditionMapper.class),
-                selectorMapper, pluginMapper, mock(RuleEventPublisher.class));
+                selectorMapper, pluginMapper, mock(RuleEventPublisher.class), 
mock(ShenyuDictMapper.class));
         ConfigsServiceImpl importService = new 
ConfigsServiceImpl(appAuthService, pluginService, namespacePluginService,
                 pluginHandleService, selectorService, ruleServiceImpl, 
metaDataService, shenyuDictService,
                 proxySelectorService, discoveryService, 
discoveryUpstreamService, Collections.emptyList());
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
index 1cfc2ad1ae..a75877eda1 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/RuleServiceTest.java
@@ -21,6 +21,8 @@ import com.github.pagehelper.Page;
 import com.github.pagehelper.PageInfo;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.shenyu.admin.exception.ShenyuAdminException;
 import org.apache.shenyu.admin.mapper.DataPermissionMapper;
@@ -28,6 +30,7 @@ import org.apache.shenyu.admin.mapper.PluginMapper;
 import org.apache.shenyu.admin.mapper.RuleConditionMapper;
 import org.apache.shenyu.admin.mapper.RuleMapper;
 import org.apache.shenyu.admin.mapper.SelectorMapper;
+import org.apache.shenyu.admin.mapper.ShenyuDictMapper;
 import org.apache.shenyu.admin.model.custom.UserInfo;
 import org.apache.shenyu.admin.model.dto.DataPermissionDTO;
 import org.apache.shenyu.admin.model.dto.RuleConditionDTO;
@@ -37,6 +40,7 @@ import org.apache.shenyu.admin.model.entity.PluginDO;
 import org.apache.shenyu.admin.model.entity.RuleConditionDO;
 import org.apache.shenyu.admin.model.entity.RuleDO;
 import org.apache.shenyu.admin.model.entity.SelectorDO;
+import org.apache.shenyu.admin.model.entity.ShenyuDictDO;
 import org.apache.shenyu.admin.model.page.CommonPager;
 import org.apache.shenyu.admin.model.page.PageCondition;
 import org.apache.shenyu.admin.model.page.PageParameter;
@@ -54,6 +58,9 @@ import org.apache.shenyu.common.enums.PluginEnum;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.MockedStatic;
@@ -85,6 +92,7 @@ import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 /**
@@ -94,6 +102,9 @@ import static org.mockito.Mockito.when;
 @MockitoSettings(strictness = Strictness.LENIENT)
 public final class RuleServiceTest {
 
+    @Mock
+    private ShenyuDictMapper shenyuDictMapper;
+
     @InjectMocks
     private RuleServiceImpl ruleService;
 
@@ -118,7 +129,7 @@ public final class RuleServiceTest {
     @BeforeEach
     public void setUp() {
         
when(dataPermissionMapper.listByUserId("1")).thenReturn(Collections.singletonList(DataPermissionDO.buildPermissionDO(new
 DataPermissionDTO())));
-        ruleService = new RuleServiceImpl(ruleMapper, ruleConditionMapper, 
selectorMapper, pluginMapper, ruleEventPublisher);
+        ruleService = new RuleServiceImpl(ruleMapper, ruleConditionMapper, 
selectorMapper, pluginMapper, ruleEventPublisher, shenyuDictMapper);
     }
 
     @Test
@@ -563,6 +574,152 @@ public final class RuleServiceTest {
         
given(this.ruleConditionMapper.selectByRuleIdSet(Sets.newHashSet("123"))).willReturn(Collections.singletonList(buildRuleConditionDO()));
     }
 
+    @Test
+    public void testCreateRejectsOverlappingCanaryLabelsBeforeSaving() {
+        configureDivideSelector();
+        RuleDTO dto = buildRuleDTO("");
+        
dto.setHandle("{\"canary\":{\"enabled\":false,\"canaryLabels\":{\"release\":\"canary\"},\"stableLabels\":{\"region\":\"east\"}}}");
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.createOrUpdate(dto));
+        verify(ruleMapper, never()).insertSelective(any());
+    }
+
+    @Test
+    public void testUpdateRejectsOverlappingCanaryLabelsBeforeSaving() {
+        configureDivideSelector();
+        RuleDTO dto = buildRuleDTO("123");
+        
dto.setHandle("{\"canary\":{\"canaryLabels\":{\"release\":\"canary\"},\"stableLabels\":{\"release\":\"canary\"}}}");
+        given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.createOrUpdate(dto));
+        verify(ruleMapper, never()).updateSelective(any());
+    }
+
+    @Test
+    public void testSaveAllowsDisabledCanaryWithDisjointPools() {
+        configureDivideSelector();
+        RuleDTO dto = buildRuleDTO("");
+        
dto.setHandle("{\"canary\":{\"enabled\":false,\"canaryLabels\":{\"release\":\"canary\"},\"stableLabels\":{\"release\":\"stable\"}}}");
+        given(ruleMapper.insertSelective(any())).willReturn(1);
+        try (MockedStatic<JwtUtils> jwt = mockStatic(JwtUtils.class)) {
+            
jwt.when(JwtUtils::getUserInfo).thenReturn(UserInfo.builder().userId("1").userName("admin").build());
+            assertEquals(1, ruleService.createOrUpdate(dto));
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"percentage\":101}", "{\"percentage\":20.5}", 
"{\"matchMode\":2}",
+        "{\"enabled\":true,\"percentage\":20}", 
"{\"stableLabels\":{\"release\":true}}", "{\"fallbackPolicy\":\"TYPO\"}",
+        "{\"stickyKey\":{\"paramType\":\"unregistered\"}}",
+        
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"unregistered\",\"paramValue\":\"/\"}]}"})
+    public void testInvalidCanaryNeverWritesOrPublishes(final String patch) {
+        configureDivideSelector();
+        
given(shenyuDictMapper.findByType("paramType")).willReturn(Collections.singletonList(dictionary("uri",
 true)));
+        RuleDTO dto = buildRuleDTO("");
+        dto.setHandle(canaryHandle(patch));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.createOrUpdate(dto));
+        dto.setId("123");
+        given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.createOrUpdate(dto));
+        verify(ruleMapper, never()).insertSelective(any());
+        verify(ruleMapper, never()).updateSelective(any());
+        verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+    }
+
+    @Test
+    public void testImportsValidateAllRulesBeforeInserting() {
+        configureDivideSelector();
+        RuleDTO valid = buildRuleDTO("");
+        valid.setHandle(canaryHandle("{}"));
+        RuleDTO invalid = buildRuleDTO("");
+        invalid.setHandle(canaryHandle("{\"percentage\":101}"));
+        List<RuleDTO> rules = Arrays.asList(valid, invalid);
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.importData(rules));
+        ConfigsImportContext context = new ConfigsImportContext();
+        context.getSelectorIdMapping().put("456", "456");
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.importData(SYS_DEFAULT_NAMESPACE_ID, rules, context));
+        verify(ruleMapper, never()).insertSelective(any());
+        verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+    }
+
+    @Test
+    public void testRegistrationRejectsInvalidCanaryBeforePublishing() {
+        configureDivideSelector();
+        RuleDTO dto = buildRuleDTO("");
+        dto.setHandle(canaryHandle("{\"percentage\":101}"));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.registerDefault(dto));
+        verify(ruleMapper, never()).insertSelective(any());
+        verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+    }
+
+    @Test
+    public void 
testEnabledDictionaryExtensionIsAcceptedAndDisabledOneRejected() {
+        configureDivideSelector();
+        RuleDTO dto = buildRuleDTO("");
+        
dto.setHandle(canaryHandle("{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"custom-source\"}}"));
+        
given(shenyuDictMapper.findByType("paramType")).willReturn(Collections.singletonList(dictionary("custom-source",
 false)));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.createOrUpdate(dto));
+        verifyNoInteractions(ruleEventPublisher);
+        
given(shenyuDictMapper.findByType("paramType")).willReturn(Collections.singletonList(dictionary("custom-source",
 true)));
+        given(ruleMapper.insertSelective(any())).willReturn(1);
+        try (MockedStatic<JwtUtils> jwt = mockStatic(JwtUtils.class)) {
+            
jwt.when(JwtUtils::getUserInfo).thenReturn(UserInfo.builder().userId("1").userName("admin").build());
+            assertEquals(1, ruleService.createOrUpdate(dto));
+        }
+    }
+
+    @Test
+    public void testSavingLegacyRulePreservesHandleWithoutCanary() {
+        configureDivideSelector();
+        RuleDTO dto = buildRuleDTO("123");
+        dto.setHandle("{\"timeout\":5000,\"extension\":{\"value\":true}}");
+        given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+        given(ruleMapper.updateSelective(any())).willReturn(1);
+        assertEquals(1, ruleService.createOrUpdate(dto));
+        ArgumentCaptor<RuleDO> saved = ArgumentCaptor.forClass(RuleDO.class);
+        verify(ruleMapper).updateSelective(saved.capture());
+        assertEquals(dto.getHandle(), saved.getValue().getHandle());
+        verifyNoInteractions(shenyuDictMapper);
+    }
+
+    @Test
+    public void testCanaryFailoverNeverWritesOrPublishes() {
+        configureDivideSelector();
+        JsonObject handle = 
JsonParser.parseString(canaryHandle("{}")).getAsJsonObject();
+        handle.addProperty("retry", 2);
+        handle.addProperty("retryStrategy", "failover");
+        RuleDTO dto = buildRuleDTO("");
+        dto.setHandle(handle.toString());
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.createOrUpdate(dto));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.registerDefault(dto));
+        dto.setId("123");
+        given(ruleMapper.selectById("123")).willReturn(buildRuleDO("123"));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.createOrUpdate(dto));
+        assertThrows(ShenyuAdminException.class, () -> 
ruleService.importData(Collections.singletonList(dto)));
+        verify(ruleMapper, never()).insertSelective(any());
+        verify(ruleMapper, never()).updateSelective(any());
+        verifyNoInteractions(ruleConditionMapper, ruleEventPublisher);
+    }
+
+    private ShenyuDictDO dictionary(final String value, final boolean enabled) 
{
+        ShenyuDictDO dict = new ShenyuDictDO();
+        dict.setDictValue(value);
+        dict.setEnabled(enabled);
+        return dict;
+    }
+
+    private String canaryHandle(final String patch) {
+        JsonObject config = 
JsonParser.parseString("{\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}")
+                .getAsJsonObject();
+        
JsonParser.parseString(patch).getAsJsonObject().entrySet().forEach(entry -> 
config.add(entry.getKey(), entry.getValue()));
+        JsonObject handle = new JsonObject();
+        handle.add("canary", config);
+        return handle.toString();
+    }
+
+    private void configureDivideSelector() {
+        given(selectorMapper.selectById("456")).willReturn(buildSelectorDO());
+        
given(pluginMapper.selectById("789")).willReturn(PluginDO.builder().id("789").name("divide").build());
+    }
+
     private void testRegisterCreate() {
         RuleDTO ruleDTO = buildRuleDTO("");
         RuleDO ruleDO = RuleDO.buildRuleDO(ruleDTO);
diff --git a/shenyu-common/pom.xml b/shenyu-common/pom.xml
index 379312c653..6c84ac31ea 100644
--- a/shenyu-common/pom.xml
+++ b/shenyu-common/pom.xml
@@ -37,6 +37,11 @@
             <artifactId>gson</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>com.google.re2j</groupId>
+            <artifactId>re2j</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.commons</groupId>
             <artifactId>commons-lang3</artifactId>
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfig.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfig.java
new file mode 100644
index 0000000000..f65d0d85d0
--- /dev/null
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfig.java
@@ -0,0 +1,265 @@
+/*
+ * 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.shenyu.common.dto.convert.rule.canary;
+
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.enums.MatchModeEnum;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Canary routing configuration.
+ */
+public class CanaryConfig {
+
+    /**
+     * Whether Canary traffic is enabled. When false, a present configuration 
routes to Stable.
+     */
+    private boolean enabled;
+
+    /**
+     * Canary matching conditions. Empty conditions allow all requests to 
participate.
+     */
+    private List<ConditionData> conditions;
+
+    /**
+     * Condition match mode: 0 for AND (default), 1 for OR.
+     */
+    private Integer matchMode = MatchModeEnum.AND.getCode();
+
+    /**
+     * Percentage of eligible identifiers assigned to Canary, from 0 to 100. 
Defaults to 0.
+     */
+    private int percentage;
+
+    /**
+     * Identifier source for sticky routing. Required when enabled with a 
percentage from 1 to 99.
+     */
+    private StickyKeyConfig stickyKey;
+
+    /**
+     * Labels used to select canary upstreams.
+     */
+    private Map<String, String> canaryLabels;
+
+    /**
+     * Labels used to select stable upstreams.
+     */
+    private Map<String, String> stableLabels;
+
+    /**
+     * Initial empty Canary pool policy: STABLE (default) or REJECT.
+     */
+    private String fallbackPolicy = "STABLE";
+
+    /**
+     * Get enabled.
+     *
+     * @return enabled
+     */
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    /**
+     * Set enabled.
+     *
+     * @param enabled enabled
+     */
+    public void setEnabled(final boolean enabled) {
+        this.enabled = enabled;
+    }
+
+    /**
+     * Get conditions.
+     *
+     * @return conditions
+     */
+    public List<ConditionData> getConditions() {
+        return conditions;
+    }
+
+    /**
+     * Set conditions.
+     *
+     * @param conditions conditions
+     */
+    public void setConditions(final List<ConditionData> conditions) {
+        this.conditions = conditions;
+    }
+
+    /**
+     * Get matchMode.
+     *
+     * @return matchMode
+     */
+    public Integer getMatchMode() {
+        return matchMode;
+    }
+
+    /**
+     * Set matchMode.
+     *
+     * @param matchMode matchMode
+     */
+    public void setMatchMode(final Integer matchMode) {
+        this.matchMode = matchMode;
+    }
+
+    /**
+     * Get percentage.
+     *
+     * @return percentage
+     */
+    public int getPercentage() {
+        return percentage;
+    }
+
+    /**
+     * Set percentage.
+     *
+     * @param percentage percentage
+     */
+    public void setPercentage(final int percentage) {
+        this.percentage = percentage;
+    }
+
+    /**
+     * Get stickyKey.
+     *
+     * @return stickyKey
+     */
+    public StickyKeyConfig getStickyKey() {
+        return stickyKey;
+    }
+
+    /**
+     * Set stickyKey.
+     *
+     * @param stickyKey stickyKey
+     */
+    public void setStickyKey(final StickyKeyConfig stickyKey) {
+        this.stickyKey = stickyKey;
+    }
+
+    /**
+     * Get canaryLabels.
+     *
+     * @return canaryLabels
+     */
+    public Map<String, String> getCanaryLabels() {
+        return canaryLabels;
+    }
+
+    /**
+     * Set canaryLabels.
+     *
+     * @param canaryLabels canaryLabels
+     */
+    public void setCanaryLabels(final Map<String, String> canaryLabels) {
+        this.canaryLabels = canaryLabels;
+    }
+
+    /**
+     * Get stableLabels.
+     *
+     * @return stableLabels
+     */
+    public Map<String, String> getStableLabels() {
+        return stableLabels;
+    }
+
+    /**
+     * Set stableLabels.
+     *
+     * @param stableLabels stableLabels
+     */
+    public void setStableLabels(final Map<String, String> stableLabels) {
+        this.stableLabels = stableLabels;
+    }
+
+    /**
+     * Get fallbackPolicy.
+     *
+     * @return fallbackPolicy
+     */
+    public String getFallbackPolicy() {
+        return fallbackPolicy;
+    }
+
+    /**
+     * Set fallbackPolicy.
+     *
+     * @param fallbackPolicy fallbackPolicy
+     */
+    public void setFallbackPolicy(final String fallbackPolicy) {
+        this.fallbackPolicy = fallbackPolicy;
+    }
+
+    /**
+     * Validate that the two label selectors cannot match the same upstream.
+     *
+     * @throws IllegalArgumentException when labels are missing or selectors 
can overlap
+     */
+    public void validatePartitionLabels() {
+        if (Objects.isNull(canaryLabels) || canaryLabels.isEmpty() || 
Objects.isNull(stableLabels) || stableLabels.isEmpty()) {
+            throw new IllegalArgumentException("Canary and Stable labels must 
both be configured");
+        }
+        boolean disjoint = canaryLabels.entrySet().stream().anyMatch(entry -> 
stableLabels.containsKey(entry.getKey())
+                && !Objects.equals(entry.getValue(), 
stableLabels.get(entry.getKey())));
+        if (!disjoint) {
+            throw new IllegalArgumentException("Canary and Stable labels must 
have a shared key with different values");
+        }
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (Objects.isNull(o) || getClass() != o.getClass()) {
+            return false;
+        }
+        CanaryConfig that = (CanaryConfig) o;
+        return enabled == that.enabled && percentage == that.percentage
+                && Objects.equals(conditions, that.conditions) && 
Objects.equals(matchMode, that.matchMode)
+                && Objects.equals(stickyKey, that.stickyKey) && 
Objects.equals(canaryLabels, that.canaryLabels)
+                && Objects.equals(stableLabels, that.stableLabels) && 
Objects.equals(fallbackPolicy, that.fallbackPolicy);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(enabled, conditions, matchMode, percentage, 
stickyKey, canaryLabels, stableLabels, fallbackPolicy);
+    }
+
+    @Override
+    public String toString() {
+        return "CanaryConfig{"
+                + "enabled=" + enabled
+                + ", conditions=" + conditions
+                + ", matchMode=" + matchMode
+                + ", percentage=" + percentage
+                + ", stickyKey=" + stickyKey
+                + ", canaryLabels=" + canaryLabels
+                + ", stableLabels=" + stableLabels
+                + ", fallbackPolicy='" + fallbackPolicy + '\''
+                + '}';
+    }
+}
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidator.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidator.java
new file mode 100644
index 0000000000..44f3d9b354
--- /dev/null
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidator.java
@@ -0,0 +1,208 @@
+/*
+ * 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.shenyu.common.dto.convert.rule.canary;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.re2j.Pattern;
+import com.google.re2j.PatternSyntaxException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
+import org.apache.shenyu.common.enums.RetryEnum;
+import org.apache.shenyu.common.utils.DateUtils;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.common.utils.UpstreamLabelsUtils;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Shared validation of Canary configuration before persistence or gateway 
caching.
+ */
+public final class CanaryConfigValidator {
+
+    private CanaryConfigValidator() {
+    }
+
+    /**
+     * Parse a Divide handle, checking JSON types before Gson can coerce 
values.
+     * An absent or null Canary configuration preserves legacy routing.
+     *
+     * @param handle the serialized rule handle
+     * @return the validated handle
+     * @throws IllegalArgumentException when the configuration is invalid
+     */
+    public static DivideRuleHandle parseHandle(final String handle) {
+        try {
+            JsonObject root = object(GsonUtils.getInstance().fromJson(handle, 
JsonElement.class), "Divide handle");
+            JsonElement canary = root.get("canary");
+            if (Objects.nonNull(canary) && !canary.isJsonNull()) {
+                validate(object(canary, "canary"));
+            }
+            DivideRuleHandle result = GsonUtils.getInstance().fromJson(root, 
DivideRuleHandle.class);
+            if (Objects.nonNull(result.getCanary()) && result.getRetry() > 0
+                    && 
!RetryEnum.CURRENT.getName().equals(StringUtils.defaultIfEmpty(result.getRetryStrategy(),
 RetryEnum.CURRENT.getName()))) {
+                throw new IllegalArgumentException("Canary routing currently 
supports only current-node retries; failover requires partition-aware retry 
support");
+            }
+            return result;
+        } catch (JsonParseException ex) {
+            throw new IllegalArgumentException("Invalid Divide handle JSON", 
ex);
+        }
+    }
+
+    /**
+     * Check extension names against the registry available at the caller.
+     * Admin uses its dictionaries; gateways use their installed SPI 
implementations.
+     *
+     * @param config the validated configuration
+     * @param parameterValidator parameter source validator
+     * @param operatorValidator condition operator validator
+     */
+    public static void validateExtensions(final CanaryConfig config, final 
Consumer<String> parameterValidator,
+                                          final Consumer<String> 
operatorValidator) {
+        if (Objects.nonNull(config.getStickyKey())) {
+            parameterValidator.accept(config.getStickyKey().getParamType());
+        }
+        if (Objects.nonNull(config.getConditions())) {
+            for (ConditionData condition : config.getConditions()) {
+                parameterValidator.accept(condition.getParamType());
+                operatorValidator.accept(condition.getOperator());
+            }
+        }
+    }
+
+    private static void validate(final JsonObject config) {
+        if (config.has("enabled") && !(config.get("enabled").isJsonPrimitive() 
&& config.getAsJsonPrimitive("enabled").isBoolean())) {
+            throw new IllegalArgumentException("canary.enabled must be a 
boolean");
+        }
+        int percentage = integer(config, "percentage", 0);
+        if (percentage < 0 || percentage > 100) {
+            throw new IllegalArgumentException("canary.percentage must be 
between 0 and 100");
+        }
+        int matchMode = integer(config, "matchMode", 0);
+        if (matchMode != 0 && matchMode != 1) {
+            throw new IllegalArgumentException("canary.matchMode must be 0 
(AND) or 1 (OR)");
+        }
+        if (config.has("fallbackPolicy")) {
+            String policy = string(config, "fallbackPolicy", 
"canary.fallbackPolicy", true);
+            if (!"STABLE".equals(policy) && !"REJECT".equals(policy)) {
+                throw new IllegalArgumentException("canary.fallbackPolicy must 
be STABLE or REJECT");
+            }
+        }
+        validateLabels(config.get("stableLabels"), "canary.stableLabels");
+        validateLabels(config.get("canaryLabels"), "canary.canaryLabels");
+        if (config.has("stickyKey")) {
+            validateSource(object(config.get("stickyKey"), 
"canary.stickyKey"), "canary.stickyKey");
+        } else if (config.has("enabled") && 
config.get("enabled").getAsBoolean() && percentage > 0 && percentage < 100) {
+            throw new IllegalArgumentException("canary.stickyKey is required 
when enabled with a percentage between 1 and 99");
+        }
+        if (config.has("conditions")) {
+            JsonElement conditions = config.get("conditions");
+            if (!conditions.isJsonArray()) {
+                throw new IllegalArgumentException("canary.conditions must be 
an array");
+            }
+            int index = 0;
+            for (JsonElement element : conditions.getAsJsonArray()) {
+                String path = "canary.conditions[" + index++ + "]";
+                JsonObject condition = object(element, path);
+                validateSource(condition, path);
+                String operator = string(condition, "operator", path + 
".operator", true);
+                String value = string(condition, "paramValue", path + 
".paramValue", !"isBlank".equals(operator));
+                validateConditionValue(operator, value, path);
+            }
+        }
+        GsonUtils.getInstance().fromJson(config, 
CanaryConfig.class).validatePartitionLabels();
+    }
+
+    private static void validateConditionValue(final String operator, final 
String value, final String path) {
+        try {
+            if ("regex".equals(operator)) {
+                // Validate with the same regex engine used by the gateway's 
RegexPredicateJudge.
+                Pattern.compile(value.trim());
+            } else if ("TimeBefore".equals(operator) || 
"TimeAfter".equals(operator)) {
+                DateUtils.parseLocalDateTime(value.trim());
+            }
+        } catch (PatternSyntaxException | IllegalArgumentException | 
java.time.DateTimeException ex) {
+            throw new IllegalArgumentException(path + ".paramValue is invalid 
for " + operator, ex);
+        }
+    }
+
+    private static void validateSource(final JsonObject source, final String 
path) {
+        String type = string(source, "paramType", path + ".paramType", true);
+        boolean named = "header".equals(type) || "cookie".equals(type) || 
"query".equals(type) || "post".equals(type);
+        string(source, "paramName", path + ".paramName", named);
+    }
+
+    /**
+     * Validate a label map without coercing numbers or booleans to strings.
+     *
+     * @param labels the JSON labels
+     * @param path field name for errors
+     */
+    private static void validateLabels(final JsonElement labels, final String 
path) {
+        Map<String, String> map;
+        try {
+            map = UpstreamLabelsUtils.parse(object(labels, path));
+        } catch (IllegalArgumentException ex) {
+            throw new IllegalArgumentException(path + ": " + ex.getMessage(), 
ex);
+        }
+        if (map.isEmpty()) {
+            throw new IllegalArgumentException(path + " must not be empty");
+        }
+    }
+
+    private static JsonObject object(final JsonElement value, final String 
path) {
+        if (Objects.isNull(value) || !value.isJsonObject()) {
+            throw new IllegalArgumentException(path + " must be an object");
+        }
+        return value.getAsJsonObject();
+    }
+
+    private static int integer(final JsonObject object, final String name, 
final int defaultValue) {
+        if (!object.has(name)) {
+            return defaultValue;
+        }
+        JsonElement value = object.get(name);
+        if (!(value.isJsonPrimitive() && 
value.getAsJsonPrimitive().isNumber())) {
+            throw new IllegalArgumentException("canary." + name + " must be an 
integer");
+        }
+        try {
+            return value.getAsBigDecimal().intValueExact();
+        } catch (ArithmeticException | NumberFormatException ex) {
+            throw new IllegalArgumentException("canary." + name + " must be an 
integer", ex);
+        }
+    }
+
+    private static String string(final JsonObject object, final String name, 
final String path, final boolean required) {
+        if (!object.has(name) && !required) {
+            return null;
+        }
+        JsonElement value = object.get(name);
+        if (Objects.isNull(value) || !(value.isJsonPrimitive() && 
value.getAsJsonPrimitive().isString())) {
+            throw new IllegalArgumentException(path + " must be a string");
+        }
+        String result = value.getAsString();
+        if (required && StringUtils.isBlank(result)) {
+            throw new IllegalArgumentException(path + " must not be blank");
+        }
+        return result;
+    }
+}
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/StickyKeyConfig.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/StickyKeyConfig.java
new file mode 100644
index 0000000000..76ca4a1e8b
--- /dev/null
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/canary/StickyKeyConfig.java
@@ -0,0 +1,98 @@
+/*
+ * 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.shenyu.common.dto.convert.rule.canary;
+
+import java.util.Objects;
+
+/**
+ * Identifier source for stable canary grouping.
+ */
+public class StickyKeyConfig {
+
+    /**
+     * Registered ParameterData SPI source name, such as header, cookie, 
query, ip or a custom extension.
+     */
+    private String paramType;
+
+    /**
+     * Source-specific parameter name, passed unchanged to the selected 
ParameterData implementation.
+     * Named built-in sources require a non-blank name; extensions determine 
how it is used.
+     */
+    private String paramName;
+
+    /**
+     * Get paramType.
+     *
+     * @return paramType
+     */
+    public String getParamType() {
+        return paramType;
+    }
+
+    /**
+     * Set paramType.
+     *
+     * @param paramType paramType
+     */
+    public void setParamType(final String paramType) {
+        this.paramType = paramType;
+    }
+
+    /**
+     * Get paramName.
+     *
+     * @return paramName
+     */
+    public String getParamName() {
+        return paramName;
+    }
+
+    /**
+     * Set paramName.
+     *
+     * @param paramName paramName
+     */
+    public void setParamName(final String paramName) {
+        this.paramName = paramName;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (Objects.isNull(o) || getClass() != o.getClass()) {
+            return false;
+        }
+        StickyKeyConfig that = (StickyKeyConfig) o;
+        return Objects.equals(paramType, that.paramType) && 
Objects.equals(paramName, that.paramName);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(paramType, paramName);
+    }
+
+    @Override
+    public String toString() {
+        return "StickyKeyConfig{"
+                + "paramType='" + paramType + '\''
+                + ", paramName='" + paramName + '\''
+                + '}';
+    }
+}
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
index 84be0832c1..b8670cb0c8 100644
--- 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandle.java
@@ -19,6 +19,7 @@ package org.apache.shenyu.common.dto.convert.rule.impl;
 
 import org.apache.shenyu.common.constant.Constants;
 import org.apache.shenyu.common.dto.convert.rule.RuleHandle;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
 import org.apache.shenyu.common.enums.HttpRetryBackoffSpecEnum;
 import org.apache.shenyu.common.enums.LoadBalanceEnum;
 import org.apache.shenyu.common.enums.RetryEnum;
@@ -63,7 +64,12 @@ public class DivideRuleHandle implements RuleHandle {
      * requestMaxSize.
      */
     private long requestMaxSize;
-    
+
+    /**
+     * Canary routing configuration.
+     */
+    private CanaryConfig canary;
+
     /**
      * New instance divide rule handle.
      *
@@ -181,6 +187,24 @@ public class DivideRuleHandle implements RuleHandle {
         this.requestMaxSize = requestMaxSize;
     }
 
+    /**
+     * Get canary configuration.
+     *
+     * @return canary configuration
+     */
+    public CanaryConfig getCanary() {
+        return canary;
+    }
+
+    /**
+     * Set canary configuration.
+     *
+     * @param canary canary configuration
+     */
+    public void setCanary(final CanaryConfig canary) {
+        this.canary = canary;
+    }
+
     public String getRetryBackOffSpec() {
         return retryBackOffSpec;
     }
@@ -201,12 +225,12 @@ public class DivideRuleHandle implements RuleHandle {
         return retry == that.retry && timeout == that.timeout && headerMaxSize 
== that.headerMaxSize
                 && requestMaxSize == that.requestMaxSize && 
Objects.equals(loadBalance, that.loadBalance)
                 && Objects.equals(retryStrategy, that.retryStrategy)
-                && Objects.equals(retryBackOffSpec, that.retryBackOffSpec);
+                && Objects.equals(retryBackOffSpec, that.retryBackOffSpec) && 
Objects.equals(canary, that.canary);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(loadBalance, retryStrategy, retryBackOffSpec, 
retry, timeout, headerMaxSize, requestMaxSize);
+        return Objects.hash(loadBalance, retryStrategy, retryBackOffSpec, 
retry, timeout, headerMaxSize, requestMaxSize, canary);
     }
 
     @Override
@@ -229,6 +253,8 @@ public class DivideRuleHandle implements RuleHandle {
                 + headerMaxSize
                 + ", requestMaxSize="
                 + requestMaxSize
+                + ", canary="
+                + canary
                 + '}';
     }
 }
diff --git 
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidatorTest.java
 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidatorTest.java
new file mode 100644
index 0000000000..5e2560a29a
--- /dev/null
+++ 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/canary/CanaryConfigValidatorTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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.shenyu.common.dto.convert.rule.canary;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.re2j.PatternSyntaxException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Canary wire contract validation tests.
+ */
+class CanaryConfigValidatorTest {
+
+    @Test
+    void testLegacyHandleDoesNotAcquireCanaryConfiguration() {
+        
assertNull(CanaryConfigValidator.parseHandle("{\"timeout\":5000}").getCanary());
+        
assertNull(CanaryConfigValidator.parseHandle("{\"canary\":null}").getCanary());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "{\"percentage\":-1}", "{\"percentage\":101}", "{\"percentage\":1.5}", 
"{\"percentage\":\"20\"}",
+        "{\"percentage\":null}", "{\"percentage\":1e20}", 
"{\"enabled\":\"false\"}", "{\"enabled\":null}",
+        "{\"matchMode\":2}", "{\"matchMode\":0.5}", "{\"matchMode\":null}", 
"{\"fallbackPolicy\":\"stable\"}",
+        "{\"fallbackPolicy\":null}", "{\"stickyKey\":null}", 
"{\"stickyKey\":{}}", "{\"stickyKey\":{\"paramType\":\" \"}}",
+        "{\"stickyKey\":{\"paramType\":\"header\"}}", 
"{\"enabled\":true,\"percentage\":20}",
+        "{\"conditions\":{}}", "{\"conditions\":null}", 
"{\"conditions\":[null]}", "{\"conditions\":[{}]}",
+        
"{\"conditions\":[{\"paramType\":\"header\",\"operator\":\"=\",\"paramValue\":\"east\"}]}",
+        "{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"=\"}]}",
+        
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"regex\",\"paramValue\":\"[\"}]}",
+        
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"TimeAfter\",\"paramValue\":\"yesterday\"}]}",
+        "{\"stableLabels\":{}}", "{\"stableLabels\":null}", 
"{\"stableLabels\":{\"release\":1}}",
+        "{\"stableLabels\":{\"release\":true}}", 
"{\"stableLabels\":{\"release\":null}}",
+        "{\"stableLabels\":{\" \" :\"stable\"}}", 
"{\"stableLabels\":{\"release\":\" \"}}",
+        "{\"stableLabels\":{\"release\":\"canary\"}}", 
"{\"stableLabels\":{\"region\":\"east\"}}"
+    })
+    void testRejectsInvalidConfigurationBeforeCoercion(final String patch) {
+        assertThrows(IllegalArgumentException.class, () -> 
CanaryConfigValidator.parseHandle(handle(patch)));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "{}", "{\"enabled\":true,\"percentage\":0}", 
"{\"enabled\":true,\"percentage\":100}",
+        "{\"enabled\":false,\"percentage\":20}",
+        
"{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"header\",\"paramName\":\"X-User-Id\"}}",
+        
"{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"ip\"}}",
+        "{\"stickyKey\":{\"paramType\":\"custom-extension\"}}",
+        "{\"conditions\":[]}", 
"{\"matchMode\":1,\"fallbackPolicy\":\"REJECT\"}",
+        
"{\"conditions\":[{\"paramType\":\"header\",\"paramName\":\"X-Optional\",\"operator\":\"isBlank\"}]}",
+        
"{\"conditions\":[{\"paramType\":\"uri\",\"operator\":\"regex\",\"paramValue\":\"/orders/.*\"}]}"
+    })
+    void testAcceptsValidBoundariesAndExtensionNames(final String patch) {
+        assertDoesNotThrow(() -> 
CanaryConfigValidator.parseHandle(handle(patch)));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"[", "(?=a)a", "(?<=a)b", "(a)\\1"})
+    void testRejectsRegexUnsupportedByGatewayWithFieldError(final String 
regex) {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class,
+                () -> CanaryConfigValidator.parseHandle(regexHandle(regex)));
+        assertEquals("canary.conditions[0].paramValue is invalid for regex", 
exception.getMessage());
+        assertEquals(PatternSyntaxException.class, 
exception.getCause().getClass());
+    }
+
+    @Test
+    void testDefaultsRemainStable() {
+        CanaryConfig config = 
CanaryConfigValidator.parseHandle(handle("{}")).getCanary();
+        assertEquals(0, config.getPercentage());
+        assertEquals(0, config.getMatchMode());
+        assertEquals("STABLE", config.getFallbackPolicy());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"default", "fixed", "exponential"})
+    void testCanaryFailoverRequiresPartitionAwareRetries(final String backoff) 
{
+        JsonObject root = 
JsonParser.parseString(handle("{}")).getAsJsonObject();
+        root.addProperty("retry", 2);
+        root.addProperty("retryStrategy", "failover");
+        root.addProperty("retryBackOffSpec", backoff);
+        assertThrows(IllegalArgumentException.class, () -> 
CanaryConfigValidator.parseHandle(root.toString()));
+        root.getAsJsonObject("canary").addProperty("enabled", true);
+        root.getAsJsonObject("canary").addProperty("percentage", 100);
+        assertThrows(IllegalArgumentException.class, () -> 
CanaryConfigValidator.parseHandle(root.toString()));
+        root.addProperty("retry", 0);
+        assertDoesNotThrow(() -> 
CanaryConfigValidator.parseHandle(root.toString()));
+        root.addProperty("retry", 2);
+        root.addProperty("retryStrategy", "current");
+        assertDoesNotThrow(() -> 
CanaryConfigValidator.parseHandle(root.toString()));
+        root.remove("retryStrategy");
+        assertDoesNotThrow(() -> 
CanaryConfigValidator.parseHandle(root.toString()));
+        root.addProperty("retryStrategy", "unknown");
+        assertThrows(IllegalArgumentException.class, () -> 
CanaryConfigValidator.parseHandle(root.toString()));
+        root.remove("canary");
+        root.addProperty("retryStrategy", "failover");
+        assertDoesNotThrow(() -> 
CanaryConfigValidator.parseHandle(root.toString()));
+    }
+
+    private String regexHandle(final String regex) {
+        JsonObject condition = new JsonObject();
+        condition.addProperty("paramType", "uri");
+        condition.addProperty("operator", "regex");
+        condition.addProperty("paramValue", regex);
+        JsonArray conditions = new JsonArray();
+        conditions.add(condition);
+        JsonObject config = new JsonObject();
+        config.add("conditions", conditions);
+        return handle(config.toString());
+    }
+
+    private String handle(final String patch) {
+        JsonObject config = 
JsonParser.parseString("{\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}")
+                .getAsJsonObject();
+        
JsonParser.parseString(patch).getAsJsonObject().entrySet().forEach(entry -> 
config.add(entry.getKey(), entry.getValue()));
+        JsonObject handle = new JsonObject();
+        handle.add("canary", config);
+        return handle.toString();
+    }
+}
diff --git 
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
index e705b66269..a4b51518df 100644
--- 
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
+++ 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/impl/DivideRuleHandleTest.java
@@ -18,11 +18,16 @@
 package org.apache.shenyu.common.dto.convert.rule.impl;
 
 import com.google.common.collect.ImmutableSet;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
 import org.apache.shenyu.common.enums.HttpRetryBackoffSpecEnum;
 import org.apache.shenyu.common.enums.LoadBalanceEnum;
 import org.apache.shenyu.common.enums.RetryEnum;
 import org.junit.jupiter.api.Test;
 
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.core.Is.is;
@@ -61,4 +66,43 @@ public class DivideRuleHandleTest {
         assertThat(ImmutableSet.of(handle1, handle2), hasSize(1));
     }
     
+    @Test
+    public void testCanaryConfigurationEquality() {
+        final DivideRuleHandle handle1 = new DivideRuleHandle();
+        final DivideRuleHandle handle2 = new DivideRuleHandle();
+        CanaryConfig canary1 = new CanaryConfig();
+        CanaryConfig canary2 = new CanaryConfig();
+        canary1.setEnabled(true);
+        canary1.setPercentage(10);
+        canary2.setEnabled(true);
+        canary2.setPercentage(10);
+        handle1.setCanary(canary1);
+        handle2.setCanary(canary2);
+
+        assertThat(handle1.getCanary(), is(canary1));
+        assertThat(ImmutableSet.of(handle1, handle2), hasSize(1));
+
+        canary2.setPercentage(20);
+        assertThat(handle1.equals(handle2), is(false));
+        assertThat(ImmutableSet.of(handle1, handle2), hasSize(2));
+
+        handle2.setCanary(null);
+        assertThat(handle1.equals(handle2), is(false));
+    }
+
+    @Test
+    public void testPartitionLabelsMustBeMutuallyExclusive() {
+        CanaryConfig config = new CanaryConfig();
+        assertThrows(IllegalArgumentException.class, 
config::validatePartitionLabels);
+        config.setCanaryLabels(Map.of("release", "canary"));
+        config.setStableLabels(Map.of("release", "stable"));
+        assertDoesNotThrow(config::validatePartitionLabels);
+        config.setStableLabels(Map.of("release", "canary"));
+        assertThrows(IllegalArgumentException.class, 
config::validatePartitionLabels);
+        config.setStableLabels(Map.of("region", "east"));
+        assertThrows(IllegalArgumentException.class, 
config::validatePartitionLabels);
+        config.setStableLabels(Map.of("release", "stable", "region", "east"));
+        assertDoesNotThrow(config::validatePartitionLabels);
+    }
+
 }
diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
index 012aa81e36..a82bc939b0 100644
--- 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java
@@ -49,6 +49,9 @@ public final class UpstreamCacheManager {
 
     private static final Map<String, List<Upstream>> UPSTREAM_MAP = 
Maps.newConcurrentMap();
 
+    // Include unhealthy configured gray nodes so legacy traffic never falls 
back to ordinary nodes.
+    private static final Map<String, Boolean> LEGACY_GRAY_SELECTORS = 
Maps.newConcurrentMap();
+
     private UpstreamCheckTask task;
 
     /**
@@ -128,6 +131,21 @@ public final class UpstreamCacheManager {
      * @return the list
      */
     public List<Upstream> findUpstreamListBySelectorId(final String 
selectorId) {
+        List<Upstream> upstreamList = 
findAllUpstreamListBySelectorId(selectorId);
+        if (Objects.isNull(upstreamList) || 
!Boolean.TRUE.equals(LEGACY_GRAY_SELECTORS.get(selectorId))) {
+            return upstreamList;
+        }
+        return 
upstreamList.stream().filter(Upstream::isGray).collect(Collectors.toList());
+    }
+
+    /**
+     * Find all healthy upstreams for rules that select their own label 
partition.
+     * The regular lookup retains the legacy gray-exclusive policy, including 
for failover.
+     *
+     * @param selectorId selector identifier
+     * @return a snapshot of all healthy upstreams, or null when the selector 
has no healthy pool
+     */
+    public List<Upstream> findAllUpstreamListBySelectorId(final String 
selectorId) {
         List<Upstream> upstreamList = 
task.getHealthyUpstream().get(selectorId);
         return Objects.isNull(upstreamList) ? null : new 
ArrayList<>(upstreamList);
     }
@@ -139,6 +157,7 @@ public final class UpstreamCacheManager {
      */
     public void removeByKey(final String key) {
         UPSTREAM_MAP.remove(key);
+        LEGACY_GRAY_SELECTORS.remove(key);
         task.triggerRemoveAll(key);
     }
 
@@ -157,6 +176,7 @@ public final class UpstreamCacheManager {
             return;
         }
 
+        LEGACY_GRAY_SELECTORS.put(selectorId, 
actualUpstreamList.stream().anyMatch(Upstream::isGray));
         initializeUpstreamHealthStatus(actualUpstreamList);
 
         Map<Boolean, List<Upstream>> partitionedUpstreams = 
actualUpstreamList.stream()
@@ -181,6 +201,7 @@ public final class UpstreamCacheManager {
             if (Objects.nonNull(cached)) {
                 cached.setMetadata(Collections.unmodifiableMap(new 
HashMap<>(upstream.getMetadata())));
                 cached.setLabels(upstream.getLabels());
+                cached.setGray(upstream.isGray());
             }
         });
     }
diff --git 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
index 1a6d868d1f..43f496f5b6 100644
--- 
a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
+++ 
b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java
@@ -118,7 +118,7 @@ public final class Upstream {
     /**
      * this is gray.
      */
-    private boolean gray;
+    private volatile boolean gray;
 
     /**
      * health check enabled.
diff --git 
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamLabelUtils.java
 
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamLabelUtils.java
new file mode 100644
index 0000000000..480e768b01
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/UpstreamLabelUtils.java
@@ -0,0 +1,54 @@
+/*
+ * 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.shenyu.plugin.base.utils;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.collections4.MapUtils;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Match healthy upstreams against a label partition.
+ */
+public final class UpstreamLabelUtils {
+
+    private UpstreamLabelUtils() {
+    }
+
+    /**
+     * Select upstreams matching every label. Missing partition labels select 
no nodes.
+     *
+     * @param upstreams latest healthy upstreams
+     * @param labels partition label constraints
+     * @return matching upstreams without modifying the supplied list or nodes
+     */
+    public static List<Upstream> filter(final List<Upstream> upstreams, final 
Map<String, String> labels) {
+        if (CollectionUtils.isEmpty(upstreams) || MapUtils.isEmpty(labels)) {
+            return Collections.emptyList();
+        }
+        return upstreams.stream().filter(upstream -> {
+            Map<String, String> upstreamLabels = upstream.getLabels();
+            return MapUtils.isNotEmpty(upstreamLabels) && 
upstreamLabels.entrySet().containsAll(labels.entrySet());
+        })
+                .collect(Collectors.toList());
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
index 199005dc67..5c39e8701e 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
@@ -22,6 +22,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.shenyu.common.constant.Constants;
 import org.apache.shenyu.common.dto.RuleData;
 import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
 import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
 import org.apache.shenyu.common.enums.HttpRetryBackoffSpecEnum;
 import org.apache.shenyu.common.enums.LoadBalanceEnum;
@@ -40,9 +41,14 @@ import org.apache.shenyu.plugin.base.AbstractShenyuPlugin;
 import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
 import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
 import org.apache.shenyu.plugin.base.utils.LoadbalancerUtils;
+import org.apache.shenyu.plugin.base.utils.UpstreamLabelUtils;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecision;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
 import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
 import org.springframework.web.server.ServerWebExchange;
 import reactor.core.publisher.Mono;
 
@@ -64,6 +70,16 @@ public class DividePlugin extends AbstractShenyuPlugin {
 
     private static final String SHORTEST_RESPONSE = "shortestResponse";
 
+    private final CanaryDecisionService canaryDecisionService;
+
+    public DividePlugin() {
+        this(new DefaultCanaryDecisionService());
+    }
+
+    public DividePlugin(final CanaryDecisionService canaryDecisionService) {
+        this.canaryDecisionService = 
Objects.requireNonNull(canaryDecisionService);
+    }
+
     @Override
     protected String getRawPath(final ServerWebExchange exchange) {
         return RequestUrlUtils.getRewrittenRawPath(exchange);
@@ -97,8 +113,18 @@ public class DividePlugin extends AbstractShenyuPlugin {
                 return WebFluxResultUtils.result(exchange, error);
             }
         }
-        List<Upstream> upstreamList = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId());
+        boolean canaryRouting = Objects.nonNull(ruleHandle.getCanary())
+                && 
CollectionUtils.isEmpty(exchange.getRequest().getHeaders().get(Constants.SPECIFY_DOMAIN));
+        List<Upstream> upstreamList = canaryRouting
+                ? 
UpstreamCacheManager.getInstance().findAllUpstreamListBySelectorId(selector.getId())
+                : 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId());
+        if (canaryRouting && CollectionUtils.isNotEmpty(upstreamList)) {
+            upstreamList = selectPartition(exchange, rule.getId(), 
ruleHandle.getCanary(), upstreamList);
+        }
         if (CollectionUtils.isEmpty(upstreamList)) {
+            if (canaryRouting) {
+                
exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
+            }
             LOG.error("divide upstream configuration error: {}", selector);
             Object error = ShenyuResultWrap.error(exchange, 
ShenyuResultEnum.CANNOT_FIND_HEALTHY_UPSTREAM_URL);
             return WebFluxResultUtils.result(exchange, error);
@@ -107,6 +133,9 @@ public class DividePlugin extends AbstractShenyuPlugin {
         final CircuitBreakerCandidates candidates = 
CircuitBreakerCandidates.filter(selector.getId(), upstreamList, requested);
         final List<Upstream> breakeredUpstreamList = candidates.getUpstreams();
         if (CollectionUtils.isEmpty(breakeredUpstreamList)) {
+            if (canaryRouting) {
+                
exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
+            }
             LOG.warn("all upstreams of selector {} are blocked by the circuit 
breaker, fail fast", selector.getId());
             Object error = ShenyuResultWrap.error(exchange, 
ShenyuResultEnum.CANNOT_FIND_HEALTHY_UPSTREAM_URL);
             return WebFluxResultUtils.result(exchange, error);
@@ -114,6 +143,18 @@ public class DividePlugin extends AbstractShenyuPlugin {
         return candidates.recordProbeOutcome(Mono.defer(() -> 
forwardSelected(exchange, chain, selector, ruleHandle, candidates)));
     }
 
+    private List<Upstream> selectPartition(final ServerWebExchange exchange, 
final String ruleId,
+                                           final CanaryConfig config, final 
List<Upstream> upstreams) {
+        CanaryDecision decision = canaryDecisionService.decide(exchange, 
ruleId, config);
+        List<Upstream> candidates = UpstreamLabelUtils.filter(upstreams,
+                decision == CanaryDecision.CANARY ? config.getCanaryLabels() : 
config.getStableLabels());
+        if (candidates.isEmpty() && decision == CanaryDecision.CANARY && 
"STABLE".equals(config.getFallbackPolicy())) {
+            candidates = UpstreamLabelUtils.filter(upstreams, 
config.getStableLabels());
+        }
+        // Resolve the pool before circuit-breaker probes and load balancing; 
neither may escape it.
+        return candidates;
+    }
+
     private Mono<Void> forwardSelected(final ServerWebExchange exchange, final 
ShenyuPluginChain chain, final SelectorData selector,
                                         final DivideRuleHandle ruleHandle, 
final CircuitBreakerCandidates candidates) {
         List<Upstream> breakeredUpstreamList = candidates.getUpstreams();
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecision.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecision.java
new file mode 100644
index 0000000000..df732c4f9a
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecision.java
@@ -0,0 +1,49 @@
+/*
+ * 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.shenyu.plugin.divide.canary;
+
+/**
+ * Expected request partition, independent of upstream availability.
+ */
+public enum CanaryDecision {
+
+    /**
+     * Production partition.
+     */
+    STABLE("stable"),
+
+    /**
+     * Canary partition.
+     */
+    CANARY("canary");
+
+    private final String name;
+
+    CanaryDecision(final String name) {
+        this.name = name;
+    }
+
+    /**
+     * Get the lowercase partition name stored on the exchange.
+     *
+     * @return partition name
+     */
+    public String getName() {
+        return name;
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecisionService.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecisionService.java
new file mode 100644
index 0000000000..0b3bf16f4b
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/CanaryDecisionService.java
@@ -0,0 +1,37 @@
+/*
+ * 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.shenyu.plugin.divide.canary;
+
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * Assign requests to a partition without selecting or invoking upstreams.
+ */
+public interface CanaryDecisionService {
+
+    /**
+     * Decide the expected partition; a disabled Canary configuration selects 
Stable.
+     *
+     * @param exchange current request
+     * @param ruleId stable rule identifier
+     * @param config present Canary configuration
+     * @return expected Stable or Canary partition
+     */
+    CanaryDecision decide(ServerWebExchange exchange, String ruleId, 
CanaryConfig config);
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionService.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionService.java
new file mode 100644
index 0000000000..acf39a4e8a
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionService.java
@@ -0,0 +1,102 @@
+/*
+ * 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.shenyu.plugin.divide.canary;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.canary.StickyKeyConfig;
+import org.apache.shenyu.common.enums.MatchModeEnum;
+import org.apache.shenyu.plugin.base.condition.data.ParameterDataFactory;
+import org.apache.shenyu.plugin.base.condition.strategy.MatchStrategyFactory;
+import org.springframework.web.server.ServerWebExchange;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Objects;
+
+/**
+ * Deterministic request partitioning using conditions and SHA-256 buckets.
+ */
+public class DefaultCanaryDecisionService implements CanaryDecisionService {
+
+    @Override
+    public CanaryDecision decide(final ServerWebExchange exchange, final 
String ruleId, final CanaryConfig config) {
+        Objects.requireNonNull(config, "Canary configuration is required");
+        if (!config.isEnabled()) {
+            return CanaryDecision.STABLE;
+        }
+        int percentage = config.getPercentage();
+        if (percentage < 0 || percentage > 100) {
+            throw new IllegalArgumentException("Canary percentage must be 
between 0 and 100");
+        }
+        if (percentage == 0) {
+            return CanaryDecision.STABLE;
+        }
+        int matchMode = Objects.isNull(config.getMatchMode()) ? 
MatchModeEnum.AND.getCode() : config.getMatchMode();
+        if (CollectionUtils.isNotEmpty(config.getConditions())
+                && !MatchStrategyFactory.match(matchMode, 
config.getConditions(), exchange)) {
+            return CanaryDecision.STABLE;
+        }
+        if (percentage == 100) {
+            return CanaryDecision.CANARY;
+        }
+        String key = readStickyKey(exchange, config.getStickyKey());
+        if (StringUtils.isBlank(key)) {
+            return CanaryDecision.STABLE;
+        }
+        return hashToBucket(ruleId, key) < percentage * 100 ? 
CanaryDecision.CANARY : CanaryDecision.STABLE;
+    }
+
+    private String readStickyKey(final ServerWebExchange exchange, final 
StickyKeyConfig stickyKey) {
+        if (Objects.isNull(stickyKey) || 
StringUtils.isBlank(stickyKey.getParamType())) {
+            throw new IllegalArgumentException("Canary sticky key parameter 
type is required");
+        }
+        // Pass the parameter name through unchanged; the selected source owns 
its extraction semantics.
+        return ParameterDataFactory.builderData(stickyKey.getParamType(), 
stickyKey.getParamName(), exchange);
+    }
+
+    /**
+     * Encode each UTF-8 field with a four-byte big-endian byte-length prefix.
+     * Interpret the first four SHA-256 bytes as an unsigned big-endian 
integer.
+     * The percentage and upstream topology deliberately do not enter the hash.
+     *
+     * @param ruleId stable rule identifier
+     * @param key request identifier
+     * @return bucket from 0 to 9999
+     */
+    int hashToBucket(final String ruleId, final String key) {
+        Objects.requireNonNull(ruleId, "Canary rule identifier is required");
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            updateField(digest, ruleId);
+            updateField(digest, key);
+            return (int) 
(Integer.toUnsignedLong(ByteBuffer.wrap(digest.digest()).getInt()) % 10000);
+        } catch (NoSuchAlgorithmException ex) {
+            throw new IllegalStateException("SHA-256 is unavailable", ex);
+        }
+    }
+
+    private void updateField(final MessageDigest digest, final String value) {
+        byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+        
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
+        digest.update(bytes);
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
index acd2f36420..95e2929ab1 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandler.java
@@ -20,12 +20,14 @@ package org.apache.shenyu.plugin.divide.handler;
 import org.apache.shenyu.common.constant.Constants;
 import org.apache.shenyu.common.dto.RuleData;
 import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfigValidator;
 import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
 import org.apache.shenyu.common.enums.PluginEnum;
-import org.apache.shenyu.common.utils.GsonUtils;
 import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
 import org.apache.shenyu.plugin.base.cache.CommonHandleCache;
 import org.apache.shenyu.plugin.base.cache.MetaDataCache;
+import org.apache.shenyu.plugin.base.condition.data.ParameterDataFactory;
+import org.apache.shenyu.plugin.base.condition.judge.PredicateJudgeFactory;
 import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
 import org.apache.shenyu.plugin.base.utils.BeanHolder;
 import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
@@ -64,7 +66,11 @@ public class DividePluginDataHandler implements 
PluginDataHandler {
     @Override
     public void handlerRule(final RuleData ruleData) {
         Optional.ofNullable(ruleData.getHandle()).ifPresent(s -> {
-            DivideRuleHandle divideRuleHandle = 
GsonUtils.getInstance().fromJson(s, DivideRuleHandle.class);
+            DivideRuleHandle divideRuleHandle = 
CanaryConfigValidator.parseHandle(s);
+            if (Objects.nonNull(divideRuleHandle.getCanary())) {
+                
CanaryConfigValidator.validateExtensions(divideRuleHandle.getCanary(),
+                        ParameterDataFactory::newInstance, 
PredicateJudgeFactory::newInstance);
+            }
             
CACHED_HANDLE.get().cachedHandle(CacheKeyUtils.INST.getKey(ruleData), 
divideRuleHandle);
             // the update is also need to clean, but there is no way to
             // distinguish between crate and update, so it is always clean
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
index 70f80a3121..623d38091f 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java
@@ -17,6 +17,7 @@
 
 package org.apache.shenyu.plugin.divide.handler;
 
+import org.apache.shenyu.common.dto.DiscoverySyncData;
 import org.apache.shenyu.common.dto.DiscoveryUpstreamData;
 import org.apache.shenyu.common.enums.PluginEnum;
 import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
@@ -30,6 +31,7 @@ import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.stream.Collectors;
 
@@ -38,6 +40,16 @@ import java.util.stream.Collectors;
  */
 public class DivideUpstreamDataHandler extends 
AbstractDiscoveryUpstreamDataHandler<Upstream> {
 
+    @Override
+    public void handlerDiscoveryUpstreamData(final DiscoverySyncData 
discoverySyncData) {
+        if (Objects.isNull(discoverySyncData) || 
Objects.isNull(discoverySyncData.getSelectorId())) {
+            return;
+        }
+        // A selector may serve both Canary and legacy rules; retain both 
pools for per-request selection.
+        submitUpstreamData(discoverySyncData.getSelectorId(), 
convertUpstreamList(discoverySyncData.getUpstreamDataList()));
+        afterSubmitUpstreamData(discoverySyncData.getSelectorId());
+    }
+
     @Override
     public String pluginName() {
         return PluginEnum.DIVIDE.getName();
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DivideCanaryRoutingTest.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DivideCanaryRoutingTest.java
new file mode 100644
index 0000000000..b9e6c9c5d1
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DivideCanaryRoutingTest.java
@@ -0,0 +1,317 @@
+/*
+ * 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.shenyu.plugin.divide;
+
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.DiscoverySyncData;
+import org.apache.shenyu.common.dto.DiscoveryUpstreamData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
+import org.apache.shenyu.plugin.api.result.DefaultShenyuResult;
+import org.apache.shenyu.plugin.api.result.ShenyuResult;
+import org.apache.shenyu.plugin.api.utils.SpringBeanUtils;
+import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecision;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
+import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
+import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies partition selection before load balancing, empty pools and legacy 
compatibility.
+ */
+class DivideCanaryRoutingTest {
+
+    private RuleData rule;
+
+    private SelectorData selector;
+
+    private DivideRuleHandle handle;
+
+    private CanaryConfig config;
+
+    private CanaryDecisionService decisions;
+
+    private DividePlugin plugin;
+
+    @BeforeEach
+    void setUp() {
+        decisions = mock(CanaryDecisionService.class);
+        plugin = new DividePlugin(decisions);
+        rule = new RuleData();
+        rule.setId("canary-routing-rule");
+        rule.setSelectorId("canary-routing-selector");
+        selector = new SelectorData();
+        selector.setId(rule.getSelectorId());
+        rule.setHandle(GsonUtils.getGson().toJson(new DivideRuleHandle()));
+        new DividePluginDataHandler().handlerRule(rule);
+        handle = 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+        config = new CanaryConfig();
+        config.setEnabled(true);
+        config.setCanaryLabels(Map.of("release", "canary", "region", "east"));
+        config.setStableLabels(Map.of("release", "stable"));
+        handle.setCanary(config);
+        when(decisions.decide(any(), anyString(), 
any())).thenReturn(CanaryDecision.CANARY);
+        ConfigurableApplicationContext context = 
mock(ConfigurableApplicationContext.class);
+        when(context.getBean(ShenyuResult.class)).thenReturn(new 
DefaultShenyuResult());
+        SpringBeanUtils.getInstance().setApplicationContext(context);
+    }
+
+    @AfterEach
+    void tearDown() {
+        new DividePluginDataHandler().removeRule(rule);
+        UpstreamCacheManager.getInstance().removeByKey(selector.getId());
+    }
+
+    @Test
+    void testAllLabelsMustMatchAndBackendFailureDoesNotFallback() {
+        Upstream canary = upstream("canary:8080", Map.of("release", "canary", 
"region", "east", "extra", "value"));
+        Upstream wrongRegion = upstream("other:8080", Map.of("release", 
"canary", "region", "west"));
+        ServerWebExchange exchange = exchange(null);
+        ShenyuPluginChain chain = current -> {
+            assertEquals("http://canary:8080";, 
current.getAttribute(Constants.HTTP_DOMAIN));
+            return Mono.error(new IllegalStateException("backend failed"));
+        };
+        try (MockedStatic<UpstreamCacheManager> cache = 
cache(List.of(wrongRegion, canary, upstream("stable:8080", Map.of("release", 
"stable"))))) {
+            StepVerifier.create(plugin.doExecute(exchange, chain, selector, 
rule)).expectError(IllegalStateException.class).verify();
+        } finally {
+            
UpstreamCircuitBreaker.getInstance().reset(UpstreamCircuitBreaker.buildKey(selector.getId(),
 canary));
+        }
+    }
+
+    @Test
+    void testEmptyCanaryPoolFallsBackToStable() {
+        try (MockedStatic<UpstreamCacheManager> cache = 
cache(List.of(upstream("stable:8080", Map.of("release", "stable"))))) {
+            assertDomain(exchange(null), "http://stable:8080";);
+        }
+    }
+
+    @Test
+    void testRejectPolicyAndEmptyStablePool() {
+        config.setFallbackPolicy("REJECT");
+        assertRejected(List.of(upstream("stable:8080", Map.of("release", 
"stable"))));
+        config.setFallbackPolicy("STABLE");
+        assertRejected(List.of(upstream("unlabelled:8080", Map.of())));
+        when(decisions.decide(any(), anyString(), 
any())).thenReturn(CanaryDecision.STABLE);
+        assertRejected(List.of(upstream("canary:8080", Map.of("release", 
"canary", "region", "east"))));
+    }
+
+    @Test
+    void testMissingLabelsCannotSelectWholePool() {
+        config.setCanaryLabels(null);
+        config.setStableLabels(Map.of());
+        assertRejected(List.of(upstream("unlabelled:8080", Map.of())));
+    }
+
+    @Test
+    void testNoHealthyUpstreamDoesNotDecide() {
+        assertRejected(List.of());
+        verifyNoInteractions(decisions);
+    }
+
+    @Test
+    void testAbsentConfigurationKeepsLegacyGrayPool() {
+        Upstream gray = upstream("gray:8080", Map.of());
+        gray.setGray(true);
+        try (MockedStatic<UpstreamCacheManager> cache = cache(List.of(gray, 
upstream("normal:8080", Map.of())))) {
+            handle.setCanary(null);
+            assertDomain(exchange(null), "http://gray:8080";);
+        }
+        verifyNoInteractions(decisions);
+    }
+
+    @Test
+    void testNewAndLegacyRulesShareSelectorWithoutLosingStableNodes() {
+        DiscoverySyncData data = new DiscoverySyncData();
+        data.setSelectorId(selector.getId());
+        data.setUpstreamDataList(List.of(
+                instance("gray:8080", true, "canary"), instance("stable:8080", 
false, "stable")));
+        new DivideUpstreamDataHandler().handlerDiscoveryUpstreamData(data);
+        plugin = new DividePlugin(new DefaultCanaryDecisionService());
+        config.setEnabled(false);
+        assertDomain(exchange(null), "http://stable:8080";);
+        config.setEnabled(true);
+        config.setPercentage(100);
+        assertDomain(exchange(null), "http://gray:8080";);
+        handle.setCanary(null);
+        assertDomain(exchange(null), "http://gray:8080";);
+        assertEquals(1, 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId()).size());
+        assertEquals(2, 
UpstreamCacheManager.getInstance().findAllUpstreamListBySelectorId(selector.getId()).size());
+    }
+
+    @Test
+    void testSpecifyDomainAndLegacyDoNotEscapeUnavailableGrayPool() {
+        UpstreamCacheManager manager = mock(UpstreamCacheManager.class);
+        
when(manager.findAllUpstreamListBySelectorId(selector.getId())).thenReturn(List.of(upstream("stable:8080",
 Map.of("release", "stable"))));
+        
when(manager.findUpstreamListBySelectorId(selector.getId())).thenReturn(List.of());
+        ShenyuPluginChain chain = mock(ShenyuPluginChain.class);
+        try (MockedStatic<UpstreamCacheManager> cache = 
mockStatic(UpstreamCacheManager.class)) {
+            cache.when(UpstreamCacheManager::getInstance).thenReturn(manager);
+            ServerWebExchange specified = exchange("stable:8080");
+            StepVerifier.create(plugin.doExecute(specified, chain, selector, 
rule)).verifyComplete();
+            assertNull(specified.getAttribute(Constants.HTTP_DOMAIN));
+            handle.setCanary(null);
+            ServerWebExchange legacy = exchange(null);
+            StepVerifier.create(plugin.doExecute(legacy, chain, selector, 
rule)).verifyComplete();
+            assertNull(legacy.getAttribute(Constants.HTTP_DOMAIN));
+        }
+        verifyNoInteractions(chain, decisions);
+    }
+
+    @Test
+    void testSpecifyDomainBypassesCanaryWithoutMutatingSharedNode() {
+        Upstream shared = upstream("original:8080", Map.of());
+        Upstream specified = upstream("override:9090", Map.of());
+        try (MockedStatic<UpstreamCacheManager> cache = cache(List.of(shared, 
specified))) {
+            assertDomain(exchange("override:9090"), "http://override:9090";);
+            assertEquals("original:8080", shared.getUrl());
+        }
+        try (MockedStatic<UpstreamCacheManager> cache = 
cache(List.of(shared))) {
+            handle.setCanary(null);
+            assertDomain(exchange("unconfigured:9090"), 
"http://original:8080";);
+            assertDomain(exchange(null), "http://original:8080";);
+        }
+        verifyNoInteractions(decisions);
+    }
+
+    @Test
+    void testCircuitBreakerCannotSelectOutsideCanaryPartition() {
+        Upstream blocked = upstream("blocked-canary:8080", Map.of("release", 
"canary", "region", "east"));
+        Upstream available = upstream("available-canary:8080", 
Map.of("release", "canary", "region", "east"));
+        Upstream stable = upstream("stable:8080", Map.of("release", "stable"));
+        UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.getInstance();
+        String key = UpstreamCircuitBreaker.buildKey(selector.getId(), 
blocked);
+        for (int i = 0; i < UpstreamCircuitBreaker.DEFAULT_FAILURE_THRESHOLD; 
i++) {
+            breaker.recordFailure(key);
+        }
+        try {
+            try (MockedStatic<UpstreamCacheManager> cache = 
cache(List.of(blocked, available, stable))) {
+                assertDomain(exchange(null), "http://available-canary:8080";);
+            }
+            assertRejected(List.of(blocked, stable));
+        } finally {
+            breaker.reset(key);
+        }
+    }
+
+    @Test
+    void testHalfOpenProbeStaysInCanaryPool() {
+        Upstream canary = upstream("probe-canary:8080", Map.of("release", 
"canary", "region", "east"));
+        Upstream stable = upstream("probe-stable:8080", Map.of("release", 
"stable"));
+        UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.getInstance();
+        String key = UpstreamCircuitBreaker.buildKey(selector.getId(), canary);
+        for (int i = 0; i < UpstreamCircuitBreaker.DEFAULT_FAILURE_THRESHOLD; 
i++) {
+            breaker.recordFailure(key);
+        }
+        Map<?, ?> breakers = (Map<?, ?>) ReflectionTestUtils.getField(breaker, 
"breakers");
+        ReflectionTestUtils.setField(breakers.get(key), "openedAtMillis", 
System.currentTimeMillis() - 60000L);
+        try (MockedStatic<UpstreamCacheManager> cache = cache(List.of(stable, 
canary))) {
+            ServerWebExchange exchange = exchange(null);
+            StepVerifier.create(plugin.doExecute(exchange, current -> {
+                assertEquals("http://probe-canary:8080";, 
current.getAttribute(Constants.HTTP_DOMAIN));
+                
assertTrue(Boolean.TRUE.equals(current.getAttribute(UpstreamCircuitBreaker.HALF_OPEN_PROBE)));
+                return Mono.empty();
+            }, selector, rule)).verifyComplete();
+            assertFalse(breaker.isBlocking(key));
+        } finally {
+            breaker.reset(key);
+        }
+    }
+
+    private void assertRejected(final List<Upstream> upstreams) {
+        ServerWebExchange exchange = exchange(null);
+        ShenyuPluginChain chain = mock(ShenyuPluginChain.class);
+        try (MockedStatic<UpstreamCacheManager> cache = cache(upstreams)) {
+            StepVerifier.create(plugin.doExecute(exchange, chain, selector, 
rule)).verifyComplete();
+        }
+        verifyNoInteractions(chain);
+        assertNull(exchange.getAttribute(Constants.HTTP_DOMAIN));
+        assertEquals(HttpStatus.SERVICE_UNAVAILABLE, 
exchange.getResponse().getStatusCode());
+    }
+
+    private void assertDomain(final ServerWebExchange exchange, final String 
domain) {
+        StepVerifier.create(plugin.doExecute(exchange, current -> 
Mono.empty(), selector, rule)).verifyComplete();
+        assertEquals(domain, exchange.getAttribute(Constants.HTTP_DOMAIN));
+    }
+
+    private MockedStatic<UpstreamCacheManager> cache(final List<Upstream> 
upstreams) {
+        UpstreamCacheManager manager = mock(UpstreamCacheManager.class);
+        
when(manager.findAllUpstreamListBySelectorId(selector.getId())).thenReturn(upstreams);
+        List<Upstream> gray = 
upstreams.stream().filter(Upstream::isGray).toList();
+        
when(manager.findUpstreamListBySelectorId(selector.getId())).thenReturn(gray.isEmpty()
 ? upstreams : gray);
+        MockedStatic<UpstreamCacheManager> cache = 
mockStatic(UpstreamCacheManager.class);
+        cache.when(UpstreamCacheManager::getInstance).thenReturn(manager);
+        return cache;
+    }
+
+    private ServerWebExchange exchange(final String domain) {
+        MockServerHttpRequest.BaseBuilder<?> request = 
MockServerHttpRequest.get("/test");
+        if (Objects.nonNull(domain)) {
+            request.header(Constants.SPECIFY_DOMAIN, domain);
+        }
+        ServerWebExchange exchange = MockServerWebExchange.from(request);
+        exchange.getAttributes().put(Constants.CONTEXT, new ShenyuContext());
+        return exchange;
+    }
+
+    private Upstream upstream(final String url, final Map<String, String> 
labels) {
+        Upstream upstream = 
Upstream.builder().url(url).protocol("http://";).weight(100).build();
+        upstream.setLabels(labels);
+        return upstream;
+    }
+
+    private DiscoveryUpstreamData instance(final String url, final boolean 
gray, final String release) {
+        Map<String, Object> props = Map.of("gray", gray, "healthCheckEnabled", 
false, "labels", Map.of("release", release, "region", "east"));
+        return 
DiscoveryUpstreamData.builder().url(url).protocol("http://";).status(0).props(GsonUtils.getInstance().toJson(props)).build();
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionServiceTest.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionServiceTest.java
new file mode 100644
index 0000000000..0000aa4a89
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/DefaultCanaryDecisionServiceTest.java
@@ -0,0 +1,248 @@
+/*
+ * 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.shenyu.plugin.divide.canary;
+
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.canary.StickyKeyConfig;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
+import org.apache.shenyu.plugin.base.utils.HostAddressUtils;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.http.HttpCookie;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ServerWebExchange;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mockStatic;
+
+/**
+ * Tests the partition contract, including portable hash vectors and rollout 
stability.
+ */
+class DefaultCanaryDecisionServiceTest {
+
+    private final DefaultCanaryDecisionService service = new 
DefaultCanaryDecisionService();
+
+    @Test
+    void testPortableHashVectors() {
+        // Independently calculated SHA-256 vectors, including unsigned values 
and UTF-8 byte lengths.
+        assertEquals(3033, service.hashToBucket("rule-1", "user-1"));
+        assertEquals(9925, service.hashToBucket("规则甲", "用户乙"));
+        assertEquals(912, service.hashToBucket("ab", "c"));
+        assertEquals(6726, service.hashToBucket("a", "bc"));
+    }
+
+    @Test
+    void testDisabledConfigurationAlwaysSelectsStable() {
+        CanaryConfig config = config(100, "unknown");
+        config.setEnabled(false);
+        config.setConditions(List.of(condition("user-1")));
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange("user-1"), 
"rule-1", config));
+        config.setStickyKey(null);
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange("user-1"), 
"rule-1", config));
+    }
+
+    @Test
+    void testPercentageBoundariesAndEmptyOrConditions() {
+        CanaryConfig config = config(0, "header");
+        config.setStickyKey(null);
+        ServerWebExchange exchange = exchange("user-1");
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1", 
config));
+        config.setPercentage(100);
+        config.setMatchMode(1);
+        config.setConditions(List.of());
+        assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1", 
config));
+    }
+
+    @Test
+    void testConditionsAreEligibilityBeforePercentage() {
+        CanaryConfig config = config(100, "header");
+        config.setConditions(List.of(condition("user-1"), 
condition("another-user")));
+        ServerWebExchange exchange = exchange("user-1");
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1", 
config));
+        config.setMatchMode(1);
+        assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1", 
config));
+        config.setMatchMode(null);
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1", 
config));
+        config.setConditions(List.of(condition("user-1")));
+        config.setPercentage(30);
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1", 
config));
+        config.setPercentage(31);
+        assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1", 
config));
+    }
+
+    @Test
+    void testStickySourcesAndMissingIdentifiers() {
+        ServerWebExchange exchange = 
MockServerWebExchange.from(MockServerHttpRequest.get("/?user=user-1")
+                .header("user", "user-1").cookie(new HttpCookie("user", 
"user-1")));
+        for (String type : List.of("header", "cookie", "query")) {
+            assertEquals(CanaryDecision.CANARY, service.decide(exchange, 
"rule-1", config(31, type)));
+            assertEquals(CanaryDecision.STABLE, 
service.decide(MockServerWebExchange.from(MockServerHttpRequest.get("/")), 
"rule-1", config(31, type)));
+        }
+        try (MockedStatic<HostAddressUtils> addresses = 
mockStatic(HostAddressUtils.class)) {
+            addresses.when(() -> 
HostAddressUtils.acquireIp(exchange)).thenReturn("user-1");
+            CanaryConfig config = config(31, "ip");
+            config.getStickyKey().setParamName(null);
+            assertEquals(CanaryDecision.CANARY, service.decide(exchange, 
"rule-1", config));
+        }
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange("   "), 
"rule-1", config(99, "header")));
+    }
+
+    @Test
+    void testAdditionalBuiltinSourcesWithoutParameterName() {
+        ServerWebExchange exchange = 
MockServerWebExchange.from(MockServerHttpRequest.get("http://example.com/orders?ignored=value";));
+        // Thresholds come from independently calculated buckets: 
/orders=9824, GET=4747, example.com=391.
+        assertSourceThreshold(exchange, "uri", null, 99);
+        assertSourceThreshold(exchange, "req_method", null, 48);
+        assertSourceThreshold(exchange, "domain", null, 4);
+        try (MockedStatic<HostAddressUtils> addresses = 
mockStatic(HostAddressUtils.class)) {
+            addresses.when(() -> 
HostAddressUtils.acquireHost(exchange)).thenReturn("client.example.com");
+            assertSourceThreshold(exchange, "host", null, 55);
+        }
+    }
+
+    @Test
+    void testPostSourceReusesShenyuContextFields() {
+        ServerWebExchange exchange = exchange("ignored");
+        ShenyuContext context = new ShenyuContext();
+        context.setModule("user-1");
+        exchange.getAttributes().put(Constants.CONTEXT, context);
+        assertSourceThreshold(exchange, "post", "module", 31);
+    }
+
+    @Test
+    void testCustomSourceLoadedFromSpiResource() {
+        ServerWebExchange exchange = exchange("ignored");
+        exchange.getAttributes().put("authenticatedUserId", "user-1");
+        exchange.getAttributes().put("tenantUser", "user-2");
+        // Neither the source name nor its optional parameter name is built 
into the decision service.
+        assertSourceThreshold(exchange, "test_attribute", null, 31);
+        assertSourceThreshold(exchange, "test_attribute", "tenantUser", 77);
+    }
+
+    @Test
+    void testCustomSourceMissingValueAndExtractionFailure() {
+        ServerWebExchange exchange = exchange("ignored");
+        CanaryConfig config = config(99, "test_attribute");
+        config.getStickyKey().setParamName(null);
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1", 
config));
+        exchange.getAttributes().put("authenticatedUserId", "   ");
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1", 
config));
+        exchange.getAttributes().put("authenticatedUserId", new Object());
+        assertThrows(ClassCastException.class, () -> service.decide(exchange, 
"rule-1", config));
+    }
+
+    @Test
+    void testRequiredSourceType() {
+        ServerWebExchange exchange = exchange("user-1");
+        CanaryConfig missingType = config(50, null);
+        assertThrows(IllegalArgumentException.class, () -> 
service.decide(exchange, "rule-1", missingType));
+        for (String type : List.of("", "   ")) {
+            assertThrows(IllegalArgumentException.class, () -> 
service.decide(exchange, "rule-1", config(50, type)));
+        }
+    }
+
+    @Test
+    void testBlankParameterNamesUseBuiltinExtraction() {
+        ServerWebExchange exchange = 
MockServerWebExchange.from(MockServerHttpRequest.get("/?user=user-1")
+                .header("user", "user-1").cookie(new HttpCookie("user", 
"user-1")));
+        ShenyuContext context = new ShenyuContext();
+        context.setModule("user-1");
+        exchange.getAttributes().put(Constants.CONTEXT, context);
+        for (String type : List.of("header", "cookie", "query", "post")) {
+            CanaryConfig missingName = config(50, type);
+            for (String paramName : new String[] {null, "", "   "}) {
+                missingName.getStickyKey().setParamName(paramName);
+                assertEquals(CanaryDecision.STABLE, service.decide(exchange, 
"rule-1", missingName));
+            }
+        }
+    }
+
+    @Test
+    void testRolloutExpansionPreservesUsersAcrossInstances() {
+        CanaryConfig tenPercent = config(10, "header");
+        CanaryConfig twentyPercent = config(20, "header");
+        DefaultCanaryDecisionService otherInstance = new 
DefaultCanaryDecisionService();
+        int originalCanaries = 0;
+        int newCanaries = 0;
+        for (int i = 0; i < 1000; i++) {
+            ServerWebExchange exchange = exchange("user-" + i);
+            CanaryDecision before = service.decide(exchange, "rule-1", 
tenPercent);
+            assertEquals(before, otherInstance.decide(exchange, "rule-1", 
tenPercent));
+            CanaryDecision after = otherInstance.decide(exchange, "rule-1", 
twentyPercent);
+            if (before == CanaryDecision.CANARY) {
+                originalCanaries++;
+                assertEquals(CanaryDecision.CANARY, after);
+            }
+            if (after == CanaryDecision.CANARY) {
+                newCanaries++;
+            }
+        }
+        assertTrue(originalCanaries > 0);
+        assertTrue(newCanaries > originalCanaries);
+    }
+
+    @Test
+    void testInvalidConfigurationIsNotARequestKeyMiss() {
+        ServerWebExchange exchange = exchange("user-1");
+        assertThrows(IllegalArgumentException.class, () -> 
service.decide(exchange, "rule-1", config(-1, "header")));
+        assertThrows(IllegalArgumentException.class, () -> 
service.decide(exchange, "rule-1", config(101, "header")));
+        assertThrows(IllegalArgumentException.class, () -> 
service.decide(exchange, "rule-1", config(50, "unknown")));
+        CanaryConfig missing = config(50, "header");
+        missing.setStickyKey(null);
+        assertThrows(IllegalArgumentException.class, () -> 
service.decide(exchange, "rule-1", missing));
+    }
+
+    private void assertSourceThreshold(final ServerWebExchange exchange, final 
String type, final String paramName, final int percentage) {
+        CanaryConfig config = config(percentage, type);
+        config.getStickyKey().setParamName(paramName);
+        assertEquals(CanaryDecision.CANARY, service.decide(exchange, "rule-1", 
config));
+        config.setPercentage(percentage - 1);
+        assertEquals(CanaryDecision.STABLE, service.decide(exchange, "rule-1", 
config));
+    }
+
+    private CanaryConfig config(final int percentage, final String type) {
+        CanaryConfig config = new CanaryConfig();
+        config.setEnabled(true);
+        config.setPercentage(percentage);
+        StickyKeyConfig stickyKey = new StickyKeyConfig();
+        stickyKey.setParamType(type);
+        stickyKey.setParamName("user");
+        config.setStickyKey(stickyKey);
+        return config;
+    }
+
+    private ServerWebExchange exchange(final String user) {
+        return 
MockServerWebExchange.from(MockServerHttpRequest.get("/").header("user", user));
+    }
+
+    private ConditionData condition(final String value) {
+        ConditionData condition = new ConditionData();
+        condition.setParamType("header");
+        condition.setParamName("user");
+        condition.setOperator("equals");
+        condition.setParamValue(value);
+        return condition;
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/fixture/AttributeParameterData.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/fixture/AttributeParameterData.java
new file mode 100644
index 0000000000..f8ef8be370
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/canary/fixture/AttributeParameterData.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.shenyu.plugin.divide.canary.fixture;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.plugin.base.condition.data.ParameterData;
+import org.apache.shenyu.spi.Join;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * Test-only extension that reads a string attribute and defines its own 
default parameter name.
+ */
+@Join
+public class AttributeParameterData implements ParameterData {
+
+    @Override
+    public String builder(final String paramName, final ServerWebExchange 
exchange) {
+        return exchange.getAttribute(StringUtils.defaultIfBlank(paramName, 
"authenticatedUserId"));
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
index 70e496b9f4..26316ea20d 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DividePluginDataHandlerTest.java
@@ -18,6 +18,8 @@
 package org.apache.shenyu.plugin.divide.handler;
 
 import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.canary.CanaryConfig;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
 import org.apache.shenyu.common.dto.SelectorData;
 import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
 import org.apache.shenyu.common.enums.PluginEnum;
@@ -25,9 +27,12 @@ import org.apache.shenyu.common.utils.GsonUtils;
 import org.apache.shenyu.common.utils.UpstreamCheckUtils;
 import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
 import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.MockedStatic;
@@ -36,11 +41,14 @@ import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
@@ -111,4 +119,80 @@ public final class DividePluginDataHandlerTest {
     public void removeRuleTest() {
         dividePluginDataHandler.removeRule(ruleData);
     }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"\"percentage\":101", "\"percentage\":20.5", 
"\"enabled\":true,\"percentage\":20",
+        "\"fallbackPolicy\":\"TYPO\"", 
"\"stickyKey\":{\"paramType\":\"missing-extension\"}"})
+    public void testInvalidConfigurationDoesNotReplaceCachedRule(final String 
fields) {
+        RuleData rule = new RuleData();
+        rule.setId("invalid-config-rule");
+        rule.setSelectorId("invalid-config-selector");
+        rule.setHandle("{\"timeout\":5000}");
+        dividePluginDataHandler.handlerRule(rule);
+        DivideRuleHandle previous = 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+        
rule.setHandle("{\"canary\":{\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"},"
 + fields + "}}");
+        try {
+            assertThrows(IllegalArgumentException.class, () -> 
dividePluginDataHandler.handlerRule(rule));
+            assertSame(previous, 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule)));
+        } finally {
+            dividePluginDataHandler.removeRule(rule);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"default", "fixed", "exponential"})
+    public void testCanaryFailoverDoesNotReplaceCachedRule(final String 
backoff) {
+        RuleData rule = new RuleData();
+        rule.setId("failover-config-rule");
+        rule.setSelectorId("failover-config-selector");
+        rule.setHandle("{\"timeout\":5000}");
+        dividePluginDataHandler.handlerRule(rule);
+        DivideRuleHandle previous = 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+        
rule.setHandle("{\"retry\":2,\"retryStrategy\":\"failover\",\"retryBackOffSpec\":\""
 + backoff + "\","
+                + 
"\"canary\":{\"enabled\":false,\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}}");
+        try {
+            assertThrows(IllegalArgumentException.class, () -> 
dividePluginDataHandler.handlerRule(rule));
+            assertSame(previous, 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule)));
+        } finally {
+            dividePluginDataHandler.removeRule(rule);
+        }
+    }
+
+    @Test
+    public void testInstalledCustomParameterSourceCanBeCached() {
+        RuleData rule = new RuleData();
+        rule.setId("custom-source-rule");
+        rule.setSelectorId("custom-source-selector");
+        
rule.setHandle("{\"canary\":{\"enabled\":true,\"percentage\":20,\"stickyKey\":{\"paramType\":\"test_attribute\"},"
+                + 
"\"stableLabels\":{\"release\":\"stable\"},\"canaryLabels\":{\"release\":\"canary\"}}}");
+        try {
+            dividePluginDataHandler.handlerRule(rule);
+            DivideRuleHandle cached = 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+            assertEquals("test_attribute", 
cached.getCanary().getStickyKey().getParamType());
+        } finally {
+            dividePluginDataHandler.removeRule(rule);
+        }
+    }
+
+    @Test
+    public void testRejectOverlappingLabelsBeforeReplacingCachedRule() {
+        RuleData rule = new RuleData();
+        rule.setId("label-validation-rule");
+        rule.setSelectorId("label-validation-selector");
+        CanaryConfig config = new CanaryConfig();
+        config.setEnabled(false);
+        config.setCanaryLabels(Map.of("release", "canary"));
+        config.setStableLabels(Map.of("release", "stable"));
+        DivideRuleHandle handle = new DivideRuleHandle();
+        handle.setCanary(config);
+        rule.setHandle(GsonUtils.getGson().toJson(handle));
+        dividePluginDataHandler.handlerRule(rule);
+        final DivideRuleHandle previous = 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
+        config.setStableLabels(Map.of("region", "east"));
+        rule.setHandle(GsonUtils.getGson().toJson(handle));
+        assertThrows(IllegalArgumentException.class, () -> 
dividePluginDataHandler.handlerRule(rule));
+        assertSame(previous, 
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule)));
+        dividePluginDataHandler.removeRule(rule);
+    }
+
 }
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
index 7e33f12756..b835ec1159 100644
--- 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java
@@ -272,6 +272,42 @@ public class DivideUpstreamDataHandlerTest {
         assertEquals(17, original.getSucceeded().get());
     }
 
+    @Test
+    public void testLegacyGrayPolicyAndLiveFlagUpdates() {
+        DiscoveryUpstreamData gray = instance("gray:8080", "{\"gray\":true}");
+        DiscoveryUpstreamData ordinary = instance("ordinary:8080", "{}");
+        publish(List.of(gray, ordinary));
+        UpstreamCacheManager manager = UpstreamCacheManager.getInstance();
+        assertEquals(List.of("gray:8080"), 
manager.findUpstreamListBySelectorId("handler").stream().map(Upstream::getUrl).toList());
+        assertEquals(2, 
manager.findAllUpstreamListBySelectorId("handler").size());
+        Upstream cached = 
manager.findUpstreamListBySelectorId("handler").get(0);
+        cached.getSucceeded().set(17);
+        gray.setProps("{}");
+        ordinary.setProps("{\"gray\":true}");
+        publish(List.of(gray, ordinary));
+        assertEquals(List.of("ordinary:8080"), 
manager.findUpstreamListBySelectorId("handler").stream().map(Upstream::getUrl).toList());
+        assertFalse(cached.isGray());
+        assertEquals(17, cached.getSucceeded().get());
+        ordinary.setProps("{}");
+        publish(List.of(gray, ordinary));
+        assertEquals(2, 
manager.findUpstreamListBySelectorId("handler").size());
+    }
+
+    @Test
+    public void testUnhealthyGrayDoesNotFallBackAndRemovalClearsGrayPolicy() {
+        DiscoveryUpstreamData gray = instance("gray:8080", "{\"gray\":true}");
+        gray.setStatus(1);
+        DiscoveryUpstreamData ordinary = instance("ordinary:8080", "{}");
+        publish(List.of(gray, ordinary));
+        UpstreamCacheManager manager = UpstreamCacheManager.getInstance();
+        assertTrue(manager.findUpstreamListBySelectorId("handler").isEmpty());
+        assertEquals(List.of("ordinary:8080"), 
manager.findAllUpstreamListBySelectorId("handler").stream().map(Upstream::getUrl).toList());
+        publish(List.of());
+        assertNull(manager.findAllUpstreamListBySelectorId("handler"));
+        publish(List.of(ordinary));
+        assertEquals(1, 
manager.findUpstreamListBySelectorId("handler").size());
+    }
+
     private DiscoveryUpstreamData instance(final String url, final String 
props) {
         return 
DiscoveryUpstreamData.builder().protocol("http://";).url(url).status(0).props(props).build();
     }
diff --git 
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/resources/META-INF/shenyu/org.apache.shenyu.plugin.base.condition.data.ParameterData
 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/resources/META-INF/shenyu/org.apache.shenyu.plugin.base.condition.data.ParameterData
new file mode 100644
index 0000000000..82e8a708a0
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/resources/META-INF/shenyu/org.apache.shenyu.plugin.base.condition.data.ParameterData
@@ -0,0 +1,18 @@
+# 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.
+
+
+test_attribute=org.apache.shenyu.plugin.divide.canary.fixture.AttributeParameterData
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
index ba75f15ecc..7e43448bfd 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/main/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfiguration.java
@@ -22,10 +22,13 @@ import 
org.apache.shenyu.plugin.api.context.ShenyuContextDecorator;
 import org.apache.shenyu.plugin.base.handler.MetaDataHandler;
 import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
 import org.apache.shenyu.plugin.divide.DividePlugin;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
 import org.apache.shenyu.plugin.divide.context.DivideShenyuContextDecorator;
 import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
 import org.apache.shenyu.plugin.divide.handler.DivideMetaDataHandler;
 import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
+import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -40,11 +43,23 @@ public class DividePluginConfiguration {
     /**
      * init dividePlugin.
      *
+     * @param canaryDecisionService request partition decision service
      * @return {@linkplain DividePlugin}
      */
     @Bean
-    public ShenyuPlugin dividePlugin() {
-        return new DividePlugin();
+    public ShenyuPlugin dividePlugin(final CanaryDecisionService 
canaryDecisionService) {
+        return new DividePlugin(canaryDecisionService);
+    }
+
+    /**
+     * Default request partition decision service.
+     *
+     * @return Canary decision service
+     */
+    @Bean
+    @ConditionalOnMissingBean(CanaryDecisionService.class)
+    public CanaryDecisionService canaryDecisionService() {
+        return new DefaultCanaryDecisionService();
     }
 
     /**
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
index 619dd2dad3..c9f5767e99 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-divide/src/test/java/org/apache/shenyu/springboot/plugin/divide/DividePluginConfigurationTest.java
@@ -21,6 +21,8 @@ import org.apache.shenyu.plugin.api.ShenyuPlugin;
 import org.apache.shenyu.plugin.api.context.ShenyuContextDecorator;
 import org.apache.shenyu.plugin.base.handler.MetaDataHandler;
 import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
+import org.apache.shenyu.plugin.divide.canary.CanaryDecisionService;
+import org.apache.shenyu.plugin.divide.canary.DefaultCanaryDecisionService;
 import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -30,6 +32,9 @@ import 
org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Configuration;
 
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
 
 /**
  * Test case for {@link DividePluginConfiguration}.
@@ -57,6 +62,20 @@ public class DividePluginConfigurationTest {
         );
     }
 
+    @Test
+    public void testDefaultCanaryDecisionService() {
+        applicationContextRunner.run(context -> 
assertTrue(context.getBean(CanaryDecisionService.class) instanceof 
DefaultCanaryDecisionService));
+    }
+
+    @Test
+    public void testCustomCanaryDecisionService() {
+        CanaryDecisionService custom = mock(CanaryDecisionService.class);
+        applicationContextRunner.withBean(CanaryDecisionService.class, () -> 
custom).run(context -> {
+            assertSame(custom, context.getBean(CanaryDecisionService.class));
+            assertNotNull(context.getBean("dividePlugin", ShenyuPlugin.class));
+        });
+    }
+
     @Test
     public void testDividePluginDataHandler() {
         applicationContextRunner.run(context -> {

Reply via email to