Providing simple but meaningful messages while handling errors in web services should be the preferred approach. This way, we will not expose any internal information about the causes of the errors and we will help the API client properly respond to issues.
When an error occurs, the default Spring Boot behavior is to return a stack trace. The main downside of this approach is that it might leak useful information about our implementation to a potential attacker. On the other hand, stack traces — unlike meaningful messages — are extremely important for debugging.
"Stack traces can tell the developer more about the sequence of events that led to a failure, as opposed to merely the final state of the software when the error occurred. Unfortunately, the same information can be useful to an attacker. The sequence of class names in a stack trace can reveal the structure of the application as well as any internal components it relies on." — Semmle
The main goal of the approach we are going to present is to automatically adapt error messages according to the current deployment environment. In the staging environment, a simple and meaningful message, as well as a stack trace, will be returned. In the production environment, we will expose only the first one.
Let's see how this can be achieved in Spring Boot and Kotlin.
Handling Different Deployment Environments
To understand which deployment environment we are in, we will use a custom environment variable called ENV
. We assume that its value is PRODUCTION
in the production environment and STAGING
in the staging environment. In Kotlin, environment variables value can be retrieved as follows:
System.getenv("env_name")
Defining a Custom Error Class
We are going to define a custom class called ErrorMessage
to represent API errors. The goal of this class is to wrap exceptions in a nice JSON representation to make life easier for API clients.
We can implement such a class as follows:
class ErrorResponse(
status: HttpStatus,
val message: String,
var stackTrace: String? = null
) {
val code: Int
val status: String
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MM-dd-yyyy hh:mm:ss")
val timestamp: Date
init {
timestamp = Date()
code = status.value()
status = status.name()
}
}
As you can notice, the stackTrace
attribute is nullable and optional since it will be used in the staging environment only.
Spring Boot Environment-Based Error Handling
Using @ExceptionHandler
and @ControllerAdvice
is one of the main methods to handle errors since Spring Boot 3.2.
"
@ExceptionHandler
is a Spring annotation that provides a mechanism to treat exceptions that are thrown during execution of handlers (Controller operations). This annotation, if used on methods of controller classes, will serve as the entry point for handling exceptions thrown within this controller only. Altogether, the most common way is to use@ExceptionHandler
on methods of@ControllerAdvice
classes so that the exception handling will be applied globally or to a subset of controllers." — Toptal
Thanks to them, we can build a global error handling component called ControllerExceptionHandler
. Its goal is to catch exceptions and wrap them in ErrorMessage
objects, which will be serialized into JSON and sent back to API clients. It also implements the environment-based logic, as shown below:
@ControllerAdvice
class ControllerExceptionsHandler {
@ExceptionHandler(
ConstraintViolationException::class,
HttpClientErrorException.BadRequest::class,
MethodArgumentNotValidException::class,
MissingServletRequestParameterException::class,
IllegalArgumentException::class
)
fun constraintViolationException(e: Exception): ResponseEntity<ErrorResponse> {
return generateErrorResponse(HttpStatus.BAD_REQUEST, "Bad request", e)
}
@ExceptionHandler(AuthorizationException::class)
fun unauthorizedException(e: Exception): ResponseEntity<ErrorResponse> {
return generateErrorResponse(HttpStatus.FORBIDDEN, "You are not authorized to do this operation", e)
}
@ExceptionHandler(AuthenticationException::class)
fun forbiddenException(e: Exception): ResponseEntity<ErrorResponse> {
return generateErrorResponse(HttpStatus.UNAUTHORIZED, "You are not allowed to do this operation", e)
}
@ExceptionHandler(
EntityNotFoundException::class,
NoSuchElementException::class,
NoResultException::class,
EmptyResultDataAccessException::class,
IndexOutOfBoundsException::class,
KotlinNullPointerException::class
)
fun notFoundException(e: Exception): ResponseEntity<ErrorResponse> {
return generateErrorResponse(HttpStatus.NOT_FOUND, "Resource not found", e)
}
@ExceptionHandler(
Exception::class
)
fun internalServerErrorException(e: Exception): ResponseEntity<ErrorResponse> {
return generateErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Generic internal error", e)
}
private fun generateErrorResponse(
status: HttpStatus,
message: String,
e: Exception
): ResponseEntity<ErrorResponse> {
// converting the exception stack trace to a string
val sw = StringWriter()
val pw = PrintWriter(sw)
e.printStackTrace(pw)
val stackTrace = sw.toString()
// example: logging the stack trace
// log.debug(stackTrace)
// environment-based logic
val stackTraceMessage =
when (System.getenv("ENV").toUpperCase()) {
"STAGING" -> stackTrace // returning the stack trace
"PRODUCTION" -> null // returning no stack trace
else -> stackTrace // default behavior
}
return ResponseEntity(ErrorResponse(status, message, stackTraceMessage), status)
}
}
The most important method is generateResponse
, which converts the exception stack trace to a String (as described in this course) and passes it to the ErrorResponse
constructor only when not in the production environment.
Example of the same error response in the production environment:
{
"timestamp": "09-21-2020 15:51:17",
"code": 404,
"status": "NOT_FOUND",
"message": "Resource not found"
}
And staging:
{
"timestamp": "09-21-2020 15:51:17",
"code": 404,
"status": "NOT_FOUND",
"message": "Resource not found",
"stackTrace": "Exception in thread \"main\" kotlin.KotlinNullPointerException at com.test.ApplicationKt.throwNullPointerException(Application.kt:8) at com.test.ApplicationKt.throwNullPointerException$default(Application.kt:7) at com.test.ApplicationKt.main(Application.kt:4)"
}
This way, the message
attribute can be used to explain what happened to users. In the staging environment, developers can understand why the error occurred thanks to the stackTrace
attribute.
Conclusion
Not being able to understand why an error occurred can be frustrating! Exposing detailed error descriptions can be dangerous. There is a way to cautiously face this issue, however. Using the approach above, when an error occurs, users will always see a simple message. But when in the staging environment, developers will have everything they need to debug it.
That's all, folks! I hope this helps you handle errors in Spring Boot and Kotlin.
The post "Environment-Based Error Handling With Spring Boot and Kotlin" appeared first on Writech.
Top comments (0)