Como tratar erros http no Spring Boot
Tags: beginners, java, springboot, tutorial
Published: 2023-10-30 21:20:59

Tratamento de erros, independente da linguagem, é sempre uma questão complicada. Mas quando se trata de Spring, existe um padrão recomendado e nativo para lidar com exceções sem muita dor de cabeça.

>Este tutorial utiliza **Java 17** e **Spring boot 3.1.5**.
>[Código completo usado como exemplo](https://github.com/m1guelsb/spring-exceptions).

## Sumário
* [A situação](#a-situação)  
* [O problema](#o-problema)  
* [Filtro global de exceções](#filtro-global-de-exceções)
* [Receptando e tratando erros de validação](#receptando-e-tratando-erros-de-validação)
* [Tratando outros tipos de erro](#tratando-outros-tipos-de-erro)

## A situação
Temos uma aplicação onde estamos usando a biblioteca de validação padrão do Spring:
```xml 
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
```

E validamos os campos do [DTO](https://www.baeldung.com/java-dto-pattern "O que é DTO?") da entidade `Person`:
```java
@Data
public class PersonDto {
  @NotBlank(message = "name: Is required")
  @Length(min = 3, max = 100, message = "title: Must be of 3 - 100 characters")
  String name;

  @NotBlank(message = "email: Is required")
  @Email(message = "email: Invalid format")
  String email;

  @NotNull(message = "age: Is required")
  @Min(value = 1, message = "age: Must be greater than 0")
  @Max(value = 100, message = "age: Must be less than 100")
  Integer age;
}
```

Após definir nosso DTO, devemos utiliza-lo para representar o body do nosso controller e sempre nos atentarmos na utilização da annotation `@Valid` para considerar as checagens definidas acima:
```java
@PostMapping
public ResponseEntity<Person> create(@RequestBody @Valid @NotNull PersonDto dto) {
  return ResponseEntity.status(HttpStatus.CREATED).body(personService.create(dto));
}
```

## O problema
Quando enviamos uma requisição para criação de um person com valores inválidos, recebemos algo parecido com isso:
```json
{
  "timestamp": "2023-10-27T00:03:21.577+00:00",
  "status": 400,
  "error": "Bad Request",
  "trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<com.m1guelsb.springexceptions.entities.Person> com.m1guelsb.springexceptions.controllers.PersonController.create(com.m1guelsb.springexceptions.dtos.PersonDto) with 3 errors: [Field error in object 'personDto' on field 'email': rejected value [example]; codes [Email.personDto.email,Email.email,Email.java.lang.String,Email]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [...]"
}
```

A única informação legível é que o erro foi um `400 Bad request`, além disso temos `trace` e `message` com valores nem um pouco descritivos. Não sabemos quais foram os campos incorretos, nem quais valores devem ser enviados.

Esse tipo de resposta, além de confundir os usuários de nossa API, também expõem as tecnologias que nosso back-end está usando. Podemos considerar isso como uma brecha de segurança já que toda tecnologia contém falhas.

>É importante destacar a parte `MethodArgumentNotValidException` que indica qual o tipo de erro que estamos recebendo. Precisaremos desta informação adiante.

## Filtro global de exceções
O Spring nos provê um jeito nativo para tratar exceções de modo global, o **Controller Advice**. Podemos usá-lo através da annotation `@RestControllerAdvice`.

Para isso é ideal criarmos uma classe onde centralizaremos nossos métodos de tratamento de erro e dentro dela teremos um método que irá nos ajudar a **padronizar** as nossas respostas de erro:
```java
@RestControllerAdvice
public class GlobalExceptionHandler {

  private Map<String, List<String>> errorsMap(List<String> errors) {
    Map<String, List<String>> errorResponse = new HashMap<>();
    errorResponse.put("errors", errors);
    return errorResponse;
  }

}
```

O método `errorsMap` vai receber uma lista de `String` e retornar um `Map` que terá uma única chave contendo os valores da lista `errors`. A representação em JSON seria a seguinte:
```JSON
{
  "errors": [
    //lista de erros
  ]
}
```

## Receptando e tratando erros de validação
Agora finalmente escrevemos o método que irá de fato interceptar os erros e retornar os valores do jeito que queremos:
```java
@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<Map<String, List<String>>> handleValidationErrors(MethodArgumentNotValidException ex) {

    List<String> errors = ex.getBindingResult()
        .getFieldErrors()
        .stream()
        .map(error -> error.getDefaultMessage())
        .toList();

    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorsMap(errors));
  }

  private Map<String, List<String>> errorsMap(List<String> errors) {
    Map<String, List<String>> errorResponse = new HashMap<>();
    errorResponse.put("errors", errors);
    return errorResponse;
  }
}
```

Usando a annotation `@ExceptionHandler()`, interceptamos as exceções do tipo `MethodArgumentNotValidException` que é exatamente o mesmo que vimos no `trace` da resposta [anteriormente](#o-problema).

### Entendendo o método `handleValidationErrors(MethodArgumentNotValidException ex)`:
- Recebemos `ex` como parâmetro do tipo do nosso erro, iteramos em `getFieldErrors()` e em seguida, coletamos as mensagens de erro usando `getDefaultMessage()` para retornar uma lista com elas:
```java 
List<String> errors = ex.getBindingResult()
    .getFieldErrors()
    .stream()
    .map(error -> error.getDefaultMessage())
    .toList();
```
- Então passamos nossa lista de erros para o `errorsMap` retornando dentro do `body` do `ResponseEntity` com o status de `BAD_REQUEST`:
```java
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorsMap(errors));
```

Com isso, quando nosso client enviar dados **incorretos**:
```JSON
{
  "name": "Mi",
  "email": "Invalid email",
  "age": 0
}
```

Terá a linda e cheirosa **resposta**:
```JSON
{
  "errors": [
    "title: Must be of 3 - 100 characters",
    "age: Must be greater than 0",
    "email: Invalid format"
  ]
}
```
>Meus caros leitores, isso aqui é o **sonho** de todo dev front-end! 🥰

## Tratando outros tipos de erro
Seguindo o mesmo modelo, podemos criar outros métodos que lidarão com outros tipos de erro.

Outro erro muito comum de acontecer é o famoso `404 NOT_FOUND`, para trata-lo podemos criar um método estendendo `RuntimeException` que irá nos ajudar a enviar uma mensagem personalizada para cada caso de NOT_FOUND que tivermos:
```java
public class NotFoundException extends RuntimeException {
  public NotFoundException(String ex) {
    super(ex);
  }
}
```
E então, no nosso `GlobalExceptionHandler` adicionamos o seguinte método:
```java
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, List<String>>> handleNotFoundException(NotFoundException ex) {
  List<String> errors = List.of(ex.getMessage());

  return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorsMap(errors));
}
```
As únicas diferenças para o método anterior são:
- Trocamos o parâmetro classe do `ExceptionHandler` para agora lidar com a nossa classe `NotFoundException`
- Agora coletamos a mensagem de erro criando uma lista contendo o seu valor: `List.of(ex.getMessage())`.

Não podemos esquecer de instanciar e retornar nossa classe sempre que um erro `404` pode ser disparado, como por exemplo o método `findById` do nosso `PersonService`:
```java
public Person findById(Long id) throws NotFoundException {
  return personRepository.findById(id)
      .orElseThrow(() -> new NotFoundException("Person with id " + id + " not found"));
}
```
Dessa forma, quando nosso client tentar acessar um Person que não existe, ele receberá a mensagem que inserimos ao instanciar a classe:
```JSON
{
  "errors": [
    "Person with id 999 not found"
  ]
}
```

E por último mas não menos importante, filtramos também os erros gerais dos tipos `Exception` e `RuntimeException`:
```java
@ExceptionHandler(Exception.class)
public final ResponseEntity<Map<String, List<String>>> handleGeneralExceptions(Exception ex) {
  List<String> errors = List.of(ex.getMessage());

  return ResponseEntity
      .status(HttpStatus.INTERNAL_SERVER_ERROR)
      .body(errorsMap(errors));
}

@ExceptionHandler(RuntimeException.class)
public final ResponseEntity<Map<String, List<String>>> handleRuntimeExceptions(RuntimeException ex) {
  List<String> errors = List.of(ex.getMessage());

  return ResponseEntity
      .status(HttpStatus.INTERNAL_SERVER_ERROR)
      .body(errorsMap(errors));
}
```

>Seguindo este padrão, podemos tratar qualquer tipo de erro e retornar sempre o mesmo padrão de mensagens. 🥳🎉

---

Por hoje é isso! Acha que faltou alguma informação importante ou descobriu algum bug? Sinta-se livre para me mandar uma mensagem no [Twitter/X](https://x.com/m1guelsb).

Obrigado pela leitura! 💝