Skip to content

Regression: spring-cloud-context 5.0.2 permanently nulls @Value-injected fields on @ConfigurationProperties beans on the first EnvironmentChangeEvent. #1716

Description

@oliverlockwood

Full disclosure: I used Claude to assist in diagnosing the issue, generating the text here, and setting up the repro scenario. However, I have carefully edited the LLM-provided text and implementation myself, and manually validated the reproduction scenario and workaround.

Describe the bug

spring-cloud-context 5.0.2 (Spring Cloud 2025.1.2) permanently nulls @Value-injected fields on @ConfigurationProperties beans on the first EnvironmentChangeEvent. The values are never restored.

Introduced by #1680 (Reset bean to defaults before rebinding values). Distinct from the known fallout in #1698 and #1700 (where BeanUtils.instantiateClass fails and the reset is skipped, milestoned for 5.0.3) and from #1709 (the transient race during reset). This is the reset succeeding, and causing permanent silent state loss.

Root cause

The two branches of ConfigurationPropertiesRebinder.rebind(String, ApplicationContext) are not equivalent:

// proxied branch - createBean() runs populateBean(), so @Value IS re-injected
Object freshBean = appContext.getAutowireCapableBeanFactory().createBean(targetClass);

// non-proxied branch - no populateBean(), so @Value is NOT re-injected
appContext.getAutowireCapableBeanFactory().destroyBean(bean);
resetBeanToDefaults(bean);
appContext.getAutowireCapableBeanFactory().initializeBean(bean, name);

resetProperties() copies every writable property from a fresh new T() onto the live bean. initializeBean() then runs BeanPostProcessors, so ConfigurationPropertiesBindingPostProcessor re-binds whatever is reachable under the bean's own prefix - but @Value injection happens in AutowiredAnnotationBeanPostProcessor.postProcessProperties(), which runs in populateBean(), and rebind() never calls it.

Net effect, for any non-proxied @ConfigurationProperties bean: a field survives if and only if its value is reachable from that bean's own prefix. @Value state is silently discarded.

Impact: CONSUL_TOKEN silently stops working

ConsulDiscoveryProperties.aclToken is populated by @Value:

@Value("${consul.token:${CONSUL_TOKEN:${spring.cloud.consul.token:${SPRING_CLOUD_CONSUL_TOKEN:}}}}")
private String aclToken;

None of those keys sit under spring.cloud.consul.discovery, so the binder cannot restore them. On any deployment supplying the token via CONSUL_TOKEN:

  1. Startup succeeds, aclToken is populated, discovery works.
  2. The Consul config watch fires an EnvironmentChangeEvent, typically within a minute.
  3. aclToken becomes null and is never re-injected.
  4. Every subsequent Consul catalog/health query goes out unauthenticated.

With ACLs enabled and default_policy: deny, Consul answers an unauthenticated catalog query with an empty result set, not a 403. So discovery silently returns zero instances for every service, load balancing fails with No servers available for service: <name>, and clients fall through to the literal hostname with UnknownHostException. Nothing anywhere points at the ACL token.

An authenticated, working service mesh becomes a silently empty one, minutes after startup, with no diagnostic.

CONSUL_TOKEN is a supported contract, not a shortcut**

This is the second time this contract has been broken. In spring-cloud/spring-cloud-consul#738, the spring.config.import migration stopped CONSUL_TOKEN being honoured. While the initial response was that it should give way to the prefixed property, after discussion, maintainers accepted the fact that CONSUL_TOKEN is the HashiCorp-conventional environment variable, used by the Consul CLI, Terraform and envconsul, and shared across non-Spring services in a mixed estate, and the fix shipped in 3.1.3.

That @Value chain exists because of spring-cloud/spring-cloud-consul#738. #1680 has now silently undone it through a different mechanism, so please don't treat "use spring.cloud.consul.discovery.acl-token instead" as the resolution here 😄

(ConsulConfigProperties is unaffected: under spring.config.import it is promoted via beanFactory.registerSingleton(), which bypasses BeanPostProcessors, so the rebinder never touches it. Only discovery breaks.)

Reproduction

Runnable, no infrastructure required — https://github.com/oliverlockwood/spring-cloud-consul-example

git clone https://github.com/oliverlockwood/spring-cloud-consul-example
cd spring-cloud-consul-example
mvn test                                                              # 2 failures
mvn test -Dspring-boot.version=4.0.6 -Dspring-cloud.version=2025.1.1  # all pass

It covers both a plain @ConfigurationProperties bean with a @Value field and ConsulDiscoveryProperties.aclToken itself, plus a control field under the bean's own prefix that passes on both versions. The README also walks through a live ACL-enabled Consul, showing the token disappearing after a refresh.

Although technically it's an supported pairing, holding Spring Boot at 4.0.6 and moving only Spring Cloud to 2025.1.2 flips the result, so this is not a Boot regression. resetBeanToDefaults / resetProperties exist only in spring-cloud-context 5.0.2 - absent from 5.0.1 and from all of 4.1.x–4.3.3.

Versions

Spring Cloud 2025.1.2 (spring-cloud-context 5.0.2)
Last known good 2025.1.1 (spring-cloud-context 5.0.1)
Also unaffected all of 4.1.x4.3.3
Spring Boot 4.1.0; also reproduces on 4.0.6
Java 25

Suggested fix

  1. Use createBean(targetClass) in the non-proxied branch too, mirroring the proxied branch, so populateBean() re-applies @Value and @Autowired.
  2. Or call applyBeanPropertyValues(bean, name) after resetBeanToDefaults() and before initializeBean().
  3. Or, at minimum, have resetProperties() skip properties whose backing field carries @Value or @Autowired — weakest, and leaves ConfigurationPropertiesRebinder exposes invalid state racily #1709 intact.

More broadly, #1680's premise — that a bean's post-construction state can be rebuilt from new T() plus a prefix rebind — does not hold for any bean whose state has a second source. Restricting the reset to properties actually owned by the bean's prefix, rather than every writable property on the class, would avoid that.

Workaround

SPRING_CLOUD_CONSUL_DISCOVERY_ACL_TOKEN=<token>

Reachable by the binder, so it is restored on every rebind. On 5.0.2 neither CONSUL_TOKEN nor spring.cloud.consul.token is sufficient, including as a real property rather than an environment variable.

Or opt the bean out of rebinding — a replace, not an append, so the Hikari default must be re-listed:

spring.cloud.refresh.never-refreshable=com.zaxxer.hikari.HikariDataSource,org.springframework.cloud.consul.discovery.ConsulDiscoveryProperties

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    Done

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions