Why do we need error handling?
Error! It is a well-known creature by users of every application developed in the world and by every I mean it :D It's an inseparable part of any application - so let's face it!
We cannot avoid errors since not everything is under the control of developers and not everything is perfect in the world. To have a robust application we need to have a good error handling solution in place.
What is a proper error handling solution?
Every solution is developed to solve a problem. So to have a proper solution we need to know the problem properly - trivial, right? Now let's have a closer look at what we want to achieve with our error handling solution.
One important goal of every application is to keep its users happy even if there is an error.
if you agree continue;
Different types of users interact with the application and each type has their own expectations when an error occurs. Keeping them all happy means that we need to meet all those expectations (don't panic!). Let's break it down.
End users
Here is what I'd expect as an end user:
- If an error occurs I expect to get a clear, easy-to-understand yet descriptive error message without any unneeded technical details.
- If I can do something to resolve the problem I'd appreciate any hint of course.
- Otherwise, I want to be able to easily report the error or get support from the help desk team.
- I don't like to be alone with the error!
Help desk team
As a member of the Help Desk team, I also have expectations!
- I expect to have categories for errors so that I can easily find the resolution.
- I'd appreciate it if we could share the same language with end users! Believe me, it's hard to understand someone that's talking in another language.
- I need to know if I can resolve the problem or if I should forward it to the development/maintenance team.
Development or maintenance team
Our expectations? That's easy! We only have one expectation!
- We would like to have everything! Of course related to the error. The more we know, the better :D
Solution
Now that we know what the expectations are, we can get into the fun part.
The concepts we've discussed so far were generic and language agnostic. Now we need to get concrete so that I can explain the solution that we're using in some of our projects in Cloudflight.
I'll describe the solution in the context of a web application developed using Kotlin and Spring Boot but feel free to tweak it based on your context and needs.
Custom application level exception
We need to differentiate errors related to the business logic from other errors like network or database level errors so that we could only provide end users with relevant information.
We create a base exception class named ApplicationException
. Whenever something is going wrong we throw an instance of this exception or any of its subclasses. You can define as many subclasses as you want!
open class ApplicationException(
open val code: String,
val httpStatus: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
override val cause: Throwable?,
override val message: String,
) : RuntimeException()
open class ApplicationNotFoundException(
override val code: String,
override val cause: Throwable? = null,
override val message: String,
) : ApplicationException(code, HttpStatus.NOT_FOUND, cause, message)
open class ApplicationBadRequestException(
override val code: String,
override val cause: Throwable? = null,
override val message: String = "",
) : ApplicationException(code, HttpStatus.BAD_REQUEST, cause, message)
...
code
: This will be used as the error category.
- End users could use it to get help from the help desk team. It's the common language between the end users and the help desk team!
- Help desk team could use it to look up the possible resolution for the error from a prefilled table mapping error codes to the possible resolutions.
httpStatus
: This will be used to set the HTTP status based on the type of error.
cause
: This is the nested exception if any exists.
message
: This is ... you guessed it! "clear, easy to understand yet descriptive error message without any unneeded technical details"
Unified error model
Now we can differentiate our application level exceptions from other exceptions. We also need to define a class describing an error. All exceptions will be translated to this model before leaving the application. This way we would have a unified error response model and our application clients can treat all errors in the same way. Let's call it APIErrorDTO
data class APIErrorDTO(
val id: String,
val code: String,
val message: String,
val details: List<ErrorDetailDTO>,
)
data class ErrorDetailDTO(
val code: String,
val message: String,
)
id
: This will uniquely identify an error.
End users may be provided with a button
report error
which will create a ticket out of the error with all the details attached to it.Development team can also use it to find the logs and all the other details related to the error.
code
and message
: Those comes from the Application level exception.
details
: If there are nested Application errors, code and message of them will be added to details so that users get more information on them.
Global exception handling
In Spring boot you can handle almost all the exceptions globally in one place using @ControllerAdvice
annotation. We will catch the exceptions here and convert them to an APIErrorDTO
instance.
@ControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
...
@ExceptionHandler(ApplicationException::class)
fun handleApplicationException(exception: ApplicationException, request: WebRequest): ResponseEntity<APIErrorDTO> {
val apiErrorDTO = createAPIErrorDTO(exception)
val httpStatus = getHttpStatus(exception)
if (HttpStatus.INTERNAL_SERVER_ERROR == httpStatus) {
request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, exception, WebRequest.SCOPE_REQUEST)
}
return ResponseEntity<APIErrorDTO>(apiErrorDTO, HttpHeaders(), httpStatus)
}
fun createAPIErrorDTO(
exception: Throwable?, defaultErrorCode: String = DEFAULT_ERROR_CODE,
defaultMessage: String = ""
): APIErrorDTO {
val errorId = UUID.randomUUID().toString()
val errorCode = if (exception is ApplicationException) exception.code else defaultErrorCode
var message = if (exception is ApplicationException) exception.message else defaultMessage
val errorDetail = mutableListOf<ErrorDetailDTO>()
var cause = exception?.cause
while (cause != null) {
if (cause is ApplicationException) {
errorDetail.add(ErrorDetailDTO(cause.code, cause.message))
}
cause = cause.cause
}
return APIErrorDTO(errorId, errorCode, errorDetail, message)
}
private fun getHttpStatus(exception: ApplicationException): HttpStatus {
var httpStatus = exception.httpStatus
var cause = exception.cause
while (cause != null) {
if (cause is ApplicationException) {
httpStatus = cause.httpStatus
} else if (cause is AccessDeniedException) {
httpStatus = HttpStatus.FORBIDDEN
}
cause = cause.cause
}
return httpStatus
}
}
If you expect to receive any other type of exception here, you can add a new method for that. Then you can call handleApplicationException
from there with appropriate arguments. This way you can create an APIErrorDTO
out of any exception. For example, if you expect to receive NestedRuntimeException
, you can add the following method to your GlobalExceptionHandler
class.
@ExceptionHandler(NestedRuntimeException::class)
fun nestedRuntimeExceptionTransformer(
exception: NestedRuntimeException,
request: WebRequest
): ResponseEntity<APIErrorDTO> {
return handleApplicationException(
ApplicationUnprocessableException(DEFAULT_ERROR_CODE, "a relevant message!", exception),
request
)
}
By the way, we cannot catch all exceptions in the GlobalExceptionHandler
. We need to define a custom ErrorAttributes
to translate all exceptions - even those that are out of our control -into the APIErrorDTO
Custom error attributes
Using the following class we could translate other exceptions to the APIErrorDTO
.
class CustomErrorAttributes : DefaultErrorAttributes() {
override fun getErrorAttributes(webRequest: WebRequest?, options: ErrorAttributeOptions?): MutableMap<String, Any> {
val defaultErrorCode =
getStatusCode(webRequest)?.toString() ?: DEFAULT_ERROR_CODE
return createAPIErrorDTO(
this.getError(webRequest),
defaultErrorCode,
defaultMessage = getDefaultErrorMessage(webRequest)
).toMap()
}
private fun getDefaultErrorMessage(webRequest: WebRequest?): String {
val defaultErrorMessage: String
val errorMessage = webRequest?.getAttribute(WebUtils.ERROR_MESSAGE_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)?.toString()
defaultErrorMessage = if (!errorMessage.isNullOrBlank()) {
errorMessage
} else {
try {
val statusCode = getStatusCode(webRequest)
if (statusCode == null) UNKNOWN_ERROR_MESSAGE else HttpStatus.valueOf(statusCode).reasonPhrase
} catch (e: Exception) {
UNKNOWN_ERROR_MESSAGE
}
}
return defaultErrorMessage
}
private fun getStatusCode(webRequest: WebRequest?): Int? {
val statusCodeAttribute = webRequest?.getAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)
return if (statusCodeAttribute != null) statusCodeAttribute as Int else null
}
}
Aspect oriented programming (AOP)
We all value clean and readable code, right? Instead of polluting our code with try/catch
blocks, we will use Spring AOP to make our code clean and concise. Let's define an aspect to decorate our methods with and get rid of some boilerplate code here and there.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExceptionWrapper(val exception: KClass<out ApplicationException>)
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class ExceptionWrapperAspect {
@Around("@annotation(io.cloudflight.thiscouldbeyourproject.server.common.exception.ExceptionWrapper)")
fun wrapException(joinPoint: ProceedingJoinPoint): Any? {
try {
return joinPoint.proceed()
} catch (e: Throwable) {
val methodSignature: MethodSignature = joinPoint.signature as MethodSignature
val method: Method = methodSignature.method
val exceptionWrapper: ExceptionWrapper =
method.annotations.find { it is ExceptionWrapper } as ExceptionWrapper
val constructor =
exceptionWrapper.exception.constructors.firstOrNull { it.parameters.size == 1 && it.parameters.first().type.classifier == Throwable::class }
throw
constructor?.call(e) ?: ApplicationException(
code = DEFAULT_ERROR_CODE,
i18nMessage = DEFAULT_ERROR_MESSAGE,
cause = e
)
}
}
}
Having these, We only need to annotate the entry point of the use case with the @ExceptionWrapper
annotation. This way, you can make sure, That end users would get an appropriate message if anything goes wrong. Notice that you don't need to annotate all of your methods with@ExceptionWrapper
It's enough to have it only at the entry point of the use case. You still can catch an error in a lower layer, If it's recoverable or if you want to share more information with the end user that you only have available where the error occurs. For the latter case, You can catch the error and throw a sub-class of ApplicationException
!
@Transactional
@ExceptionWrapper(CreateUserException::class)
override fun createUser(user: UserChange): User {
if (EmailFormatIsInvalid(user.email))
throw EmailFormatIsInvalid()
...
return createdUser
}
class CreateUserException(cause: Throwable) : ApplicationException(
code = "ERR-CU-1",
message = "Failed to create the user",
cause = cause
)
class EmailFormatIsInvalid : ApplicationUnprocessableException(
code = "ERR-CU-2",
message = "Format of provided email is invalid. The format should look like local-part@domain e.g. example@cloudflight.io",
)
Conclusion
Now let's see if we satisfied all our user's expectations with this solution:
End users
- Using the
message
we can describe the error and provide hints to resolve that if it'd be possible. - Using the
id
we can let our users report the error with minimal effort. - Using the
code
users can get support from the help desk team.
- Using the
Help desk team
- Using the
code
:- We can categorize errors and prepare a resolution table mapping the error codes to the possible resolution.
- We make the communication between users and the help desk team easier.
- Help desk team can decide if they should forward the request to the development team or not. You might ask how? If
code
is set to the default error code they should forwarded it, right?
- Using the
Development or maintenance team
- Using the
id
we can uniquely identify each error and find the logs or any other information related to it and use them to debug the error.
- Using the
All in all, I guess now our users should be happy even when there is an error. If you don't think so, or you see any improvement possibility please let me know your opinions in the comments. Thanks!