Repository navigation
Description
I am experiencing a memory exhaustion event in a Spring Boot 4.0.5 web app that I have traced back to the rendering of FieldError default message values after a BindException occurs on a field with high cardinality input values. When messageSource.getMessage(fieldError, locale) is invoked on the exception's errors, the FieldError has a defaultMessage populated like
Failed to convert property value of type 'java.lang.String' to required type
'java.time.LocalDateTime' for property 'dt'; Failed to convert from type
[java.lang.String] to type [java.time.LocalDateTime] for value [asdbcsdfasdfasdf]
and eventually the MessageSourceSupport.renderDefaultMessage() method is invoked with that message and anargs value as a single element array with a DefaultMessageSourceResolvable instance in it. This eventually leads to the formatMessage() method getting called, and the logic then caches an entry in the messageFormatsPerMessage map:
protected String formatMessage(String msg, Object @Nullable [] args, @Nullable Locale locale) {
if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
return msg;
}
Map<Locale, MessageFormat> messageFormatsPerLocale = this.messageFormatsPerMessage
.computeIfAbsent(msg, key -> new ConcurrentHashMap<>());
MessageFormat messageFormat = messageFormatsPerLocale.computeIfAbsent(locale, key -> {Because the default message in my case is effectively unique per request (i.e. the current date/time) this map gets filled up with entries, until memory is exhausted.
The following Spring Boot demo app demonstrates this scenario (call http://localhost:8080/req?dt=BADLY_FORMATTED_TIMESTAMP to trigger the effect):
package com.example.demo;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.Errors;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
@SpringBootApplication
@RestController
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Autowired
private MessageSource messageSource;
public static final class Criteria {
private LocalDateTime dt;
public final LocalDateTime getDt() {
return dt;
}
public final void setDt(LocalDateTime dt) {
this.dt = dt;
}
}
// bind the request to a bean with a LocalDateTime field
@GetMapping("/req")
public Map<String, Object> req(Criteria criteria) {
return Map.of("success", true, "dt", criteria.getDt() != null ? criteria.getDt() : "N/A");
}
// return a JSON error message based on the bind error
@ExceptionHandler(BindException.class)
@ResponseBody
@ResponseStatus(HttpStatus.UNPROCESSABLE_CONTENT)
public Map<String, Object> handleBindException(BindException e, WebRequest request, Locale locale) {
return Map.of("success", false, "message", generateErrorsMessage(e, locale, messageSource));
}
public static String generateErrorsMessage(Errors e, Locale locale, MessageSource msgSrc) {
String msg = "Bind error";
if ( msgSrc != null && e != null && e.hasErrors() ) {
StringBuilder buf = new StringBuilder();
for ( ObjectError error : e.getAllErrors() ) {
if ( !buf.isEmpty() ) {
buf.append(" ");
}
// the next getMessage() call caches unique default message values based on the request
// parameter value (in MessageSourceSupport messageFormatsPerMessage field) causing
// an OOM error eventually when the request parameter cardinality is high
buf.append(msgSrc.getMessage(error, locale));
}
msg = buf.toString();
}
return msg;
}
}I was looking for a way to avoid having the FieldError default messages not end up in the cache.
Activity
msqr commentedon Apr 7, 2026
Here is a basic jMeter test plan I used to quickly exhaust memory (I limited the app's memory to
-Xmx32mto quickly trigger this):bind-exception-ram-exhaustion.jmx.xml
Work around spring-projects/spring-framework/issues/36609 OOM error.
bclozel commentedon Apr 7, 2026
Can you elaborate please?
Which observation/metric is causing the high cardinality problem and on which tag? I don't think Spring Framework is using the exception message as an observation keyvalue, maybe I'm missing something?
msqr commentedon Apr 7, 2026
Hey @bclozel this isn't related to metrics or observations, just
MessageSourceresolutions onFieldErrorobjects, when the@ExceptionHandlerhandles theBindException. TheMessageSourceSupportclass is caching everyFieldError.defaultMessagevalue as a key in its internalmessageFormatsPerMessagemap, and since thesedefaultMessagevalues are arbitrary the number of entries in the map grows in an uncontrolled fashion.I guess this isn't even technically web related, it just happens to be happening in one of my web applications, triggered by
BindExceptionon invalid request parameter values.bclozel commentedon Apr 7, 2026
Thanks @msqr I understand now. I coudln't see the screenshot earlier. We can certainly enforce bounds to that cache.
1 remaining item
[-]Memory exhaustion from high cardinatlity BindException FieldError default messages cached in MessageSourceSupport.messageFormatsPerMessage[/-][+]Memory exhaustion from high-cardinality FieldError default messages cached in MessageSourceSupport[/+][-]Memory exhaustion from high-cardinality FieldError default messages cached in MessageSourceSupport[/-][+]Cache pollution from high-cardinality FieldError default messages in MessageSourceSupport[/+]jhoeller commentedon Apr 7, 2026
This turns out to be a cache pollution bug where we unnecessarily populate the MessageFormat cache for binding errors whose default messages come from an original exception message which will never contain MessageFormat placeholders. As a consequence, we should be able to avoid using that cache for binding errors to begin with, similar to how we do it for Bean Validation messages already (#22761). To be fixed for 7.0.7 and 6.2.18.
msqr commentedon Apr 7, 2026
Thanks for that... and for the sake of completeness here is a simplified demonstration of the bug, without web stuff:
Avoid MessageFormat rendering for exception messages in binding errors
Avoid MessageFormat rendering for exception messages in binding errors
jhoeller commentedon Apr 8, 2026
This is available in the latest 7.0.7 and 6.2.18 snapshots now. Feel free to give it an early try!
Work around spring-projects/spring-framework/issues/36609 OOM error.