Skip to content

Error Handling

BrunoRosendo edited this page Aug 18, 2023 · 7 revisions

Any API should effectively manage input or internal errors in a manner that is comprehensible and consistent for its users. Spring offers several methods for error management, which effectively address the separation of concerns. In this page, we will explore the primary techniques for error handling and provide a more elaborate explanation of the approach employed in the project, specifically utilizing @ControllerAdvice.

Contents

Main Approaches for Error Handling

  1. Controller Level

Just as new endpoints can be developed, it is also feasible to establish exception handlers directly within the controllers (refer to Services and Controllers).

It is important to recognize that these handlers will exclusively manage exceptions raised within the respective controller. In previous iterations of Spring, this limitation could be mitigated by having all controllers inherit from a shared base class. Thankfully, Spring has evolved to offer more effective approaches for global exception handling, as elaborated in the upcoming sections.

class FooController {
    //...
    @ExceptionHandler(ConstraintViolationException::class, IllegalArgumentException::class)
    fun handleException(): ResponseEntity<String> {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request")
    }
}
  1. Exception Level

Spring's built-in default exception resolver enables us to directly specify the response's status code for custom exceptions we may define. To illustrate:

@ResponseStatus(value = HttpStatus.NOT_FOUND)
class MyResourceNotFoundException(message: String?, cause: Throwable?) : RuntimeException(message, cause)

However, a drawback is that while the response status is accurately assigned, its body will remain empty. While this can be addressed using a custom exception handler, as detailed in this example, there exist more elegant solutions for this issue. Additionally, this approach restricts us to utilizing exceptions annotated with @ResponseStatus solely for error handling purposes.

  1. Method Level

Exception handling can be localized within a controller's endpoints by utilizing the ResponseStatusException class. This action enables us to interrupt the method's flow while specifying a preferred status code, along with an optional reason and cause for the error.

This approach offers notable benefits:

  • Excellent for prototyping: We can implement a basic solution quite fast.
  • One exception type can lead to multiple different responses, which does not happen with the other methods.
  • Avoids creating too many custom exceptions or handlers.

However, this approach lacks the capability to establish a uniform method of exception handling, and it becomes challenging to enforce conventions. Moreover, there's a higher likelihood of code duplication across different endpoints. For these reasons, this technique proves effective when used in conjunction with other strategies, particularly global exception handlers. Should you opt for this combination, be mindful that handling the same exception through multiple avenues may lead to unexpected (or inconsistent) behaviors.

@PostMapping("/refresh")
fun refreshAccessToken(@RequestBody tokenDto: TokenDto): Map<String, String> {
    val jwt =
        try {
            jwtDecoder.decode(tokenDto.token)
        } catch (e: Exception) {
            throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token", e)
        }
    // ...
}
  1. Global Exception Handlers

Global exception handlers can be established using the @ControllerAdvice or @RestControllerAdvice class annotations (the difference being the inclusion of a response body). Subsequently, you can generate handlers using the @ExceptionHandler annotation alongside the appropriate exception class. This approach can be combined with either the @ResponseStatus annotation or ResponseEntity to specify both the response body and the status code.

In our project, we adopt this approach due to the following advantages it offers:

  • It gives us full control over the body of the response as well as the status code.
  • Enabling the mapping of multiple exceptions to a single method for unified handling.
  • Enhancing error consistency and simplifying controller logic.
@RestControllerAdvice
class ErrorController {
    @ExceptionHandler(IllegalArgumentException::class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    fun illegalArgument(e: IllegalArgumentException): Map<String, String> {
        return mapOf("error" to (e.message ?: "invalid argument"))
    }
}

Source: Baeldung

Custom Errors

To ensure uniformity throughout our API, all errors adhere to a consistent structure. Our error messages are structured as an array of individual errors, each comprising a message, and optionally, param and value attributes. This adaptable format is easily extensible, making it especially advantageous for handling validations.

The present implementation of these classes is as follows:

data class SimpleError(
    val message: String,
    val param: String? = null,
    val value: Any? = null
)

data class CustomError(val errors: List<SimpleError>)

Error Controller

As previously mentioned, our project used a global exception handler methodology. Currently, this approach utilizes a singular error controller. However, should the class become too extensive in the future, it can be easily divided into distinct semantic error controllers.

One notable distinction in our error controller, compared to the prior example, is the inclusion of a catch-all endpoint intended for all unspecified routes. This ensures a consistent 404 error response with a uniform message. To achieve this, the @RestController annotation is applied, and the class extends the ErrorController interface. This designates to Spring that any unmapped routes should default to this controller.

Below is a snippet from the current controller. For a more comprehensive understanding, please refer to the actual class within the project.

@RestController
@RestControllerAdvice
class ErrorController(private val objectMapper: ObjectMapper) : ErrorController, Logging {

    @RequestMapping("/**")
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun endpointNotFound(): CustomError = wrapSimpleError("invalid endpoint")

    @ExceptionHandler(ConstraintViolationException::class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    fun invalidArguments(e: ConstraintViolationException): CustomError {
        val errors = mutableListOf<SimpleError>()
        e.constraintViolations.forEach { violation ->
            errors.add(
                SimpleError(
                    violation.message,
                    violation.propertyPath.toString(),
                    violation.invalidValue.takeIf { it.isSerializable() }
                )
            )
        }
        return CustomError(errors)
    }

    @ExceptionHandler(Exception::class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    fun unexpectedError(e: Exception): CustomError {
        logger.error(e.message)
        return wrapSimpleError("unexpected error")
    }

    // ...

    fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError(
        mutableListOf(SimpleError(msg, param, value))
    )

    fun Any.isSerializable() = try {
        objectMapper.writeValueAsString(this)
        true
    } catch (err: Exception) {
        false
    }
}

Source: StackOverflow - @ControllerAdvice vs ErrorController