Presenting Exceptions to Users
Many applications want to present errors to their users in a nice human-readable form. This article reviews some widely used best practices on this topic.
Join the DZone community and get the full member experience.
Join For FreeThis article was originally posted at https://victorrentea.ro/blog/presenting-exceptions-to-users/
The Author: Victor Rentea is a Java Champion, Independent Trainer, and founder of one of the largest developer communities in his country. After training thousands of developers in dozens of companies throughout the world, he now shares best practices with hundreds of teams. You can read more about him on victorrentea.ro.
Many applications want to present errors to their users in a nice human-readable form. This article reviews some widely used best practices on this topic.
Before we even start, let's make it clear:
NEVER expose exception stack traces in your API responses.
It's not only ugly but dangerous from a security point of view. Based on the line numbers in that stack trace, an attacker might find out the libraries and versions you're using and attack you by exploiting their known vulnerabilities.
Then what can we show our users?
The Purpose of Exception Messages
The simplest idea is to display to the users the exception message string. For simple applications, this might work, but in medium-large applications, two issues arise.
First, you might want to unit-test that a certain exception was thrown. But asserting message strings will lead to fragile tests, especially when you start concatenating user input to those messages:
throw new IllegalArgumentException("Invalid username: " + username);
Secondly, one might argue that you're violating the Model-View-Controller paradigm, because you're mixing diverging concerns in the same code: formatting the user-visible message and detecting the business error condition.
Best Practice: Use the exception message to report any technical details about the error that might be useful for developers investigating the exception.
You can go as far as to catch an exception in a method x()
and re-throw it wrapped in another exception with a useful message that captures the key debug information from that method x()
. For example, you might include method arguments, any interesting id, its current row index, and any other useful debugging information. This is the catch-rethrow-with-debug-message pattern:
xxxxxxxxxx
throw new RuntimeException("For id: " + keyDebugId, e);
You can even instantiate and throwback the same exception type you caught previously.
I'm okay as long as you keep the original exception in the chain by passing it to the new exception constructor as its cause
. This way, the resulting exception stack trace will contain all the debug information you need to analyze the exception, allowing you to avoid breakpoint-debugging. I'll explain more about how to avoid breakpoint-based investigation in a future blog post.
At the other extreme, please don't write 30+ word phrases in your exception messages. They will only distract the reader: they will find themselves staring endlessly at a nice human-friendly phrase in the middle of that difficult code. But honestly, the exception messages are the least important elements when browsing unknown code. Furthermore, the first thing a developer does when debugging an exception is to click through the stack trace. So keep your exception messages short (KISS).
Best Practice: Keep your exception messages concise and meaningful. Just like comments.
To sum up, I generally recommend that you use exception messages to report concise debugging information for developers' eyes. User error messages should be externalized in .properties
files such that you can easily adjust, correct them, and use UTF-8 accents without any pain.
But then how can we distinguish between different error causes? In other words, what should be the translation key in that properties file?
Different Exception Subtypes
It might be tempting to start creating multiple exception subtypes, one for each business error in our application, and then distinguish between errors based on the actual throw exception type. Although fun at first, this will rapidly lead to creating hundreds of exception classes in any typical real-world application. Clearly, that's unreasonable.
Best Practice: Only create a new exception type
E1
if you need tocatch(E1)
and selectively handle that particular exception type, to work-around or recover from it.
But in most applications, you rarely work-around or recover from exceptions. Instead, you typically terminate the current request by allowing the exception to bubble up to the outer layers.
Error Code Enum
The best solution to distinguish between your non-recoverable error conditions is using an error code. Not the exception message, nor the exception type.
Should that error code be an int
?
If it were, we would then need the Exception Manual close-by every time we wanted to walk through the code. Horrible scenario!
But wait!
Every time the range of values in Java is finite and pre-known, we should always consider using an enum
. Let's give it a try:
xxxxxxxxxx
public class MyException extends RuntimeException {
public enum ErrorCode {
GENERAL,
BAD_CONFIG
}
private final ErrorCode code;
public MyException(ErrorCode code, Throwable cause) {
super(cause);
this.code = code;
}
public ErrorCode getCode() {
return code;
}
}
And then every time we encounter an issue due to bad configuration, we could throw new MyException(ErrorCode.BAD_CONFIG, e);
.
Unit tests are also more robust when using enum
for Error Codes, compared to matching the String message:
xxxxxxxxxx
// junit 4.13 or 5: MyException e = assertThrows(MyException.class, () -> testedMethod(...)); assertEquals(ErrorCode.BAD_CONFIG, e.getErrorCode());
Okay, we sorted out how to distinguish between individual errors, and we mentioned that the user exception message should be externalized in the .properties
files. But when and how does this translation happen? Let's see...
The Global Exception Handler
Providing the team with a global safety net that correctly catches, logs, and reports any unhandled exception should be a key concern for any technical lead.
Developers should never be afraid to let go of a runtime exception.
Let's write a GlobalExceptionHandler
that catches our exception, logs it, and then translates it into a friendly message for our users. UIs typically send HTTP requests, so we'll assume a REST endpoint in a Spring Boot application, but all other major web frameworks today offer similar functionality (Quarkus, Micronaut, JAXRS).
xxxxxxxxxx
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
MyException.class) (
// by default returns 500 error code
public String handleMyException(MyException exception, HttpServletRequest request) {
String userMessage = messageSource.getMessage(exception.getCode().name(), null, request.getLocale());
log.error(userMessage, exception); return userMessage;
}
}
The framework will pass every MyException
that slips unhandled from any REST endpoint (@RequestMapping
, @GetMapping
...) to this @RestControllerAdvice
.
At this point, I sometimes face a dilemma: where should we translate the error code? On the server-side (in Java) or in the browser (in JavaScript)?
Although it's tempting to throw the problem out on the front-end, translating the error codes on the backend tends to better localize the impact of adding a new error code. Indeed, why should the front-end code need to change when you add or remove a new error?
Plus, you might even write code to check that all the enum error codes have a corresponding translation in the .properties
files at application startup. That's why I will translate the error on the backend and send user-friendly messages in the HTTP responses.
My Preference: translate the error codes on the backend.
In the code above, I used a Spring MessageSource
to look up the user message corresponding to the error code of the caught MyException
. Based on the user Locale, the MessageSource
determines what file to read from, e.g.: messages_RO.properties
for RO, messages_FR.properties
for fr, or defaults to messages.properties
for any other language.
I extracted the user Locale from the Accept-Language header in the HTTP Request. However, in many applications, the user language is read from the user profile instead of the incoming HTTP request.
In src/main/resources/messages.properties
we store the translations:
xxxxxxxxxx
BAD_CONFIG=Incorrect application configuration
That's it! Free internationalization for our error messages.
There's a minor problem left, though. What if we want to report some user input causing the error back to the UI? We need to add parameters to MyException
. After adding other convenience constructors, here's the complete code:
xxxxxxxxxx
public class MyException extends RuntimeException {
public enum ErrorCode {
GENERAL,
BAD_CONFIG
}
private final ErrorCode code;
private final Object[] params;
// canonical constructor
public MyException(String message, ErrorCode code, Throwable cause, Object... params) {
super(message, cause);
this.code = code;
this.params = params;
}
// 6 more overloaded constructors for convenience
public ErrorCode getCode() {
return code;
}
public Object[] getParams() {
return params;
}
}
This will allow us to use those parameters in the messages*.properties
files:
xxxxxxxxxx
BAD_CONFIG=Incorrect application configuration. User info: {0}
Oh, and of course we have to pass those arguments to the MessageSource
when we ask for the translation:
xxxxxxxxxx
String userMessage = messageSource.getMessage(
exception.getCode().name(),
exception.getParams(), // THIS
request.getLocale());
To see the entire app running, check out this commit, run the SpringBoot
class and navigate to http://localhost:8080.
Reporting General Errors
When should we use the ErrorCode.GENERAL
?
When it comes to what error cases to report to users, many good developers are tempted to distinguish between as many errors as they can detect. In other words, they think that their users need to be aware of the reason for every failure. Although putting yourself in the shoes of your users is usually a good practice, in this case, it's a trap.
You should generally prefer displaying an opaque general-purpose error message, like "Internal Server Error, please check the logs for error id = UUID". Consider carefully whether the user (NOT developer) can do anything about that error or not. If they are helpless, do not bother to distinguish that particular error cause. If you do, you might have to make sure you continue to detect that error cause from then on. It may be simple today, but who knows, three years from now you could end up wondering the $1M question: "Is that code really necessary?" It's a good example of unnecessary and unplanned developer-induced feature creep.
Best Practice: Avoid displaying the exact error cause to your users unless they can do something to fix it.
Honestly, if the application configuration is corrupted, can the user really fix that? Probably not. Therefore we should use new MyException(ErrorCode.GENERAL)
or another convenience constructor overload like new MyException()
which does the exact same thing.
We can simplify it even more and directly throw new RuntimeException("debug message?", e)
. Sonar may not be very happy about it, but it's the simplest possible form.
But what if there's no additional debug message to add to the new exception? Then your code might look like this:
xxxxxxxxxx
} catch (SomeException e) throw new RuntimeException(e); }
Note that I didn't log the exception before re-throwing. Doing that is actually an anti-pattern, as I will explain in another blog post.
But look again at the code snippet above. Why in the world would you catch(SomeException)
? If it were a runtime exception, why not let it fly-through? Therefore, SomeException
must be a checked exception, and you're doing that to convert it into a runtime, invisible exception. If that's the case, then there's another widely used option for you: add a @SneakyThrows
annotation from Lombok on that method and let the checked exception propagate invisibly down the call stack.
One last thing: we need to enhance our Global Exception Handler to also catch any other Exception
that slips from a RestController
method, like the infamous NullPointerException
. Let's add another method to do that:
xxxxxxxxxx
Exception.class) (
public String handleAnyOtherException(Exception exception, HttpServletRequest request) {
String userMessage = messageSource.getMessage(ErrorCode.GENERAL.name(), null, request.getLocale());
log.error(userMessage, exception);
return userMessage;
}
Note that we used a similar code to the previous @ExceptionHandler
method to read the user error messages from the same .properties
file(s). You can do a bit more polishing, but these are the main points.
You can find the code on this Git Repo.
Conclusions
- Use concise exception messages to convey debugging information for developers.
- Consider using the catch-rethrow-with-debug-message pattern to display more data in the stack trace.
- Only define new exception types if you selectively catch them.
- Use an error code enum to translate errors to users.
- If needed, add the incorrect user input as exception parameters and use them in your error messages.
- We implemented a Global Exception Handler in Spring to catch, log, and translate all exceptions slipped from HTTP requests.
If you liked this article, I posted a whole series about exception handling in Java.
Published at DZone with permission of Victor Rentea. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments