DEV Community

loading...
Cover image for Presenting Exceptions to Users

Presenting Exceptions to Users

victorrentea profile image Victor Rentea Originally published at victorrentea.ro ・8 min read

Many applications have to present the errors that happen in the application to their users in a nice human-readable form. This article covers some widely used best practices on this topic.

Before we even start, let’s make it clear:

NEVER expose 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 infer the libraries and versions that you're using, and attack you by exploiting their known weaknesses.

Then what to show our users?

Purpose of Exception Message

The simplest idea is to display the exception message string to the users. 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. Asserting message strings will lead to brittle tests, especially when you start adding concatenating user input to those messages:

throw new IllegalArgumentException("Invalid username: " + username);
Enter fullscreen mode Exit fullscreen mode

Secondly, one might argue that you're violating Model-View-Controller, 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 back 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, current row index, and any other useful debug information. That's the Catch-rethrow-with-debug-message Pattern:

throw new RuntimeException("For id: " + keyDebugId, e);
Enter fullscreen mode Exit fullscreen mode

You can even instantiate and throw back the same exception type you caught previously. I'm perfectly fine as long as you keep the original exception in the chain by passing it to the new exception constructor as its cause.

Using this pattern will include in your final exception stack trace all the useful debug information you need, allowing you to avoid breakpoint-debugging. πŸ‘ I'll explain more hints on how to avoid breakpointing in a future blog post.

At the other extreme please don't write 30+ words phrases in your exception messages. They will just distract the reader: their brain will find itself staring endlessly at that nice human-friendly phrase in the middle of that difficult code. And honestly, the exception messages are the least important things to read when browsing unknown code. Keep in mind that the first thing a developer does when debugging an exception is to click through the stack trace. So keep it simple (KISS).

Best Practice Keep your exception messages concise and meaningful. Just like comments.

To sum up, I will recommend that in most cases you keep the exception message for concise debug information for developers' eyes only. User error messages should be externalized in .properties files so you can easily adjust, correct, and use UTF-8 accents without any pain.

But then how to 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 of our application, and then distinguish between errors based on the exception type. Although fun at first, this will rapidly lead to creating hundreds of exception classes in any typical real-world applications. Clearly, that's unreasonable.

Best Practice: Only create a new exception type E1 if you use need to catch(E1) and selectively handle that particular exception type, to work-around or recover from it.

But in most applications, you almost never work-around or recover from exceptions. Instead, you typically terminate the execution of the current use-case/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 need the Exception Manual at hand every time we walk through the code. Horrible scenario! But wait!

Every time in Java the range of values is finite and pre-known, we should always consider using an enum. Let's give it a first try:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

And then every time we encounter an issue due to bad configuration, we could do 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:

// junit 4.13 or 5:
MyException e = assertThrows(MyException.class, () -> method(...));
assertThat(ErrorCode.BAD_CONFIG, e.getErrorCode());
Enter fullscreen mode Exit fullscreen mode

Okay, we've sorted how to distinguish between errors, and we've mentioned that the user exception messages should be externalized in a .properties files. But who does and when does this translation? Let's see...

The Global Exception Handler

Let's write a GlobalExceptionHandler that catches our exception and translates it into a friendly message for our users. User are typically sending HTTP requests, so we'll assume a REST endpoint in a Spring Boot application, but all other major web frameworks today offer similar functionality.

Alt Text

@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    private final MessageSource messageSource;

    public GlobalExceptionHandler(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @ExceptionHandler(MyException.class)
    @ResponseStatus // 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

The framework will pass to this @RestControllerAdvice every MyException that slips from any REST endpoint (@RequestMapping, @GetMapping...).

At this point there's sometimes a debate: where should we translate the error code: on server-side (in Java) or on the browser (in JavaScript)?

Although it's tempting to throw the problem on the frontend, translating the error codes on the backend tends to localize better the impact of adding a new error code. Indeed, why should the frontend code change when you add or remove a new error?

Plus, you might even write code to check at application startup that all the enum error codes have a translation in the .properties files. 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

I used a Spring MessageSource to read 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, eg: messages_RO.properties for RO, messages_FR.properties for fr, or default from messages.properties for any other language.

I extracted the user Locale from the Accept-Language header in the HTTP Request. In many applications however, 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:

BAD_CONFIG=Incorrect application configuration
Enter fullscreen mode Exit fullscreen mode

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 of it:

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;
    }
} 
Enter fullscreen mode Exit fullscreen mode

This will allow us to use those parameters in the messages*.properties files:

BAD_CONFIG=Incorrect application configuration. User info: {0}
Enter fullscreen mode Exit fullscreen mode

Oh, and we have to pass those arguments to the MessageSourcewhen we ask for the translation:

String userMessage = messageSource.getMessage(exception.getCode().name(), exception.getParams(), request.getLocale());
Enter fullscreen mode Exit fullscreen mode

To see the entire app running, checkout this commit, start the SpringBoot class and navigate to http://localhost:8080.

Reporting General Errors

When should we use the GENERAL error code?

When it comes to what error messages to display to users, many good developers are tempted to distinguish between as many errors they can detect. In other words, they will consider that their users need to be notified about each and every failure cause. 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". Consider carefully whether the ~developer~ user can do anything about that error or not. If not, do not bother to distinguish that particular error cause, because if you do, you'll have to make sure you continue to detect that error cause from then on. It may be simpler today, but who knows in 3 years... It's a good example of unnecessary and unforeseen long-term maintenance costs.

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 new MyException() which does the exact same thing.

We can simplify it even more, and directly throw new RuntimeException("debug message?"...). 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:

} catch (SomeException e)
    throw new RuntimeException(e);
}
Enter fullscreen mode Exit fullscreen mode

Note that I didn't log the exception before re-throwing. Doing that is actually an anti-pattern, as I will explain in a future blog post.

But look again at the code snippet above. Why in the world would do you catch that SomeException? If it were a runtime exception, why don't you let it fly-through? So SomeException must be a checked exception and you're doing that to convert it into a runtime, invisible one. If that's the case, then you have another widely used option: throw a @SneakyThrows annotation from Lombok on that method to effectively let the checked exception propagate invisibly down the call stack. You can read more about this technique in my other blog post.

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. We need to add another method:

@ExceptionHandler(Exception.class)
@ResponseStatus
public String handleAnyOtherException(Exception exception, HttpServletRequest request) {
    String userMessage = messageSource.getMessage(ErrorCode.GENERAL.name(), null, request.getLocale());
    log.error(userMessage, exception);
    return userMessage;
}
Enter fullscreen mode Exit fullscreen mode

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 around, but these are the main points.

Conclusions

  • Use concise exception messages to convey debugging information for developers
  • Catch-rethrow-with-debug-message to display more data in the stack trace
  • Only define new exception types in case you selectively catch them
  • Use an error code enum to translate errors to users
  • If needed, add the incorrect user input in the exception parameters and template your error messages
  • We implemented a Global Exception Handler in Spring to catch, log and translate all exceptions slipped from HTTP requests (+internationalized)

Discussion (0)

pic
Editor guide