-
Notifications
You must be signed in to change notification settings - Fork 38.9k
Memory exhaustion from high cardinatlity BindException FieldError default messages cached in MessageSourceSupport.messageFormatsPerMessage #36609
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.