Обработка ошибки 404 spring

I use @ExceptionHandler to handle exceptions thrown by my web app, in my case my app returns JSON response with HTTP status for error responses to the client.

However, I am trying to figure out how to handle error 404 to return a similar JSON response like with the one handled by @ExceptionHandler

Update:

I mean, when a URL that does not exist is accessed

asked Nov 13, 2012 at 6:45

quarks's user avatar

quarksquarks

33.1k70 gold badges282 silver badges508 bronze badges

I use spring 4.0 and java configuration. My working code is:

@ControllerAdvice
public class MyExceptionController {
    @ExceptionHandler(NoHandlerFoundException.class)
    public ModelAndView handleError404(HttpServletRequest request, Exception e)   {
            ModelAndView mav = new ModelAndView("/404");
            mav.addObject("exception", e);  
            //mav.addObject("errorcode", "404");
            return mav;
    }
}

In JSP:

    <div class="http-error-container">
        <h1>HTTP Status 404 - Page Not Found</h1>
        <p class="message-text">The page you requested is not available. You might try returning to the <a href="<c:url value="/"/>">home page</a>.</p>
    </div>

For Init param config:

public class AppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    @Override
    public void customizeRegistration(ServletRegistration.Dynamic registration) {
        registration.setInitParameter("throwExceptionIfNoHandlerFound", "true");
    }
}

Or via xml:

<servlet>
    <servlet-name>rest-dispatcher</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>throwExceptionIfNoHandlerFound</param-name>
        <param-value>true</param-value>
    </init-param>
</servlet>

See Also: Spring MVC Spring Security and Error Handling

Community's user avatar

answered Dec 1, 2014 at 8:07

Md. Kamruzzaman's user avatar

5

With spring > 3.0 use @ResponseStatus

  @ResponseStatus(value = HttpStatus.NOT_FOUND)
  public class ResourceNotFoundException extends RuntimeException {
    ...
}

    @Controller
    public class MyController {
    @RequestMapping.....
    public void handleCall() {
        if (isFound()) {
        // do some stuff
        }
        else {
              throw new ResourceNotFoundException(); 
        }
    }
}

answered Nov 13, 2012 at 6:52

Yves_T's user avatar

Yves_TYves_T

1,1702 gold badges10 silver badges16 bronze badges

4

Simplest way to find out is use the following:

@ExceptionHandler(Throwable.class)
  public String handleAnyException(Throwable ex, HttpServletRequest request) {
    return ClassUtils.getShortName(ex.getClass());
  }

If the URL is within the scope of DispatcherServlet then any 404 caused by mistyping or anything else will be caught by this method but if the URL typed is beyond the URL mapping of the DispatcherServlet then you have to either use:

<error-page>
   <exception-type>404</exception-type>
   <location>/404error.html</location>
</error-page>

or

Provide «/» mapping to your DispatcherServlet mapping URL so as to handle all the mappings for the particular server instance.

answered Nov 13, 2012 at 11:23

Liam's user avatar

LiamLiam

2,83723 silver badges36 bronze badges

3

public final class ResourceNotFoundException extends RuntimeException {

}


@ControllerAdvice
public class AppExceptionHandler {
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handleNotFound() {
        return "404";
    }
}

Just define an Exception, an ExceptionHandler, throw the Exception from your business code controller.

answered Oct 12, 2017 at 7:51

igonejack's user avatar

igonejackigonejack

2,34620 silver badges28 bronze badges

1

You can use servlet standard way to handle 404 error. Add following code in web.xml

<error-page>
   <exception-type>404</exception-type>
   <location>/404error.html</location>
</error-page>

answered Nov 13, 2012 at 9:28

Fu Cheng's user avatar

Fu ChengFu Cheng

3,3851 gold badge21 silver badges24 bronze badges

Время на прочтение
9 мин

Количество просмотров 64K

image

Часто на практике возникает необходимость централизованной обработки исключений в рамках контроллера или даже всего приложения. В данной статье разберём основные возможности, которые предоставляет Spring Framework для решения этой задачи и на простых примерах посмотрим как всё работает. Кому интересна данная тема — добро пожаловать под кат!

Изначально до Spring 3.2 основными способами обработки исключений в приложении были HandlerExceptionResolver и аннотация @ExceptionHandler. Их мы ещё подробно разберём ниже, но они имеют определённые недостатки. Начиная с версии 3.2 появилась аннотация @ControllerAdvice, в которой устранены ограничения из предыдущих решений. А в Spring 5 добавился новый класс ResponseStatusException, который очень удобен для обработки базовых ошибок для REST API.

А теперь обо всём по порядку, поехали!

Обработка исключений на уровне контроллера — @ExceptionHandler

С помощью аннотации @ExceptionHandler можно обрабатывать исключения на уровне отдельного контроллера. Для этого достаточно объявить метод, в котором будет содержаться вся логика обработки нужного исключения, и проаннотировать его.

В качестве примера разберём простой контроллер:

@RestController
public class Example1Controller {

    @GetMapping(value = "/testExceptionHandler", produces = APPLICATION_JSON_VALUE)
    public Response testExceptionHandler(@RequestParam(required = false, defaultValue = "false") boolean exception)
            throws BusinessException {
        if (exception) {
            throw new BusinessException("BusinessException in testExceptionHandler");
        }
        return new Response("OK");
    }

    @ExceptionHandler(BusinessException.class)
    public Response handleException(BusinessException e) {
        return new Response(e.getMessage());
    }

}

Тут я сделал метод testExceptionHandler, который вернёт либо исключение BusinessException, либо успешный ответ — всё зависит от того что было передано в параметре запроса. Это нужно для того, чтобы можно было имитировать как штатную работу приложения, так и работу с ошибкой.

А вот следующий метод handleException предназначен уже для обработки ошибок. У него есть аннотация @ExceptionHandler(BusinessException.class), которая говорит нам о том что для последующей обработки будут перехвачены все исключения типа BusinessException. В аннотации @ExceptionHandler можно прописать сразу несколько типов исключений, например так: @ExceptionHandler({BusinessException.class, ServiceException.class}).

Сама обработка исключения в данном случае примитивная и сделана просто для демонстрации работы метода — по сути вернётся код 200 и JSON с описанием ошибки. На практике часто требуется более сложная логика обработки и если нужно вернуть другой код статуса, то можно воспользоваться дополнительно аннотацией @ResponseStatus, например @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR).

Пример работы с ошибкой:

Пример штатной работы:

Основной недостаток @ExceptionHandler в том что он определяется для каждого контроллера отдельно, а не глобально для всего приложения. Это ограничение можно обойти если @ExceptionHandler определен в базовом классе, от которого будут наследоваться все контроллеры в приложении, но такой подход не всегда возможен, особенно если перед нами старое приложение с большим количеством легаси.

Обработка исключений с помощью HandlerExceptionResolver

HandlerExceptionResolver является общим интерфейсом для обработчиков исключений в Spring. Все исключений выброшенные в приложении будут обработаны одним из подклассов HandlerExceptionResolver. Можно сделать как свою собственную реализацию данного интерфейса, так и использовать существующие реализации, которые предоставляет нам Spring из коробки. Давайте разберем их для начала:

ExceptionHandlerExceptionResolver — этот резолвер является частью механизма обработки исключений с помощью аннотации @ExceptionHandler, о которой я уже упоминал ранее.

DefaultHandlerExceptionResolver — используется для обработки стандартных исключений Spring и устанавливает соответствующий код ответа, в зависимости от типа исключения:

Основной недостаток заключается в том что возвращается только код статуса, а на практике для REST API одного кода часто не достаточно. Желательно вернуть клиенту еще и тело ответа с описанием того что произошло. Эту проблему можно решить с помощью ModelAndView, но не нужно, так как есть способ лучше.

ResponseStatusExceptionResolver — позволяет настроить код ответа для любого исключения с помощью аннотации @ResponseStatus.

В качестве примера я создал новый класс исключения ServiceException:

@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public class ServiceException extends Exception {

    public ServiceException(String message) {
        super(message);
    }

}

В ServiceException я добавил аннотацию @ResponseStatus и в value указал что данное исключение будет соответствовать статусу INTERNAL_SERVER_ERROR, то есть будет возвращаться статус-код 500.

Для тестирования данного нового исключения я создал простой контроллер:

@RestController
public class Example2Controller {

    @GetMapping(value = "/testResponseStatusExceptionResolver", produces = APPLICATION_JSON_VALUE)
    public Response testResponseStatusExceptionResolver(@RequestParam(required = false, defaultValue = "false") boolean exception)
            throws ServiceException {
        if (exception) {
            throw new ServiceException("ServiceException in testResponseStatusExceptionResolver");
        }
        return new Response("OK");
    }

}

Если отправить GET-запрос и передать параметр exception=true, то приложение в ответ вернёт 500-ю ошибку:

Из недостатков такого подхода — как и в предыдущем случае отсутствует тело ответа. Но если нужно вернуть только код статуса, то @ResponseStatus довольно удобная штука.

Кастомный HandlerExceptionResolver позволит решить проблему из предыдущих примеров, наконец-то можно вернуть клиенту красивый JSON или XML с необходимой информацией. Но не спешите радоваться, давайте для начала посмотрим на реализацию.

В качестве примера я сделал кастомный резолвер:

@Component
public class CustomExceptionResolver extends AbstractHandlerExceptionResolver {

    @Override
    protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        ModelAndView modelAndView = new ModelAndView(new MappingJackson2JsonView());
        if (ex instanceof CustomException) {
            modelAndView.setStatus(HttpStatus.BAD_REQUEST);
            modelAndView.addObject("message", "CustomException was handled");
            return modelAndView;

        }
        modelAndView.setStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        modelAndView.addObject("message", "Another exception was handled");
        return modelAndView;
    }

}

Ну так себе, прямо скажем. Код конечно работает, но приходится выполнять всю работу руками: сами проверяем тип исключения, и сами формируем объект древнего класса ModelAndView. На выходе конечно получим красивый JSON, но в коде красоты явно не хватает.

Такой резолвер может глобально перехватывать и обрабатывать любые типы исключений и возвращать как статус-код, так и тело ответа. Формально он даёт нам много возможностей и не имеет недостатков из предыдущих примеров. Но есть способ сделать ещё лучше, к которому мы перейдем чуть позже. А сейчас, чтобы убедиться что всё работает — напишем простой контроллер:

@RestController
public class Example3Controller {

    @GetMapping(value = "/testCustomExceptionResolver", produces = APPLICATION_JSON_VALUE)
    public Response testCustomExceptionResolver(@RequestParam(required = false, defaultValue = "false") boolean exception)
            throws CustomException {
        if (exception) {
            throw new CustomException("CustomException in testCustomExceptionResolver");
        }
        return new Response("OK");
    }

}

А вот и пример вызова:

Видим что исключение прекрасно обработалось и в ответ получили код 400 и JSON с сообщением об ошибке.

Обработка исключений с помощью @ControllerAdvice

Наконец переходим к самому интересному варианту обработки исключений — эдвайсы. Начиная со Spring 3.2 можно глобально и централизованно обрабатывать исключения с помощью классов с аннотацией @ControllerAdvice.

Разберём простой пример эдвайса для нашего приложения:

@ControllerAdvice
public class DefaultAdvice {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Response> handleException(BusinessException e) {
        Response response = new Response(e.getMessage());
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

}

Как вы уже догадались, любой класс с аннотацией @ControllerAdvice является глобальным обработчиком исключений, который очень гибко настраивается.
В нашем случае мы создали класс DefaultAdvice с одним единственным методом handleException. Метод handleException имеет аннотацию @ExceptionHandler, в которой, как вы уже знаете, можно определить список обрабатываемых исключений. В нашем случае будем перехватывать все исключения BusinessException.

Можно одним методом обрабатывать и несколько исключений сразу: @ExceptionHandler({BusinessException.class, ServiceException.class}). Так же можно в рамках эдвайса сделать сразу несколько методов с аннотациями @ExceptionHandler для обработки разных исключений.
Обратите внимание, что метод handleException возвращает ResponseEntity с нашим собственным типом Response:

public class Response {

    private String message;

    public Response() {
    }

    public Response(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

}

Таким образом у нас есть возможность вернуть клиенту как код статуса, так и JSON заданной структуры. В нашем простом примере я записываю в поле message описание ошибки и возвращаю HttpStatus.OK, что соответствует коду 200.

Для проверки работы эдвайса я сделал простой контроллер:

@RestController
public class Example4Controller {

    @GetMapping(value = "/testDefaultControllerAdvice", produces = APPLICATION_JSON_VALUE)
    public Response testDefaultControllerAdvice(@RequestParam(required = false, defaultValue = "false") boolean exception)
            throws BusinessException {
        if (exception) {
            throw new BusinessException("BusinessException in testDefaultControllerAdvice");
        }
        return new Response("OK");
    }

}

В результате, как и ожидалось, получаем красивый JSON и код 200:

А что если мы хотим обрабатывать исключения только от определенных контроллеров?
Такая возможность тоже есть! Смотрим следующий пример:

@ControllerAdvice(annotations = CustomExceptionHandler.class)
public class CustomAdvice {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Response> handleException(BusinessException e) {
        String message = String.format("%s %s", LocalDateTime.now(), e.getMessage());
        Response response = new Response(message);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

}

Обратите внимание на аннотацию @ControllerAdvice(annotations = CustomExceptionHandler.class). Такая запись означает что CustomAdvice будет обрабатывать исключения только от тех контроллеров, которые дополнительно имеют аннотацию @CustomExceptionHandler.

Аннотацию @CustomExceptionHandler я специально сделал для данного примера:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomExceptionHandler {
}

А вот и исходный код контроллера:

@RestController
@CustomExceptionHandler
public class Example5Controller {

    @GetMapping(value = "/testCustomControllerAdvice", produces = APPLICATION_JSON_VALUE)
    public Response testCustomControllerAdvice(@RequestParam(required = false, defaultValue = "false") boolean exception)
            throws BusinessException {
        if (exception) {
            throw new BusinessException("BusinessException in testCustomControllerAdvice");
        }
        return new Response("OK");
    }

}

В контроллере Example5Controller присутствует аннотация @CustomExceptionHandler, а так же на то что выбрасывается то же исключение что и в Example4Controller из предыдущего примера. Однако в данном случае исключение BusinessException обработает именно CustomAdvice, а не DefaultAdvice, в чём мы легко можем убедиться.

Для наглядности я немного изменил сообщение об ошибке в CustomAdvice — начал добавлять к нему дату:

На этом возможности эдвайсов не заканчиваются. Если мы посмотрим исходный код аннотации @ControllerAdvice, то увидим что эдвайс можно повесить на отдельные типы или даже пакеты. Не обязательно создавать новые аннотации или вешать его на уже существующие.

Исключение ResponseStatusException.

Сейчас речь пойдёт о формировании ответа путём выброса исключения ResponseStatusException:

@RestController
public class Example6Controller {

    @GetMapping(value = "/testResponseStatusException", produces = APPLICATION_JSON_VALUE)
    public Response testResponseStatusException(@RequestParam(required = false, defaultValue = "false") boolean exception) {
        if (exception) {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ResponseStatusException in testResponseStatusException");
        }
        return new Response("OK");
    }

}

Выбрасывая ResponseStatusException можно также возвращать пользователю определённый код статуса, в зависимости от того, что произошло в логике приложения. При этом не нужно создавать кастомное исключение и прописывать аннотацию @ResponseStatus — просто выбрасываем исключение и передаём нужный статус-код. Конечно тут возвращаемся к проблеме отсутствия тела сообщения, но в простых случаях такой подход может быть удобен.

Пример вызова:

Резюме: мы познакомились с разными способами обработки исключений, каждый из которых имеет свои особенности. В рамках большого приложения можно встретить сразу несколько подходов, но при этом нужно быть очень осторожным и стараться не переусложнять логику обработки ошибок. Иначе получится что какое-нибудь исключение обработается не в том обработчике и на выходе ответ будет отличаться от ожидаемого. Например если в приложении есть несколько эдвайсов, то при создании нового нужно убедиться, что он не сломает существующий порядок обработки исключений из старых контроллеров.
Так что будьте внимательны и всё будет работать замечательно!

Ссылка на исходники из статьи

Spring Boot provides pretty nifty defaults to handle exceptions and formulate a helpful response in case anything goes wrong. Still, for any number of reasons, an exception can be thrown at runtime and the consumers of your API may get a garbled exception message (or worse, no message at all) with a 500 Internal Server Error response.

Such a scenario is undesirable, because of the

  • usability concerns Although relevant, the default exception message may not be helpful to the consumers of your API.
  • security concerns The exception message may expose the internal details of your application to anyone using the API.

This is a pretty common occurrence and customizing the error response so that it is easy to comprehend is often one of the requirements of the API design. Like many other niceties, Spring Boot does a lot of heavy lifting for you; it does not send binding errors (due to validation failure), exceptions, or stacktrace in a response unless you configure them otherwise (see server.error keys under Server Properties available for a Spring application).

In this post, we’ll explore some of the ways to customize error responses returned by a REST API. We’ll also cover some usecases when Spring Security comes into the picture.

The code written for this post uses:

  • Java 14
  • Spring Boot 2.3.2
  • Postgres 13
  • Maven 3.6.3

You can launch an instance of Postgres with Docker using the following Compose file.

version: '3'

services:
  db:
    image: postgres:13-alpine
    restart: always
    ports:
      - 5432:5432
    environment:
      POSTGRES_USER: erin
      POSTGRES_PASSWORD: richards

Execute the following command to launch the container.

Configure the project

Generate a Spring Boot project with Spring Initializr, and add spring-boot-starter-web, spring-boot-starter-data-jdbc, and postgresql as dependencies.

Your pom.xml would look like this.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.2.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>

  <groupId>dev.mflash.guides</groupId>
  <artifactId>spring-rest-error-handling</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <properties>
    <java.version>14</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jdbc</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>

</project>

Rename application.properties to application.yml, open the file, and add the following database configuration.

# src/main/resources/application.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/spring
    username: erin
    password: richards

Create an API

Say, you want to save a Book object, described by the following entity.

// src/main/java/dev/mflash/guides/resterror/domain/Book.java

public class Book {

  private @Id long id;
  private String title;
  private String author;
  private Genre genre;

  // getters, setters, etc.
}

The id will be of type SERIAL in Postgres which will be automatically incremented by the database.

Create the required table using the following SQL statement.

CREATE TABLE book (
	id SERIAL PRIMARY KEY,
	title TEXT NOT NULL,
	author TEXT NOT NULL,
	genre TEXT NOT NULL
);

Note that the genre field is backed by an enum described as follows.

public enum Genre {
  fantasy,
  thriller,
  scifi
}

Define a BookRepository to perform database operations.

// src/main/java/dev/mflash/guides/resterror/persistence/BookRepository.java

public interface BookRepository extends CrudRepository<Book, Long> {

}

Create a BookController to expose some endpoints.

// src/main/java/dev/mflash/guides/resterror/controller/BookController.java

@RequestMapping("/book")
public @RestController class BookController {

  private final BookRepository repository;

  public BookController(BookRepository repository) {
    this.repository = repository;
  }

  @PutMapping
  public Map<String, String> addBook(Book newBook) {
    repository.save(newBook);
    return Map.of("message", String.format("Save successful for %s", newBook.getTitle()));
  }

  @GetMapping
  public List<Book> getAllBooks() {
    List<Book> allBooks = List.of();
    repository.findAll().forEach(allBooks::add);
    return allBooks;
  }

  @GetMapping("/{id}")
  public Book getBookById(@PathVariable long id) {
    Optional<Book> book = repository.findById(id);

    if (book.isEmpty()) {
      throw new DataAccessResourceFailureException(String.format("No resource found for id (%s)", id));
    }

    return book.get();
  }

  @PatchMapping("/{id}")
  public Map<String, String> editBook(@PathVariable long id, Book editedBook) {
    final Optional<Book> saved = repository.findById(id);

    if (saved.isPresent()) {
      final Book savedBook = saved.get();
      final Book patchedBook = new Book();
      patchedBook.setId(savedBook.getId());
      patchedBook.setTitle(
          !editedBook.getTitle().equals(savedBook.getTitle()) ? editedBook.getTitle() : savedBook.getTitle());
      patchedBook.setAuthor(
          !editedBook.getAuthor().equals(savedBook.getAuthor()) ? editedBook.getAuthor() : savedBook.getAuthor());
      patchedBook.setGenre(
          !editedBook.getGenre().equals(savedBook.getGenre()) ? editedBook.getGenre() : savedBook.getGenre());
      repository.save(patchedBook);
    } else {
      throw new InvalidDataAccessApiUsageException("Couldn't patch unrelated or non-existent records");
    }

    return Map.of("message", String.format("Patch successful for %s", editedBook.getTitle()));
  }

  @DeleteMapping("/{id}")
  public Map<String, String> deleteBook(@PathVariable long id) {
    if (repository.deleteById(id) < 1) {
      throw new InvalidDataAccessApiUsageException("Couldn't delete a non-existent record");
    }

    return Map.of("message", String.format("Deletion successful for book with id: %s", id));
  }
}

Launch the application and try to access a non-existent book.

$ curl --location --request GET 'http://localhost:8080/book/0'
{
  "timestamp": "2020-07-25T15:54:09.567+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "",
  "path": "/book/0"
}

The operation failed, obviously, and you received a JSON response with some useful fields. These fields are managed by DefaultErrorAttributes class and the response is formed by an implementation of HandlerExceptionResolver. However, we can already see what is amiss here.

  • There is no message clarifying what exactly went wrong.
  • It is the client that passed the incorrect id but the status code indicates it is a server error.
  • Since the record was not found, a 404 Not found would’ve been the accurate status.

Send the correct status using ResponseStatusException

The fastest way to address the issue with status is to throw Spring provided ResponseStatusException which accepts an HttpStatus.

// src/main/java/dev/mflash/guides/resterror/controller/BookController.java

@RequestMapping("/book")
public @RestController class BookController {

  // Rest of the controller

  @GetMapping("/{id}")
  public Book getBookById(@PathVariable long id) {
    Optional<Book> book = repository.findById(id);

    if (book.isEmpty()) {
      throw new ResponseStatusException(NOT_FOUND, String.format("No resource found for id (%s)", id));
    }

    return book.get();
  }

  @PatchMapping("/{id}")
  public Map<String, String> editBook(@PathVariable long id, Book editedBook) {
    final Optional<Book> saved = repository.findById(id);

    if (saved.isPresent()) {
      // logic for patch
    } else {
      throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Couldn't patch unrelated or non-existent records");
    }

    return Map.of("message", String.format("Patch successful for %s", editedBook.getTitle()));
  }

  @DeleteMapping("/{id}")
  public Map<String, String> deleteBook(@PathVariable long id) {
    if (repository.deleteById(id) < 1) {
      throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Couldn't delete a non-existent record");
    }

    return Map.of("message", String.format("Deletion successful for book with id: %s", id));
  }
}

Launch the application and try the request again.

$ curl --location --request GET 'http://localhost:8080/book/0'
{
  "timestamp": "2020-07-26T07:07:30.313+00:00",
  "status": 404,
  "error": "Not Found",
  "message": "",
  "path": "/book/0"
}

We are getting the correct status now but it’d be useful to let the client know the cause of this issue. Also, it’d be useful to throw relevant custom exceptions instead of the same exception everywhere.

Exception handling with @ControllerAdvice and @ExceptionHandler

If you examine ResponseStatusException, you’d notice that it saves the message in a variable reason. We’d prefer to map this to the message key in the response above. Let’s create a class RestResponse that can hold this response.

// src/main/java/dev/mflash/guides/resterror/exception/RestResponse.java

public class RestResponse {

  private final LocalDateTime timestamp = LocalDateTime.now();
  private int status;
  private String error;
  private String message;
  private String path;

  // getters, setters, etc.

  public static RestResponseBuilder builder() {
    return new RestResponseBuilder();
  }
}

Let’s also create a RestResponseBuilder that can provide a fluent API to create RestResponse objects from a variety of inputs.

// src/main/java/dev/mflash/guides/resterror/exception/RestResponseBuilder.java

public class RestResponseBuilder {

  private int status;
  private String error;
  private String message;
  private String path;

  public RestResponseBuilder status(int status) {
    this.status = status;
    return this;
  }

  public RestResponseBuilder status(HttpStatus status) {
    this.status = status.value();

    if (status.isError()) {
      this.error = status.getReasonPhrase();
    }

    return this;
  }

  public RestResponseBuilder error(String error) {
    this.error = error;
    return this;
  }

  public RestResponseBuilder exception(ResponseStatusException exception) {
    HttpStatus status = exception.getStatus();
    this.status = status.value();
    
    if (!Objects.requireNonNull(exception.getReason()).isBlank()) {
      this.message = exception.getReason();
    }
    

    if (status.isError()) {
      this.error = status.getReasonPhrase();
    }

    return this;
  }

  public RestResponseBuilder message(String message) {
    this.message = message;
    return this;
  }

  public RestResponseBuilder path(String path) {
    this.path = path;
    return this;
  }

  public RestResponse build() {
    RestResponse response = new RestResponse();
    response.setStatus(status);
    response.setError(error);
    response.setMessage(message);
    response.setPath(path);
    return response;
  }

  public ResponseEntity<RestResponse> entity() {
    return ResponseEntity.status(status).headers(HttpHeaders.EMPTY).body(build());
  }
}

We can configure a @ControllerAdvice that can trigger methods to handle ResponseStatusException. This method has to be annotated by @ExceptionHandler which specifies what type of exceptions a method can handle.

// src/main/java/dev/mflash/guides/resterror/exception/RestErrorHandler.java

@RestControllerAdvice
public class RestErrorHandler {

  private static final Logger logger = LoggerFactory.getLogger(RestErrorHandler.class);

  @ExceptionHandler(ResponseStatusException.class)
  ResponseEntity<?> handleStatusException(ResponseStatusException ex, WebRequest request) {
    logger.error(ex.getReason(), ex);
    return RestResponse.builder()
        .exception(ex)
        .path(request.getDescription(false).substring(4))
        .entity();
  }
}

In the above codeblock, handleStatusException will be invoked whenever a ResponseStatusException is thrown and instead of the boilerplate Spring response, an instance of RestResponse would be returned with the reason.

$ curl --location --request GET 'http://localhost:8080/book/0'
{
  "timestamp": "2020-07-26T15:24:38.0644948",
  "status": 404,
  "error": "Not Found",
  "message": "No resource found for id (0)",
  "path": "/book/0"
}

Note that we’re also injecting a WebRequest instance to get the path. Besides path, a WebRequest can provide a whole lot of other details about the request and client. Also, you may want to log the exceptions in the handler method, else Spring will not print them on the logs.

At this point, you may continue to throw ResponseStatusException throughout your application or you can choose to extend it to define custom exceptions with specific HttpStatus. But what about exceptions that are thrown by a third-party?

Handling Exceptions thrown by a third-party

One approach is to rethrow such exceptions as ResponseStatusException; this can be done wherever you encounter them. Another way is to write handler methods to intercept them in the @RestControllerAdvice above. It makes things a bit cleaner, but you can’t handle every exception out there. To deal with this, you can write a generic exception handler that may handle Exception class.

To get you started, Spring offers a ResponseEntityExceptionHandler class that provides a huge number of handlers for the exceptions thrown by Spring. You can extend this class and implement your handlers on top of it. Even better, you can override the existing handlers to customize their behavior. Let’s modify RestErrorHandler as follows.

// src/main/java/dev/mflash/guides/resterror/exception/RestErrorHandler.java

@RestControllerAdvice
public class RestErrorHandler extends ResponseEntityExceptionHandler {

  private static final Logger logger = LoggerFactory.getLogger(RestErrorHandler.class);

  @ExceptionHandler(ResponseStatusException.class)
  ResponseEntity<?> handleStatusException(ResponseStatusException ex, WebRequest request) {
    logger.error(ex.getReason(), ex);
    return handleResponseStatusException(ex, request);
  }

  @ExceptionHandler(Exception.class)
  ResponseEntity<?> handleAllExceptions(Exception ex, WebRequest request) {
    logger.error(ex.getLocalizedMessage(), ex);
    return handleEveryException(ex, request);
  }

  @SuppressWarnings("unchecked")
  protected @Override ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers,
      HttpStatus status, WebRequest request) {

    ResponseEntity<?> responseEntity;

    if (!status.isError()) {
      responseEntity = handleStatusException(ex, status, request);
    } else if (INTERNAL_SERVER_ERROR.equals(status)) {
      request.setAttribute("javax.servlet.error.exception", ex, 0);
      responseEntity = handleEveryException(ex, request);
    } else {
      responseEntity = handleEveryException(ex, request);
    }

    return (ResponseEntity<Object>) responseEntity;
  }

  protected ResponseEntity<RestResponse> handleResponseStatusException(ResponseStatusException ex, WebRequest request) {
    return RestResponse.builder()
        .exception(ex)
        .path(getPath(request))
        .entity();
  }

  protected ResponseEntity<RestResponse> handleStatusException(Exception ex, HttpStatus status, WebRequest request) {
    return RestResponse.builder()
        .status(status)
        .message("Execution halted")
        .path(getPath(request))
        .entity();
  }

  protected ResponseEntity<RestResponse> handleEveryException(Exception ex, WebRequest request) {
    return RestResponse.builder()
        .status(INTERNAL_SERVER_ERROR)
        .message("Server encountered an error")
        .path(getPath(request))
        .entity();
  }

  private String getPath(WebRequest request) {
    return request.getDescription(false).substring(4);
  }
}

A lot of things are going on here.

  • handleResponseStatusException method specifically handles ResponseStatusException
  • handleStatusException method handles exceptions when the status is not an error status (the statuses in 1xx, 2xx and 3xx series)
  • handleEveryException method handles all other exceptions and sets their status as 500 Internal Server Error
  • we’re also overriding handleExceptionInternal to translate the exceptions thrown by Spring to return RestResponse
  • finally, we’ve defined handleAllExceptions handler that serves as a catch-all. If no specific error handler is found for an exception, this method will be invoked.

To test this, launch the application and try to put a new book with genre as kids.

$ curl --location --request PUT 'http://localhost:8080/book' 
--header 'Content-Type: application/json' 
--data-raw '{
  "title": "The Land of Roar",
  "author": "Jenny McLachlan",
  "genre": "kids"
}'

{
  "timestamp": "2020-07-26T16:30:51.3444858",
  "status": 500,
  "error": "Internal Server Error",
  "message": "Server encountered an error",
  "path": "/book"
}

Since we’ve not configured any constant named kids in the Genre enum, Jackson will serialize the genre field as null which would violate NOT NULL constraint in the database. The application will throw a DataAccessException as a result. Since there’s no handler defined for this exception in RestErrorHandler class, the handleAllExceptions handler method will be invoked, sending the response seen above.

Error Handling for Spring Security

What happens if you add Spring Security in our application? After adding the JWT-based authentication (from the post Securing Spring Boot APIs with JWT Authentication) in our application, try to hit any BookController endpoint.

$ curl --location --request GET 'http://localhost:8080/book'
{
  "timestamp": "2020-07-26T11:36:58.225+00:00",
  "status": 403,
  "error": "Forbidden",
  "message": "",
  "path": "/book"
}

You’d notice that the exception handler that we configured earlier is not being invoked and we’re getting the default response. This happens because our custom advice is invoked after Spring Security’s servlet filters have verified the user. Since the user authentication failed, the handlers were never invoked.

Handle Authentication failure with AuthenticationEntryPoint

AuthenticationEntryPoint’s commence method is called when an AuthenticationException is thrown. You can implement this interface to return a customized response.

// src/main/java/dev/mflash/guides/resterror/security/CustomAuthenticationEntryPoint.java

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

  private static final Logger logger = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);

  public @Override void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
      throws IOException {
    logger.error(e.getLocalizedMessage(), e);

    String message = RestResponse.builder()
        .status(UNAUTHORIZED)
        .error("Unauthenticated")
        .message("Insufficient authentication details")
        .path(request.getRequestURI())
        .json();

    response.setStatus(UNAUTHORIZED.value());
    response.setContentType(APPLICATION_JSON_VALUE);
    response.getWriter().write(message);
  }
}

You can be as generic or versatile in handling different types of AuthenticationExceptions as you need. Configure this entrypoint in the security configuration as follows.

// src/main/java/dev/mflash/guides/resterror/security/SecurityConfiguration.java

@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  private static final String LOGIN_URL = "/user/login";
  private final CustomUserDetailsService userDetailsService;

  public SecurityConfiguration(CustomUserDetailsService userDetailsService) {
    this.userDetailsService = userDetailsService;
  }

  protected @Override void configure(HttpSecurity http) throws Exception {
    http.cors().and()
        .csrf().disable()
        .authorizeRequests().antMatchers(POST, REGISTRATION_URL).permitAll()
        .anyRequest().authenticated().and()
        .addFilter(new CustomAuthenticationFilter(authenticationManager()))
        .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()).and()
        .addFilter(new CustomAuthorizationFilter(authenticationManager()))
        .sessionManagement().sessionCreationPolicy(STATELESS);
  }

  // other configurations
}

To handle authorization failures, you can implement the AccessDeniedHandler interface.

// src/main/java/dev/mflash/guides/resterror/security/CustomAccessDeniedHandler.java

public class CustomAccessDeniedHandler implements AccessDeniedHandler {

  private static final Logger logger = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

  public @Override void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e)
      throws IOException {
    logger.error(e.getLocalizedMessage(), e);

    String message = RestResponse.builder()
        .status(FORBIDDEN)
        .message("Invalid Authorization token")
        .path(request.getRequestURI())
        .json();

    response.setStatus(FORBIDDEN.value());
    response.setContentType(APPLICATION_JSON_VALUE);
    response.getWriter().write(message);
  }
}

Similar to the AuthenticationEntryPoint approach, you can handle different scenarios that can lead to authorization failure. Call the handle method implemented above whenever such scenarios are encountered. An example is given below.

// src/main/java/dev/mflash/guides/resterror/security/CustomAuthorizationFilter.java

public class CustomAuthorizationFilter extends BasicAuthenticationFilter {

  private final AccessDeniedHandler accessDeniedHandler;

  public CustomAuthorizationFilter(AuthenticationManager authenticationManager, AccessDeniedHandler accessDeniedHandler) {
    super(authenticationManager);
    this.accessDeniedHandler = accessDeniedHandler;
  }

  protected @Override void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {

    String header = request.getHeader(AUTH_HEADER_KEY);

    if (Objects.isNull(header) || !header.startsWith(TOKEN_PREFIX)) {
      chain.doFilter(request, response);
      return;
    }

    if (header.startsWith(TOKEN_PREFIX)) {
      try {
        var authentication = getAuthentication(header);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        chain.doFilter(request, response);
      } catch (Exception e) {
        accessDeniedHandler.handle(request, response, new AccessDeniedException(e.getLocalizedMessage(), e));
      }
    }
  }

  private UsernamePasswordAuthenticationToken getAuthentication(String header) {

    if (header.startsWith(TOKEN_PREFIX)) {
      String username = parseToken(header);
      return new UsernamePasswordAuthenticationToken(username, null, List.of());
    } else {
      throw new AccessDeniedException("Failed to parse authentication token");
    }
  }
}

For this to work, you’ll have to inject the CustomAccessDeniedHandler in CustomAuthorizationFilter through the security configuration, as follows.

// src/main/java/dev/mflash/guides/resterror/security/SecurityConfiguration.java

@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  private static final String LOGIN_URL = "/user/login";
  private final CustomUserDetailsService userDetailsService;

  public SecurityConfiguration(CustomUserDetailsService userDetailsService) {
    this.userDetailsService = userDetailsService;
  }

  protected @Override void configure(HttpSecurity http) throws Exception {
    http.cors().and()
        .csrf().disable()
        .authorizeRequests().antMatchers(POST, REGISTRATION_URL).permitAll()
        .anyRequest().authenticated().and()
        .addFilter(new CustomAuthenticationFilter(authenticationManager()))
        .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()).and()
        .addFilter(new CustomAuthorizationFilter(authenticationManager(), new CustomAccessDeniedHandler()))
        .sessionManagement().sessionCreationPolicy(STATELESS);
  }

  // other configurations
}

Launch the application again and try accessing an endpoint without any authentication details.

$ curl --location --request GET 'http://localhost:8080/book'
{
  "timestamp": "2020-07-26T20:38:10.166004300",
  "status": 401,
  "error": "Unauthenticated",
  "message": "Insufficient authentication details",
  "path": "/book"
}

A 401 Unauthenticated error was sent, informing that authentication details were not sufficient for this request. Now, add an expired Bearer token in an Authorization header and send the request again.

$ curl --location --request GET 'http://localhost:8080/book' 
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJlbWlseS5icm9udGVAZXhhbXBsZS5jb20iLCJleHAiOjE1OTY1NTI0MTZ9.utTGxu-CTJglYQku9GMZPsOl-J8rni363nBaGbodNiP7D0J66Znf-fZZ-Hz_iVCO7CHj_s4E6Xuw68HCwyTZig'

{
  "timestamp": "2020-07-26T20:57:27.164265300",
  "status": 403,
  "error": "Forbidden",
  "message": "Invalid Authorization token",
  "path": "/book"
}

This time, a 403 Forbidden error was sent indicating that even though the authentication was successful, the token was invalid. Generate a new token by sending a login request.

$ curl --location --request POST 'http://localhost:8080/login' 
--header 'Content-Type: application/json' 
--data-raw '{
  "email": "arya.antrix@example.com",
  "password": "pa55word"
}'

You’ll receive a response 200 OK with an Authorization header that contains a token.

Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJlbWlseS5icm9udGVAZXhhbXBsZS5jb20iLCJleHAiOjE1OTY2NDE0MjJ9.xK9KvbdlPN_r_rJzwfaidYY2r83pvGsXgIw8LQokvMbVXCyF9fZnV1CgnVc1pjQeswFq8rOGhmgEdCHp7DbR8w

Using this token, try sending the request again.

$ curl --location --request GET 'http://localhost:8080/book' 
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJlbWlseS5icm9udGVAZXhhbXBsZS5jb20iLCJleHAiOjE1OTY2NDE0MjJ9.xK9KvbdlPN_r_rJzwfaidYY2r83pvGsXgIw8LQokvMbVXCyF9fZnV1CgnVc1pjQeswFq8rOGhmgEdCHp7DbR8w'

[
  {
    "id": 1,
    "title": "Kill Orbit",
    "author": "Joel Dane",
    "genre": "scifi"
  }
]

You’ll receive a 200 OK with a list of books saved in the database, as expected.


Source code

  • spring-rest-error-handling

Related

  • Securing Spring Boot APIs with JWT Authentication
  • Spring Security: Authentication and Authorization In-Depth

В этой статье — обзор способов обработки исключений в Spring Boot.

Приложение

Мы рассмотрим простое REST API приложение с одной сущностью Person и с одним контроллером.

Класс Person:

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

}

Контроллер PersonController:

@RestController
@RequestMapping("/persons")
public class PersonController {

    @Autowired
    private PersonRepository personRepository;

    @GetMapping
    public List<Person> listAllPersons() {
        List<Person> persons = personRepository.findAll();
        return persons;
    }

    @GetMapping(value = "/{personId}")
    public Person getPerson(@PathVariable("personId") long personId){
        return personRepository.findById(personId).orElseThrow(() -> new MyEntityNotFoundException(personId));
    }

    @PostMapping
    public Person createPerson(@RequestBody Person person) {
        return personRepository.save(person);
    }

    @PutMapping("/{id}")
    public Person updatePerson(@RequestBody Person person, @PathVariable long id)  {
        Person oldPerson = personRepository.getOne(id);
        oldPerson.setName(person.getName());
        return personRepository.save(oldPerson);
    }
    
}

При старте приложения выполняется скрипт data.sql, который добавляет в базу данных H2 одну строку — Person c id=1. То есть Person c id=2 в базе отсутствует.

При попытке запросить Person c id=2:

GET localhost:8080/persons/2

метод контроллера getPerson() выбрасывает исключение — в данном случае наше пользовательское MyEntityNotFoundException:

public class MyEntityNotFoundException extends RuntimeException {

    public MyEntityNotFoundException(Long id) {
        super("Entity is not found, id="+id);
    }
}

BasicErrorController

По умолчанию все исключения попадают на адрес /error в BasicErrorController, в метод error():

@Controller
@RequestMapping({"${server.error.path:${error.path:/error}}"})
public class BasicErrorController extends AbstractErrorController {

...
    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        HttpStatus status = this.getStatus(request);
        if (status == HttpStatus.NO_CONTENT) {
            return new ResponseEntity(status);
        } else {
            Map<String, Object> body = this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.ALL));
            return new ResponseEntity(body, status);
        }
    }
 ...
}

Если поставить в этом методе break point, то будет понятно, из каких атрибутов собирается ответное JSON сообщение.

Проверим ответ по умолчанию, запросив с помощью клиента Postman отсутствующий Person, чтобы выбросилось MyEntityNotFoundException:

GET localhost:8080/persons/2

Получим ответ:

{
    "timestamp": "2021-02-28T15:33:56.339+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Entity is not found, id=2",
    "path": "/persons/2"
}

Причем для того, чтобы поле message было непустым, в application.properties нужно включить свойство:

server.error.include-message=always

В текущей версии Spring сообщение не включается по умолчанию из соображений безопасности.

Обратите внимание, что поле status JSON-тела ответа дублирует реальный http-код ответа. В Postman он виден:

Поле message заполняется полем message выброшенного исключения.

Независимо от того, какое исключение выбросилось: пользовательское или уже существующее, ответ стандартный — в том смысле, что набор полей одинаковый. Меняется только внутренняя часть и, возможно, код ответа (он не обязательно равен 500, некоторые существующие в Spring исключения подразумевают другой код).

Но структура ответа сохраняется.

Проверим это.

Не пользовательское исключение

Например, если изменить код, убрав пользовательское MyEntityNotFoundException, то при отсутствии Person исключение будет все равно выбрасываться, но другое:

@GetMapping(value = "/{personId}")
public Person getPerson(@PathVariable("personId") long personId){
    return personRepository.findById(personId).get();
}

findById() возвращает тип Optional, а Optional.get() выбрасывает исключение NoSuchElementException с другим сообщением:

java.util.NoSuchElementException: No value present

в итоге при запросе несуществующего Person:

GET localhost:8080/persons/2

ответ сохранит ту же структуру, но поменяется поле message:

{
    "timestamp": "2021-02-28T15:44:20.065+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "No value present",
    "path": "/persons/2"
}

Вернем обратно пользовательское исключение MyEntityNotFoundException.

Попробуем поменять ответ, выдаваемый в ответ за запрос. Статус 500 для него явно не подходит.

Рассмотрим способы изменения ответа.

@ResponseStatus

Пока поменяем только статус ответа. Сейчас возвращается 500, а нам нужен 404 — это логичный ответ, если ресурс не найден.

Для этого аннотируем наше исключение:

@ResponseStatus(HttpStatus.NOT_FOUND)
public class MyEntityNotFoundException extends RuntimeException {

    public MyEntityNotFoundException(Long id) {
        super("Entity is not found, id="+id);
    }
}

Теперь ответ будет таким:

{
    "timestamp": "2021-02-28T15:54:37.070+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "Entity is not found, id=2",
    "path": "/persons/2"
}

Уже лучше.

@ControllerAdvice

Есть еще более мощный способ изменить ответ — @ControllerAdvice, и он имеет больший приоритет, чем @ResponseStatus.

В @ControllerAdvice можно не только изменить код ответа, но и тело. К тому же один обработчик можно назначить сразу для нескольких исключений.

Допустим мы хотим, чтобы ответ на запрос несуществующего Person имел такую структуру:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiError {
    private String message;
    private String debugMessage;
}

Для этого создадим обработчик в @ControllerAdvice, который перехватывает наше исключение MyEntityNotFoundException:

@ControllerAdvice
public class RestExceptionHandler {


    @ExceptionHandler({MyEntityNotFoundException.class, EntityNotFoundException.class})
    protected ResponseEntity<Object> handleEntityNotFoundEx(RuntimeException ex, WebRequest request) {
      ApiError apiError = new ApiError("entity not found ex", ex.getMessage());
      return new ResponseEntity<>(apiError, HttpStatus.NOT_FOUND);
    }

}

Теперь в ответ на запрос

GET localhost:8080/persons/2

мы получаем статус 404 с телом:

{
    "message": "entity not found ex",
    "debugMessage": "Entity is not found, id=2"
}

Но помимо MyEntityNotFoundException, наш обработчик  поддерживает и javax.persistence.EntityNotFoundException (см. код выше).

Попробуем сделать так, чтобы оно возникло.

Это исключение EntityNotFoundException возникает в методе updatePerson() в контроллера. А именно, когда мы обращаемся с помощью метода PUT к несуществующей сущности в попытке назначить ей имя:

PUT localhost:8080/persons/2

{
    "name": "new name"
}

В этом случае мы тоже получим ответ с новой структурой:

{
    "message": "entity not found ex",
    "debugMessage": "Unable to find ru.sysout.model.Person with id 2"
}

Итого, обработчик в @ControllerAdvice позволил изменить не только код ответа, но и тело сообщение. Причем один обработчик мы применили для двух исключений.

Последовательность проверок

Обратите внимание, что MyEntityNotFoundException мы «обработали» дважды — изменили код с помощью @ResponseStatus (1) и прописали в @ContollerAdvice — тут изменили как код, так и тело ответа (2). Эти обработки могли быть противоречивы, но существует приоритет:

  • Когда выбрасывается исключение MyEntityNotFoundException, сначала Spring проверяет @ControllerAdvice-класс. А именно, нет ли в нем обработчика, поддерживающего наше исключение. Если обработчик есть, то исключение в нем и обрабатывается. В этом случае код @ResponseStatus значения не имеет, и в BasicErrorController исключение тоже не идет.
  • Если исключение не поддерживается в @ControllerAdvice-классе, то оно идет в BasicErrorController. Но перед этим Spring проверяет, не аннотировано ли исключение аннотацией @ResponseStatus. Если да, то код ответа меняется, как указано в @ResponseStatus. Далее формируется ответ в BasicErrorController.
  • Если же первые два условия не выполняются, то исключение обрабатывается сразу в BasicErrorController — там формируется стандартный ответ со стандартным кодом (для пользовательских исключений он равен 500).

Но и стандартный ответ можно изменить, для этого нужно расширить класс DefaultErrorAttributes.

Попробуем это сделать.

Изменение DefaultErrorAttributes

Давайте добавим в стандартный ответ еще одно поле. Для этого расширим класс:

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {


    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
        Map<String, Object> errorAttributes=super.getErrorAttributes(webRequest, options);
        errorAttributes.put("newAttribute", "value");
        return errorAttributes;
    }

}

В Map errorAttributes перечисляются поля ответа. Мы взяли их из родительского метода и добавили свое поле newAttribute.

Чтобы выполнить проверку, надо убрать @ControllerAdvice, поскольку он самый приоритетный и с ним мы даже не дойдем до BasicErrorController со «стандартными» полями.

Далее запросим ресурс:

localhost:8080/persons/2

Получим ответ:

{
    "timestamp": "2021-02-28T18:50:14.479+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "Entity is not found, id=2",
    "path": "/persons/2",
    "newAttribute": "value"
}

В JSON-ответе появилось дополнительное поле.

ResponseStatusException

Рассмотрим еще один вариант, позволяющий сразу протолкнуть код ответа и сообщение стандартные поля, не прописывая обработку пользовательских или  встроенных исключений.  А вместо этого просто выбросив специально предназначенное исключение ResponseStatusException.

Изменим код метода контроллера getPerson():

@GetMapping(value = "/{personId}")
public Person getPerson(@PathVariable("personId") long personId){
    return personRepository.findById(personId).orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND, "Person Not Found"));
}

Теперь тут не выбрасывается ни MyEntityNotFoundException, ни java.util.NoSuchElementException. А выбрасывается ResponseStatusException с заданным сообщением и кодом ответа.

Теперь при запросе

GET localhost:8080/persons/2

ответ будет таким:

{
    "timestamp": "2021-03-01T07:42:19.164+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "Person Not Found",
    "path": "/persons/2",
    "newAttribute": "value"
}

Как код, так и сообщение появилось в полях стандартного ответа.

ResponseStatusException не вступает в конкуренцию ни со способом @ControllerAdvice, ни с @ResponseStatus — просто потому, что это другое исключение.

Итоги

Код примера доступен на GitHub. В следующей части мы унаследуем RestExceptionHandler от ResponseEntityExceptionHandler. Это класс-заготовка, которая уже обрабатывает ряд исключений.

Понравилась статья? Поделить с друзьями:
  • Обработка ошибок python raise
  • Обработка ошибки 403
  • Обработка ошибки 401
  • Обработка 500 ошибки
  • Обработка 404 ошибки php