Você tem uma classe @ConfigurationProperties com um campo Duration timeout e, no application.yaml, escreve isto:
app:
timeout: 10
A aplicação sobe. Nenhum warning, nenhuma falha de bind. Quem escreveu o YAML pensou em dez segundos. O Spring entendeu dez milissegundos. O timeout real ficou mil vezes menor do que o time imaginava, e nada no startup avisa.
O YAML diz 10, toMillis() também diz 10, e os dois estão certos. Quem errou foi o time.
Este texto mostra a regra de conversão de Duration no Spring Boot 4.1, o que muda com @DurationUnit, quais formatos derrubam o startup e quais passam em silêncio com a unidade errada. Tudo foi conferido num projeto Maven com testes JUnit. A tabela de resultados está mais abaixo.
Versões e as três formas de escrever um Duration
O projeto de teste usa Spring Boot 4.1.1 (parent spring-boot-starter-parent:4.1.1), Temurin 25.0.4 com java.version 25 no POM e Maven 3.9.12. Segundo os requisitos de sistema do Boot, o 4.1.1 exige pelo menos Java 17 e é compatível até o Java 26, com Spring Framework 7.0.9 ou superior. O JDK 25 fica dentro dessa faixa.
A seção Converting Durations da documentação 4.1 aceita três formatos para um Duration:
- Um
longcomum, que usa milissegundos como unidade padrão, a menos que o campo tenha@DurationUnit. - O formato ISO-8601 do próprio
java.time.Duration, comoPT10S. - Um formato legível com valor e unidade juntos, como
10s. As unidades aceitas sãons,us,ms,s,m,hed.
A frase que importa está no primeiro item: sem unidade, vale milissegundo. O exemplo da documentação diz que 30, PT30S e 30s são equivalentes, mas lá o campo sessionTimeout tem @DurationUnit(ChronoUnit.SECONDS). No campo sem anotação, a própria página dá como equivalentes 500, PT0.5S e 500ms. Quem copia o exemplo de 30 segundos sem a anotação herda o número e perde a unidade.
As classes do experimento
São duas classes de propriedades. A primeira é o caso comum, um Duration sem nenhuma anotação:
package academy.devdojo.durationbind;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app")
public class AppProperties {
private Duration timeout;
public Duration getTimeout() {
return this.timeout;
}
public void setTimeout(Duration timeout) {
this.timeout = timeout;
}
}
A segunda tem o mesmo tipo, mas com @DurationUnit trocando a unidade padrão para segundos:
package academy.devdojo.durationbind;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
@ConfigurationProperties("app.seconds")
public class SecondsProperties {
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout;
public Duration getTimeout() {
return this.timeout;
}
public void setTimeout(Duration timeout) {
this.timeout = timeout;
}
}
O javadoc da anotação descreve o elemento value como "the duration unit to use if one is not specified". O detalhe está em "if one is not specified": a anotação só vale quando o valor vem sem unidade. Voltamos a isso adiante.
Os testes usam ApplicationContextRunner com withPropertyValues para os casos de propriedade. Como o runner não tem um withYaml, os casos de YAML sobem um SpringApplication real apontando spring.config.location para um arquivo em src/test/resources/yaml/. Os principais:
private final ApplicationContextRunner appRunner = new ApplicationContextRunner()
.withUserConfiguration(AppBindConfig.class);
private final ApplicationContextRunner secondsRunner = new ApplicationContextRunner()
.withUserConfiguration(SecondsBindConfig.class);
@Test
void suffixLessTenIsTenMillis() {
this.appRunner.withPropertyValues("app.timeout=10").run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(AppProperties.class).getTimeout().toMillis()).isEqualTo(10L);
});
}
@Test
void tenSecondsSuffixIsTenSeconds() {
this.appRunner.withPropertyValues("app.timeout=10s").run((context) -> {
assertThat(context).hasNotFailed();
Duration timeout = context.getBean(AppProperties.class).getTimeout();
assertThat(timeout.toSeconds()).isEqualTo(10L);
assertThat(timeout.toMillis()).isEqualTo(10_000L);
});
}
@Test
void durationUnitSecondsMakesSuffixLessTenTenSeconds() {
this.secondsRunner.withPropertyValues("app.seconds.timeout=10").run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(SecondsProperties.class).getTimeout().toSeconds()).isEqualTo(10L);
assertThat(context.getBean(SecondsProperties.class).getTimeout().toMillis()).isEqualTo(10_000L);
});
}
@Test
void simpleSuffixWinsOverDurationUnit() {
this.secondsRunner.withPropertyValues("app.seconds.timeout=10ms").run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(SecondsProperties.class).getTimeout().toMillis()).isEqualTo(10L);
});
}
@Test
void malformedSimpleUnitFailsRefresh() {
this.appRunner.withPropertyValues("app.timeout=10seconds")
.run((context) -> assertThat(context).hasFailed());
this.appRunner.withPropertyValues("app.timeout=10 sec")
.run((context) -> assertThat(context).hasFailed());
this.appRunner.withPropertyValues("app.timeout=foo")
.run((context) -> assertThat(context).hasFailed());
}
E o helper que sobe o YAML de verdade:
private static void assertYamlMillis(String location, long expectedMillis) {
SpringApplication application = new SpringApplication(DurationBindApplication.class);
try (ConfigurableApplicationContext context = application.run("--spring.main.web-application-type=none",
"--spring.config.location=" + location)) {
assertThat(context.getBean(AppProperties.class).getTimeout().toMillis()).isEqualTo(expectedMillis);
}
}
Aqui, "verde" quer dizer só que o refresh do contexto terminou sem falha. Não tem servidor HTTP nem health check no experimento. É o mínimo que já basta para mostrar o problema.
O que mvn -q test mostrou
No diretório do projeto, mvn -q test executou 13 testes: 0 falhas, 0 erros, 0 ignorados. O relatório do Surefire fica em TEST-academy.devdojo.durationbind.DurationBindTests.xml. Estes foram os resultados:
| Entrada | Campo | Contexto | Resultado |
|---|---|---|---|
app.timeout=10, YAML timeout: 10, YAML timeout: "10" | Duration sem anotação | sobe | toMillis() == 10 |
app.timeout=10s, YAML timeout: 10s | Duration sem anotação | sobe | toMillis() == 10000, toSeconds() == 10 |
app.timeout=PT10S, YAML timeout: PT10S | Duration sem anotação | sobe | toMillis() == 10000, toSeconds() == 10 |
app.seconds.timeout=10 | @DurationUnit(ChronoUnit.SECONDS) | sobe | toMillis() == 10000 |
app.seconds.timeout=10ms | @DurationUnit(ChronoUnit.SECONDS) | sobe | toMillis() == 10 (o sufixo vence) |
app.timeout=10seconds, 10 sec, foo | Duration sem anotação | falha no refresh | hasFailed() |
YAML timeout: 10seconds | Duration sem anotação | SpringApplication não sobe | FailureAnalysis no bind de app.timeout |
app.valid.nested.timeout=10 | @Valid aninhado com @NotNull Duration | sobe | toMillis() == 10 |
app.valid.nested.timeout omitido | @Valid aninhado com @NotNull Duration | falha no refresh | violação de @NotNull |
Quatro coisas saem dessa tabela.
Sem sufixo é milissegundo, com ou sem aspas. No YAML, timeout: 10 é um inteiro e timeout: "10" é uma string, e a documentação não compara os dois casos. No experimento, os dois deram 10 ms. Isso bate com o código-fonte do 4.1.1: o NumberToDurationConverter chama toString() no número e repassa para o StringToDurationConverter, então tudo termina no mesmo parser.
@DurationUnit muda o padrão, não a regra. No campo anotado, 10 vira 10 segundos. Já 10ms no mesmo campo continua sendo 10 milissegundos. No DurationStyle do 4.1.1, quando o valor no formato simples traz sufixo, a unidade vem do sufixo. A unidade da anotação só entra quando não há sufixo, e sem anotação o fallback é MILLIS. O formato ISO-8601 ignora a anotação de vez e chama Duration.parse.
Unidade malformada derruba o startup. 10seconds, 10 sec e foo não passam no refresh. O motivo também está no DurationStyle. O formato simples segue a regex ^([+-]?\d+)([a-zA-Z]{0,2})$, que aceita no máximo duas letras de sufixo e nenhum espaço. O ISO-8601 exige começar com P. Nenhuma das três entradas casa com um formato nem com o outro, e o parser lança IllegalArgumentException. A página de conversão da documentação não lista esses casos. O comportamento vem do código e do teste.
No caso do YAML com timeout: 10seconds, a FailureAnalysis do SpringApplication diz que falhou o bind de app.timeout, com Value "10seconds" e Reason contendo '10seconds' is not a valid duration. Repare na ironia: a única versão que tenta deixar a unidade explícita por extenso é a única que não sobe.
O validador não enxerga unidade. Com @Validated, @Valid no objeto aninhado e @NotNull no Duration, app.valid.nested.timeout=10 sobe com 10 ms. Faz sentido: 10 milissegundos não é null. Só a ausência do valor derruba o contexto. Esse não é o problema do @Valid em @ConfigurationProperties aninhado, em que a validação deixava de disparar. Aqui ela dispara e aprova, porque o valor fez o bind certinho, só que na unidade errada. Também não adianta buscar um @Positive: a especificação Jakarta Validation 3.1 não inclui Duration entre os tipos suportados dessa constraint. E mesmo que incluísse, 10 ms é positivo.
Armadilhas que valem uma frase cada
O hábito vem de outro lugar. No Kubernetes, o timeoutSeconds de uma probe é um inteiro em segundos ("Number of seconds after which the probe times out", na referência da API de Pod). Quem acabou de editar o manifesto e passa para o application.yaml leva o 10 junto. A probe pensa em segundos, o Spring pensa em milissegundos e o YAML não diz qual dos dois está valendo. O Spring não copia a convenção do Kubernetes. O Go também não: time.ParseDuration exige sufixo de unidade, então lá um número sozinho nem é uma duração válida.
O sufixo vence a anotação. Esse comportamento é bom, porque 10ms escrito à mão diz exatamente o que quer dizer. Mas quem lê só a classe e vê @DurationUnit(SECONDS) pode supor que qualquer valor naquele campo está em segundos. Não está.
Malformado falha alto, unidade errada falha calado. 10seconds impede a aplicação de subir, e você descobre no deploy. 10 sobe normalmente, e você descobre quando o timeout dispara mil vezes mais cedo do que deveria. Entre os dois erros, o barulhento é o menos perigoso.
Trocar Duration por Long não resolve. Um long timeoutSeconds só passa a unidade para o nome do campo e abre mão do sufixo, do ISO-8601 e da falha de bind em valor malformado. O problema continua lá, só que menos visível.
Não é precedência de propriedades. O valor que chegou ao campo é exatamente o que estava no YAML. Nenhuma outra fonte sobrescreveu nada. O problema está só na conversão.
O que fazer
A recomendação depende de quem escreve a configuração.
- Se o time escreve o YAML à mão, use sempre unidade explícita:
10souPT10S. Os dois deramtoMillis() == 10000no experimento, e quem lê o arquivo depois não precisa abrir a classe Java para descobrir a unidade. - Se a convenção do time (ou de uma ferramenta que gera a configuração) é número puro em segundos, coloque
@DurationUnit(ChronoUnit.SECONDS)no campo. Assim10vira 10 segundos, e quem quiser milissegundos ainda pode escrever10ms. - Reveja a decisão se o mesmo prefixo de propriedades acabar misturando campos anotados e não anotados. Nesse ponto, um
10sem sufixo no YAML pode significar segundos numa linha e milissegundos na linha de baixo. Aí volte a exigir sufixo em todos.
Em qualquer dos casos, o próximo passo é curto: escreva um teste com ApplicationContextRunner que faça o bind do valor que a aplicação usa de fato e verifique toMillis(). É o mesmo formato do suffixLessTenIsTenMillis acima, trocando o 10 pelo valor real. O lugar certo para descobrir que o timeout é 10 ms é a asserção de um teste. No log de timeout de produção, a descoberta chega tarde.