Todos os artigos

// Knowledge.log — 技術記事

Duration no @ConfigurationProperties: o 10 no YAML que vira 10 milissegundos

No Spring Boot 4.1.1 com JDK 25, timeout: 10 num Duration vira 10 ms e a app sobe. Veja 10s, PT10S, @DurationUnit e o que falha no bind.

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:

  1. Um long comum, que usa milissegundos como unidade padrão, a menos que o campo tenha @DurationUnit.
  2. O formato ISO-8601 do próprio java.time.Duration, como PT10S.
  3. Um formato legível com valor e unidade juntos, como 10s. As unidades aceitas são ns, us, ms, s, m, h e d.

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:

EntradaCampoContextoResultado
app.timeout=10, YAML timeout: 10, YAML timeout: "10"Duration sem anotaçãosobetoMillis() == 10
app.timeout=10s, YAML timeout: 10sDuration sem anotaçãosobetoMillis() == 10000, toSeconds() == 10
app.timeout=PT10S, YAML timeout: PT10SDuration sem anotaçãosobetoMillis() == 10000, toSeconds() == 10
app.seconds.timeout=10@DurationUnit(ChronoUnit.SECONDS)sobetoMillis() == 10000
app.seconds.timeout=10ms@DurationUnit(ChronoUnit.SECONDS)sobetoMillis() == 10 (o sufixo vence)
app.timeout=10seconds, 10 sec, fooDuration sem anotaçãofalha no refreshhasFailed()
YAML timeout: 10secondsDuration sem anotaçãoSpringApplication não sobeFailureAnalysis no bind de app.timeout
app.valid.nested.timeout=10@Valid aninhado com @NotNull DurationsobetoMillis() == 10
app.valid.nested.timeout omitido@Valid aninhado com @NotNull Durationfalha no refreshviolaçã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: 10s ou PT10S. Os dois deram toMillis() == 10000 no 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. Assim 10 vira 10 segundos, e quem quiser milissegundos ainda pode escrever 10ms.
  • Reveja a decisão se o mesmo prefixo de propriedades acabar misturando campos anotados e não anotados. Nesse ponto, um 10 sem 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.

javaspring-boot

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos