Spring Boot: implementing a startup failure analyzer
If you have been using Spring Boot for a while, sometimes you may have encountered a failure when launching an application. One common and frequent case is when the application is already running, and you try to re-run the application (or another application that uses the same server port). In this case, Spring Boot logs a clean and informative message about the server port that is already in use. Would you like to produce a similar failure message? This article shows how to implement a custom startup failure analyzer.
Introduction
First of all, I want to show you what is a typical startup failure message. I’m sure you’ve seen such a log at least once in your developments with Spring Boot.
……… other logs ………
2023-02-06 18:09:52.511 WARN 7076 --- [ restartedMain] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.context.ApplicationContextException: Failed to start bean 'webServerStartStop'; nested exception is org.springframework.boot.web.server.PortInUseException: Port 8080 is already in use
2023-02-06 18:09:52.514 INFO 7076 --- [ restartedMain] o.apache.catalina.core.StandardService : Stopping service [Tomcat]
2023-02-06 18:09:52.526 INFO 7076 --- [ restartedMain] ConditionEvaluationReportLoggingListener :
Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2023-02-06 18:09:52.542 ERROR 7076 --- [ restartedMain] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
Web server failed to start. Port 8080 was already in use.
Action:
Identify and stop the process that's listening on port 8080 or configure this application to listen on another port.
The above log shows a specific message with a description and an action that should help solve the problem. It is not a trivial stack trace log, which is only sometimes useful! This error message is produced by a startup failure analyzer, represented in Spring Boot by the FailureAnalyzer
interface described in the next section.
The FailureAnalyzer interface
FailureAnalyzer
is a simple, functional interface that has the following definition:
@FunctionalInterface
public interface FailureAnalyzer {
FailureAnalysis analyze(Throwable failure);
}
The analyze
method receives an exception object and must decide whether the exception can be handled. If the exception cannot be handled, the method must return null
. Otherwise, it must analyze the failure and return an object of type FailureAnalysis
.
FailureAnalysis
is a simple bean class that contains only three pieces of information: a description (String
), an action (String
), and a cause (Throwable
). Description and action are the two information reported in the startup failure message.
Spring Boot already provides several implementations of a failure analyzer. For example, the message “Web server failed to start. Port nnnn was already in use.” is produced by the PortInUseFailureAnalyzer
that handles exceptions of type PortInUseException
. And clearly, you can also implement your startup failure analyzers.
When you implement a failure analyzer, you have mainly two options:
-
you can implement the
FailureAnalyzer
interface directlypublic class MyFailureAnalyzer implements FailureAnalyzer { /*...*/ }
-
you can extend the
AbstractFailureAnalyzer<T extends Throwable>
abstract classpublic class MyFailureAnalyzer extends AbstractFailureAnalyzer<SomeException> { /*...*/ }
Both approaches are straightforward in general. However, the second has the added benefit of not needing to do any checks or type cast. The second option is greatly explained in Step 3 of my example case, where you can see a more concrete and realistic implementation of a failure analyzer.
Example case
I used a simple scenario to show a concrete example of a custom startup failure. You certainly know that the java.lang.Runtime
class has the maxMemory()
method to retrieve the maximum amount of memory (heap space) that the JVM can use. This amount is a value in bytes; for example, 134217728 means 128 Megabytes of maximum heap space.
This example case shows how to check the max memory at startup and generate a custom startup failure message if the max memory value is lower than a configured minimum threshold.
I don’t know if this scenario could be applicable in real applications, but at least it is reasonable for this article.
Step 1: Choosing the exception
As you have seen before, the goal of a failure analyzer is to check if it can handle an exception and, if so, to provide a more meaningful description of the problem. Therefore, an exception must be thrown somewhere during the application startup if you want to cause a startup failure with a description/action message. This exception can be any that already exists (e.g., from a framework or a library) or an exception developed by you.
For this article, I created the following custom exception named InsufficientMaxMemoryException
:
package your.pkgname;
public class InsufficientMaxMemoryException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final long actualMaxMemory;
private final long minimumMaxMemory;
public InsufficientMaxMemoryException(long actualMaxMemory, long minimumMaxMemory) {
super("Insufficient max memory: " + actualMaxMemory);
this.actualMaxMemory = actualMaxMemory;
this.minimumMaxMemory = minimumMaxMemory;
}
public long getActualMaxMemory() {
return actualMaxMemory;
}
public long getMinimumMaxMemory() {
return minimumMaxMemory;
}
}
Note that the exception has an extra state to keep the “actual” and the “minimum” max memory value. These pieces of information are not strictly required for the exception object itself, but they will come in handy for the failure analyzer implementation.
Step 2: Checking the max memory at startup
Now we have to check the actual max memory at startup. Where? How? A Spring Boot application has a complex life cycle, and the Spring Framework can emit several types of events to registered listeners, especially during the startup phase. The Spring Boot reference documents all the predefined events: Application Events and Listeners.
My main goal is to check the max memory as soon as possible but not too soon to avoid problems or restrictions. Therefore, I used ApplicationEnvironmentPreparedEvent
. This event is fired after the Environment
has been defined but before the context is created. The Environment
is very useful because I want to read the “minimum” max memory value from the application.properties
configuration file.
In Spring Framework/Boot, any Spring bean can receive events by implementing the ApplicationListener
<E extends ApplicationEvent>
interface, where E
stands for the specific event type you want to handle. For example, the following class handles the ApplicationEnvironmentPreparedEvent
with the necessary logic to check the max memory value.
package your.pkgname;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.context.ApplicationListener;
public class MaxMemoryChecker implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
@Override
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
long actualMaxMemory = Runtime.getRuntime().maxMemory();
Long minimumMaxMemory = event.getEnvironment().getProperty("app.minimumMaxMemory", Long.class);
if (minimumMaxMemory != null && actualMaxMemory < minimumMaxMemory) {
throw new InsufficientMaxMemoryException(actualMaxMemory, minimumMaxMemory);
}
}
}
Configuration
There is, however, an important issue to consider. The ApplicationEnvironmentPreparedEvent
is fired before the context is created. It means that when the event is fired, there is no Spring bean, dependency injection, autowiring, etc. Therefore, we cannot use @Component
to make MaxMemoryChecker
a Spring bean!
This type of event listener (like some few others) can only be registered in either of the following two ways:
- Using
SpringApplication.addListeners(
…)
or alternativelySpringApplicationBuilder.listeners(
…)
. - Using an entry in the
META-INF/spring.factories
file. Spring reads this file to know which particular extensions need to be registered.
Since the latter option is more generic and also independent of how the application is built, packaged, and run, this is the solution I generally choose. Thus, in a Maven/Gradle project, create the file <your-project>
/src/main/resources/META-INF/spring.factories
with the following content:
File: spring.factories
org.springframework.context.ApplicationListener = your.pkgname.MaxMemoryChecker
The part before “=” is fixed to org.springframework.context.ApplicationListener
for registering application listeners. The part after “=” is the fully qualified name of your listener implementation class.
Since MaxMemoryChecker
uses a configuration property, we must also configure the app.minimumMaxMemory
value in application.properties
(<your-project>
/src/main/resources/application.properties
in Maven/Gradle projects).
File: application.properties
# requires minimum 512 MB of max memory
app.minimumMaxMemory = 536870912
You can use a .yaml
format instead of the .properties
format (see Working With YAML).
Partial test
At this point, if you try to start the application using -Xmx128m
as the JVM option (see Extra Options for Java), you should obtain a similar result:
18:13:41.172 [Thread-0] DEBUG org.springframework.boot.devtools.restart.classloader.RestartClassLoader - Created RestartClassLoader org.springframework.boot.devtools.restart.classloader.RestartClassLoader@7ecf6d3
2023-02-06 18:13:41.662 ERROR 11320 --- [ restartedMain] o.s.boot.SpringApplication : Application run failed
your.pkgname.InsufficientMaxMemoryException: Insufficient max memory: 134217728
at your.pkgname.MaxMemoryChecker.onApplicationEvent(MaxMemoryChecker.java:13) ~[classes/:na]
at your.pkgname.MaxMemoryChecker.onApplicationEvent(MaxMemoryChecker.java:1) ~[classes/:na]
at org.springframework.context.event.SimpleApplicationEventMulticaster.doInvokeListener(SimpleApplicationEventMulticaster.java:176) ~[spring-context-5.3.25.jar:5.3.25]
at org.springframework.context.event.SimpleApplicationEventMulticaster.invokeListener(SimpleApplicationEventMulticaster.java:169) ~[spring-context-5.3.25.jar:5.3.25]
……… long stack trace ………
The application effectively fails at startup because the JVM was started with 128 MBytes of max memory, which is lower than the minimum expected. However, you see an ugly stack trace. We need to do another final step, the implementation of a failure analyzer.
Step 3: Implementing the startup failure analyzer
You must implement a failure analyzer to see a more descriptive failure message instead of the ugly stack trace shown in the previous section. The following class extends AbstractFailureAnalyzer
to handle exceptions of type InsufficientMaxMemoryException
:
package your.pkgname;
import java.util.Locale;
import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
import org.springframework.boot.diagnostics.FailureAnalysis;
public class MaxMemoryFailureAnalyzer extends AbstractFailureAnalyzer<InsufficientMaxMemoryException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, InsufficientMaxMemoryException imme) {
return new FailureAnalysis(
String.format(Locale.ENGLISH, "The actual max memory is insufficient: %,d bytes", imme.getActualMaxMemory()),
String.format(Locale.ENGLISH, "Configure the max memory to be at least: %,d bytes", imme.getMinimumMaxMemory()),
imme);
}
}
This code, at first glance, might look like black magic. Who checks the exception? How is this check performed? The answer is straightforward: AbstractFailureAnalyzer
does some very clever things! First of all, you should notice that the class extends a generic type:
extends AbstractFailureAnalyzer
<InsufficientMaxMemoryException>
When a class extends a generic type (or implements a generic interface), the information about the parameterization (which is <InsufficientMaxMemoryException>
in our case) is maintained and available at runtime. AbstractFailureAnalyzer
uses Java reflection to find out this type, then checks if the exception object (or recursively the exception “cause”) is an instance of that type. If a match exists, AbstractFailureAnalyzer
performs an “unchecked” cast of the exception object and calls the specialized method analyze(Throwable, T)
.
Overall, we do not have to do any checks, loops, or type cast. AbstractFailureAnalyzer
already performs these operations. If you want to understand better, look at the source code of AbstractFailureAnalyzer
(here on GitHub: AbstractFailureAnalyzer.java) because it is fascinating and instructive.
At this point, there is still one small thing to do. A failure analyzer must be registered using an entry in the META-INF/spring.factories
file. Thus, this file should now look like the following:
File: spring.factories
org.springframework.context.ApplicationListener = your.pkgname.MaxMemoryChecker
org.springframework.boot.diagnostics.FailureAnalyzer = your.pkgname.MaxMemoryFailureAnalyzer
The part before “=” in the second line is fixed to org.springframework.boot.diagnostics.FailureAnalyzer
for registering failure analyzers.
Final test
If you run the application after the three implementation steps, again with the -Xmx128m
JVM option, you should see the following result:
18:17:39.630 [Thread-0] DEBUG org.springframework.boot.devtools.restart.classloader.RestartClassLoader - Created RestartClassLoader org.springframework.boot.devtools.restart.classloader.RestartClassLoader@6447b747
2023-02-06 18:17:40.082 ERROR 17080 --- [ restartedMain] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
The actual max memory is insufficient: 134,217,728 bytes
Action:
Configure the max memory to be at least: 536,870,912 bytes
The above log is the expected result of the failure analyzer implemented in this article. As you can see, the failure message is much more descriptive and pleasant! I also used the String formatting feature (the String.format
method, available since Java 5) to print a “localized” message in English. In particular, I used the %,d
specifier to format the number with groupings. This custom formatting is not strictly necessary but makes the message more readable.
Conclusions
In this article, you have seen a particular feature of Spring Boot, how to implement a custom startup failure analyzer. This technique is not a standard and recurrent task; however, this article helps better understand how Spring Boot works.
Here is just a recap of the three steps:
- Choose or define an exception that will cause the startup failure.
- Throw that exception conditionally during the startup phase. You can do this, for example, in a listener, in a
@PostConstruct
method of a Spring bean, or any other code executed during startup. - Implement a failure analyzer and register it using the
META-INF/spring.factories
file.