Rest обработка ошибок

Разберемся, как правильно обрабатывать исключения в SpringBoot сервисах с помощью ControllerAdvice и RestControllerAdvice аннотации.

Во время работы вашего приложения часто будут возникать исключительные ситуации. Когда у вас простое консольное приложение, то все просто – ошибка выводится в консоль. Но как быть с веб-приложением?

Допустим у пользователя отсутсвует доступ, или он передал некорректные данные. Лучшим вариантом будет в ответ на такие ситуации, отправлять пользователю сообщения с описанием ошибки. Это позволит клиенту вашего API скорректировать свой запрос.

В данной статье разберём основные возможности, которые предоставляет SpringBoot для решения этой задачи и на простых примерах посмотрим как всё работает.

@ExceptionHandler

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

Для примера у нас будет сущность Person, бизнес сервис к ней и контроллер. Контроллер имеет один эндпойнт, который возвращает пользователя по логину. Рассмотрим классы нашего приложения:

Сущность Person:

package dev.struchkov.general.sort; import java.text.MessageFormat;

public class Person { private String lastName; private String firstName; private Integer age; //getters and setters }

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

package dev.struchkov.example.controlleradvice.controller; import dev.struchkov.example.controlleradvice.domain.Person; import dev.struchkov.example.controlleradvice.service.PersonService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.UUID;

@Slf4j @RestController @RequestMapping("api/person") @RequiredArgsConstructor public class PersonController { private final PersonService personService; @GetMapping public ResponseEntity<Person> getByLogin(@RequestParam("login") String login) { return ResponseEntity.ok(personService.getByLoginOrThrown(login)); } @GetMapping("{id}") public ResponseEntity<Person> getById(@PathVariable("id") UUID id) { return ResponseEntity.ok(personService.getById(id).orElseThrow()); } }

И наконец PersonService, который будет возвращать исключение NotFoundException, если пользователя не будет в мапе persons.

package dev.struchkov.example.controlleradvice.service; import dev.struchkov.example.controlleradvice.domain.Person; import dev.struchkov.example.controlleradvice.exception.NotFoundException; import lombok.NonNull; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.UUID;

@Service public class PersonService { private final Map<UUID, Person> people = new HashMap<>(); public PersonService() { final UUID komarId = UUID.randomUUID(); people.put(komarId, new Person(komarId, "komar", "Алексей", "ertyuiop")); } public Person getByLoginOrThrown(@NonNull String login) { return people.values().stream() .filter(person -> person.getLogin().equals(login)) .findFirst() .orElseThrow(() -> new NotFoundException("Пользователь не найден")); } public Optional<Person> getById(@NonNull UUID id) { return Optional.ofNullable(people.get(id)); } }

Перед тем, как проверить работу исключения, давайте посмотрим на успешную работу эндпойнта.

Все отлично. Нам в ответ пришел код 200, а в теле ответа пришел JSON нашей сущности. А теперь мы отправим запрос с логином пользователя, которого у нас нет. Посмотрим, что сделает Spring по умолчанию.

Обратите внимание, ошибка 500 – это стандартный ответ Spring на возникновение любого неизвестного исключения. Также исключение было выведено в консоль.

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

@RequestMapping("api/person")
@RequiredArgsConstructor
public class PersonController {

    private final PersonService personService;

    @GetMapping
    public ResponseEntity<Person> getByLogin(@RequestParam("login") String login) {
        return ResponseEntity.ok(personService.getByLoginOrThrown(login));
    }

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorMessage> handleException(NotFoundException exception) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ErrorMessage(exception.getMessage()));
    }

}

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

Но теперь вернулся 200 http код, куда корректнее вернуть 404 код.

Однако некоторые разработчики предпочитают возвращать объект, вместо ResponseEntity<T>. Тогда вам необходимо воспользоваться аннотацией @ResponseStatus.

    import org.springframework.web.bind.annotation.ResponseStatus;

    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(NotFoundException.class)
    public ErrorMessage handleException(NotFoundException exception) {
        return new ErrorMessage(exception.getMessage());
    }

Если попробовать совместить ResponseEntity<T> и @ResponseStatus, http-код будет взят из ResponseEntity<T>.

Главный недостаток @ExceptionHandler в том, что он определяется для каждого контроллера отдельно. Обычно намного проще обрабатывать все исключения в одном месте.

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

HandlerExceptionResolver

Как мы знаем в программировании магии нет, какой механизм задействуется, чтобы перехватывать исключения?

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

Давайте разберем стандартные для начала:

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

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

Exception HTTP Status Code
BindException 400 (Bad Request)
ConversionNotSupportedException 500 (Internal Server Error)
HttpMediaTypeNotAcceptableException 406 (Not Acceptable)
HttpMediaTypeNotSupportedException 415 (Unsupported Media Type)
HttpMessageNotReadableException 400 (Bad Request)
HttpMessageNotWritableException 500 (Internal Server Error)
HttpRequestMethodNotSupportedException 405 (Method Not Allowed)
MethodArgumentNotValidException 400 (Bad Request)
MissingServletRequestParameterException 400 (Bad Request)
MissingServletRequestPartException 400 (Bad Request)
NoSuchRequestHandlingMethodException 404 (Not Found)
TypeMismatchException 400 (Bad Request)

Мы можем создать собственный HandlerExceptionResolver. Назовем его CustomExceptionResolver и вот как он будет выглядеть:

package dev.struchkov.example.controlleradvice.service;

import dev.struchkov.example.controlleradvice.exception.NotFoundException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver; import org.springframework.web.servlet.view.json.MappingJackson2JsonView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;

@Component public class CustomExceptionResolver extends AbstractHandlerExceptionResolver { @Override protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception e) { final ModelAndView modelAndView = new ModelAndView(new MappingJackson2JsonView()); if (e instanceof NotFoundException) { modelAndView.setStatus(HttpStatus.NOT_FOUND); modelAndView.addObject("message", "Пользователь не найден"); return modelAndView; } modelAndView.setStatus(HttpStatus.INTERNAL_SERVER_ERROR); modelAndView.addObject("message", "При выполнении запроса произошла ошибка"); return modelAndView; } }

Мы создаем объект представления – ModelAndView, который будет отправлен пользователю, и заполняем его. Для этого проверяем тип исключения, после чего добавляем в представление сообщение о конкретной ошибке и возвращаем представление из метода. Если ошибка имеет какой-то другой тип, который мы не предусмотрели в этом обработчике, то мы отправляем сообщение об ошибке при выполнении запроса.

Так как мы пометили этот класс аннотацией @Component, Spring сам найдет и внедрит наш резолвер куда нужно. Посмотрим, как Spring хранит эти резолверы в классе DispatcherServlet.

Все резолверы хранятся в обычном ArrayList и в случае исключнеия вызываются по порядку, при этом наш резолвер оказался последним. Таким образом, если непосредственно в контроллере окажется @ExceptionHandler обработчик, то наш кастомный резолвер не будет вызван, так как обработка будет выполнена в ExceptionHandlerExceptionResolver.

Важное замечание. У меня не получилось перехватить здесь ни одно Spring исключение, например MethodArgumentTypeMismatchException, которое возникает если передавать неверный тип для аргументов @RequestParam.

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

@RestControllerAdvice

Исключения возникают в разных сервисах приложения, но удобнее всего обрабатывать все исключения в каком-то одном месте. Именно для этого в SpringBoot предназначены аннотации @ControllerAdvice и @RestControllerAdvice. В статье мы рассмотрим @RestControllerAdvice, так как у нас REST API.

На самом деле все довольно просто. Мы берем методы помеченные аннотацией @ExceptionHandler, которые у нас были в контроллерах и переносим в отдельный класс аннотированный @RestControllerAdvice.

package dev.struchkov.example.controlleradvice.controller; import dev.struchkov.example.controlleradvice.domain.ErrorMessage; import dev.struchkov.example.controlleradvice.exception.NotFoundException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@RestControllerAdvice public class ExceptionApiHandler { @ExceptionHandler(NotFoundException.class) public ResponseEntity<ErrorMessage> notFoundException(NotFoundException exception) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(new ErrorMessage(exception.getMessage())); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity<ErrorMessage> mismatchException(MethodArgumentTypeMismatchException exception) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(new ErrorMessage(exception.getMessage())); } }

За обработку этих методов класса точно также отвечает класс ExceptionHandlerExceptionResolver. При этом мы можем здесь перехватывать даже стандартные исключения Spring, такие как MethodArgumentTypeMismatchException.

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

Еще про обработку

Все написанное дальше относится к любому способу обработки исключений.

Запись в лог

Важно отметить, что исключения больше не записываются в лог. Если помимо ответа пользователю, вам все же необходимо записать это событие в лог, то необходимо добавить строчку записи в методе обработчике, например вот так:

@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorMessage> handleException(NotFoundException exception) {
    log.error(exception.getMessage(), exception);
    return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorMessage(exception.getMessage()));
}

Перекрытие исключений

Вы можете использовать иерархию исключений с наследованием и обработчики исключений для всей своей иерархии. В таком случае обработка исключения будет попадать в самый специализированный обработчик.

Допустим мы бросаем NotFoundException, как в примере выше, который наследуется от RuntimeException. И у вас будет два обработчика исключений для NotFoundException и RuntimeException. Исключение попадет в обработчик для NotFoundException. Если этот обработчик убрать, то попадет в обработчик для RuntimeException.

Резюмирую

Обработка исключений это важная часть REST API. Она позволяет возвращать клиентам информационные сообщения, которые помогут им скорректировать свой запрос.

Мы можем по разному реализовать обработку в зависимости от нашей архитектуры. Предпочитаемым способом считаю вариант с @RestControllerAdvice. Этот вариант самый чистый и понятный.

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 — просто выбрасываем исключение и передаём нужный статус-код. Конечно тут возвращаемся к проблеме отсутствия тела сообщения, но в простых случаях такой подход может быть удобен.

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

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

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

Обработка ошибок ¶

Если при обработке запроса к RESTful API в запросе пользователя обнаруживается ошибка или происходит
что-то непредвиденное на сервере, вы можете просто выбрасывать исключение, чтобы уведомить пользователя о нештатной ситуации.
Если вы можете установить конкретную причину ошибки (например, запрошенный ресурс не существует), вам следует подумать
о том, чтобы выбрасывать исключение с соответствующим кодом состояния HTTP (например, yiiwebNotFoundHttpException,
соответствующее коду состояния 404). Yii отправит ответ с соответствующим
HTTP-кодом и текстом. Он также включит в тело ответа сериализованное представление
исключения. Например:

HTTP/1.1 404 Not Found
Date: Sun, 02 Mar 2014 05:31:43 GMT
Server: Apache/2.2.26 (Unix) DAV/2 PHP/5.4.20 mod_ssl/2.2.26 OpenSSL/0.9.8y
Transfer-Encoding: chunked
Content-Type: application/json; charset=UTF-8

{
    "name": "Not Found Exception",
    "message": "The requested resource was not found.",
    "code": 0,
    "status": 404
}

Сводный список кодов состояния HTTP, используемых REST-фреймворком Yii:

  • 200: OK. Все сработало именно так, как и ожидалось.
  • 201: Ресурс был успешно создан в ответ на POST-запрос. Заголовок Location
    содержит URL, указывающий на только что созданный ресурс.
  • 204: Запрос обработан успешно, и в ответе нет содержимого (для запроса DELETE, например).
  • 304: Ресурс не изменялся. Можно использовать закэшированную версию.
  • 400: Неверный запрос. Может быть связано с разнообразными проблемами на стороне пользователя, такими как неверные JSON-данные
    в теле запроса, неправильные параметры действия, и т.д.
  • 401: Аутентификация завершилась неудачно.
  • 403: Аутентифицированному пользователю не разрешен доступ к указанной точке входа API.
  • 404: Запрошенный ресурс не существует.
  • 405: Метод не поддерживается. Сверьтесь со списком поддерживаемых HTTP-методов в заголовке Allow.
  • 415: Не поддерживаемый тип данных. Запрашивается неправильный тип данных или номер версии.
  • 422: Проверка данных завершилась неудачно (в ответе на POST-запрос, например). Подробные сообщения об ошибках смотрите в теле ответа.
  • 429: Слишком много запросов. Запрос отклонен из-за превышения ограничения частоты запросов.
  • 500: Внутренняя ошибка сервера. Возможная причина — ошибки в самой программе.

Свой формат ответа с ошибкой ¶

Вам может понадобиться изменить формат ответа с ошибкой. Например, вместо использования разных статусов ответа HTTP
для разных ошибок, вы можете всегда отдавать статус 200, а реальный код статуса отдавать как часть JSON ответа:

HTTP/1.1 200 OK
Date: Sun, 02 Mar 2014 05:31:43 GMT
Server: Apache/2.2.26 (Unix) DAV/2 PHP/5.4.20 mod_ssl/2.2.26 OpenSSL/0.9.8y
Transfer-Encoding: chunked
Content-Type: application/json; charset=UTF-8

{
    "success": false,
    "data": {
        "name": "Not Found Exception",
        "message": "The requested resource was not found.",
        "code": 0,
        "status": 404
    }
}

Для этого можно использовать событие beforeSend компонента response прямо в конфигурации приложения:

return [
    // ...
    'components' => [
        'response' => [
            'class' => 'yiiwebResponse',
            'on beforeSend' => function ($event) {
                $response = $event->sender;
                if ($response->data !== null && !empty(Yii::$app->request->get('suppress_response_code'))) {
                    $response->data = [
                        'success' => $response->isSuccessful,
                        'data' => $response->data,
                    ];
                    $response->statusCode = 200;
                }
            },
        ],
    ],
];

Приведённый выше код изменит формат ответа (как для удачного запроса, так и для ошибок) если передан GET-параметр
suppress_response_code.

Автор оригинала: Justin Albano.

1. введение

REST-это архитектура без состояния, в которой клиенты могут получать доступ к ресурсам на сервере и управлять ими. Как правило, службы REST используют HTTP для объявления набора ресурсов, которыми они управляют, и предоставляют API, который позволяет клиентам получать или изменять состояние этих ресурсов.

В этом уроке мы познакомимся с некоторыми из лучших практик обработки ошибок REST API, включая полезные подходы для предоставления пользователям соответствующей информации, примеры из крупномасштабных веб-сайтов и конкретную реализацию с использованием примера приложения Spring REST.

Дальнейшее чтение:

Обработка ошибок для ОТДЫХА с пружиной

Spring ResponseStatusException

2. Коды состояния HTTP

Когда клиент делает запрос к HTTP — серверу — и сервер успешно получает запрос – сервер должен уведомить клиента, был ли запрос успешно обработан или нет . HTTP выполняет это с помощью пяти категорий кодов состояния:

  • 100-уровневый (информационный) — Сервер подтверждает запрос
  • 200-уровень (Успех) — Сервер выполнил запрос, как и ожидалось
  • 300-уровень (Перенаправление) — Клиенту необходимо выполнить дальнейшие действия для завершения запроса
  • 400-level (Ошибка клиента) — Клиент отправил неверный запрос
  • 500-level (Server error) — Серверу не удалось выполнить допустимый запрос из-за ошибки с сервером

Основываясь на коде ответа, клиент может предположить результат конкретного запроса.

3. Обработка Ошибок

Первым шагом в обработке ошибок является предоставление клиенту правильного кода состояния. Кроме того, нам может потребоваться предоставить дополнительную информацию в теле ответа.

3.1. Основные Ответы

Самый простой способ обработки ошибок-это ответить соответствующим кодом состояния .

Некоторые распространенные коды ответов включают в себя:

  • 400 Плохой запрос — Клиент отправил недопустимый запрос — например, отсутствует требуемое тело запроса или параметр
  • 401 Неавторизованный — Клиенту не удалось пройти аутентификацию на сервере
  • 403 Forbidden — Клиент аутентифицирован, но не имеет разрешения на доступ к запрошенному ресурсу
  • 404 Не найден — Запрошенный ресурс не существует
  • 412 Ошибка предварительного условия — одно или несколько условий в полях заголовка запроса оцениваются как ложные.
  • 500 Внутренняя ошибка сервера — На сервере произошла общая ошибка
  • 503 Услуга Недоступна — Запрошенная услуга недоступна

Будучи базовыми, эти коды позволяют клиенту понять широкий характер возникшей ошибки. Например, мы знаем, если получаем ошибку 403, что у нас нет разрешений на доступ к запрошенному ресурсу.

Во многих случаях, однако, мы должны предоставить дополнительные детали в наших ответах.

500 ошибок сигнализируют о том, что при обработке запроса на сервере возникли некоторые проблемы или исключения. Как правило, эта внутренняя ошибка не является делом нашего клиента.

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

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

3.2. Ответы На Весенние ошибки По Умолчанию

Эти принципы настолько распространены, что Spring кодифицировала их в своем механизме обработки ошибок по умолчанию .

Чтобы продемонстрировать это, предположим, что у нас есть простое приложение Spring REST , которое управляет книгами, с конечной точкой для извлечения книги по ее идентификатору:

curl -X GET -H "Accept: application/json" http://localhost:8082/spring-rest/api/book/1

Если нет книги с идентификатором 1, мы ожидаем, что наш контроллер выдаст исключение BookNotFoundException . Выполняя GET на этой конечной точке, мы видим, что это исключение было выброшено, и тело ответа:

{
    "timestamp":"2019-09-16T22:14:45.624+0000",
    "status":500,
    "error":"Internal Server Error",
    "message":"No message available",
    "path":"/api/book/1"
}

Обратите внимание, что этот обработчик ошибок по умолчанию включает метку времени возникновения ошибки, код состояния HTTP, заголовок (поле error ), сообщение (по умолчанию пустое) и URL-адрес, по которому произошла ошибка.

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

Кроме того, обратите внимание, что Spring автоматически возвращает код состояния HTTP 500 при вызове нашего BookNotFoundException . Хотя некоторые API будут возвращать код состояния 500 или другие общие коды, как мы увидим с API Facebook и Twitter — для всех ошибок ради простоты лучше всего использовать наиболее конкретный код ошибки, когда это возможно .

В нашем примере мы можем добавить @ControllerAdvice так, чтобы при возникновении BookNotFoundException наш API возвращал статус 404 для обозначения Not Found вместо 500 Internal Server Error .

3.3. Более Подробные Ответы

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

  • Error — Уникальный идентификатор ошибки
  • Сообщение — Краткое читаемое человеком сообщение
  • Детализация — более подробное объяснение ошибки

Например, если клиент отправляет запрос с неверными учетными данными, мы можем отправить ответ 401 с телом:

{
    "error": "auth-0001",
    "message": "Incorrect username and password",
    "detail": "Ensure that the username and password included in the request are correct"
}

Поле error не должно совпадать с кодом ответа . Вместо этого это должен быть код ошибки, уникальный для нашего приложения. Как правило, нет никакого соглашения для поля error , ожидайте, что оно будет уникальным.

Обычно это поле содержит только буквенно-цифровые символы и соединительные символы, такие как тире или подчеркивание. Например, 0001 , auth-0001 и incorrect-user-pass являются каноническими примерами кодов ошибок.

Часть тела message обычно считается презентабельной на пользовательских интерфейсах. Поэтому мы должны перевести это название, если мы поддерживаем интернационализацию . Таким образом, если клиент отправляет запрос с заголовком Accept-Language , соответствующим французскому языку, значение title должно быть переведено на французский.

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

Кроме того, мы также можем предоставить URL — адрес — например, поле help , по которому клиенты могут перейти для получения дополнительной информации:

{
    "error": "auth-0001",
    "message": "Incorrect username and password",
    "detail": "Ensure that the username and password included in the request are correct",
    "help": "https://example.com/help/error/auth-0001"
}

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

{
    "errors": [
        {
            "error": "auth-0001",
            "message": "Incorrect username and password",
            "detail": "Ensure that the username and password included in the request are correct",
            "help": "https://example.com/help/error/auth-0001"
        },
        ...
    ]
}

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

3.4. Стандартизированные Органы реагирования

В то время как большинство API REST следуют аналогичным соглашениям, специфика обычно варьируется, включая имена полей и информацию, включенную в тело ответа. Эти различия затрудняют единообразную обработку ошибок библиотеками и фреймворками.

Стремясь стандартизировать обработку ошибок REST API, |/IETF разработала RFC 7807 , который создает обобщенную схему обработки ошибок .

Эта схема состоит из пяти частей:

  1. тип — Идентификатор URI, который классифицирует ошибку
  2. заголовок — Краткое, читаемое человеком сообщение об ошибке
  3. status — Код ответа HTTP (необязательно)
  4. подробно — Читаемое человеком объяснение ошибки
  5. экземпляр — URI, идентифицирующий конкретное возникновение ошибки

Вместо того чтобы использовать наше пользовательское тело ответа на ошибку, мы можем преобразовать ваше тело в:

{
    "type": "/errors/incorrect-user-pass",
    "title": "Incorrect username or password.",
    "status": 401,
    "detail": "Authentication failed due to incorrect username or password.",
    "instance": "/login/log/abc123"
}

Обратите внимание, что поле type классифицирует тип ошибки, в то время как instance идентифицирует конкретное возникновение ошибки аналогично классам и объектам соответственно.

Используя URI, клиенты могут следовать этим путям, чтобы найти дополнительную информацию об ошибке таким же образом, как ссылки HATEOAS могут использоваться для навигации по REST API.

Придерживаться RFC 7807 необязательно, но выгодно, если требуется однородность.

4. Примеры

Описанные выше методы являются общими для некоторых наиболее популярных API REST. В то время как конкретные имена полей или форматов могут варьироваться между сайтами, общие шаблоны почти универсальны .

4.1. Твиттер

Например, давайте отправим запрос GET без предоставления необходимых аутентификационных данных:

curl -X GET https://api.twitter.com/1.1/statuses/update.json?include_entities=true

API Twitter отвечает ошибкой со следующим телом:

{
    "errors": [
        {
            "code":215,
            "message":"Bad Authentication data."
        }
    ]
}

Этот ответ включает в себя список, содержащий одну ошибку, с ее кодом ошибки и сообщением. В случае Twitter нет подробного сообщения, и общая ошибка — а не более конкретная ошибка 401 — используется для обозначения того, что аутентификация не удалась.

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

4.2. Facebook

Подобно Twitter, Facebook Graph REST API также включает подробную информацию в свои ответы.

Например, давайте выполним POST-запрос для аутентификации с помощью API Facebook Graph:

curl -X GET https://graph.facebook.com/oauth/access_token?client_id=foo&client_secret=bar&grant_type=baz

Мы получаем следующую ошибку:

{
    "error": {
        "message": "Missing redirect_uri parameter.",
        "type": "OAuthException",
        "code": 191,
        "fbtrace_id": "AWswcVwbcqfgrSgjG80MtqJ"
    }
}

Как и Twitter, Facebook также использует общую ошибку — а не более конкретную ошибку 400-го уровня — для обозначения сбоя. В дополнение к сообщению и числовому коду Facebook также включает в себя поле type , которое классифицирует ошибку, и идентификатор трассировки ( fbtrace_id ), который действует как внутренний идентификатор поддержки .

5. Заключение

В этой статье мы рассмотрели некоторые из лучших практик обработки ошибок REST API, в том числе:

  • Предоставление конкретных кодов состояния
  • Включение дополнительной информации в органы реагирования
  • Единообразная обработка исключений

Хотя детали обработки ошибок будут варьироваться в зависимости от приложения, эти общие принципы применимы почти ко всем API REST и должны соблюдаться, когда это возможно .

Это не только позволяет клиентам последовательно обрабатывать ошибки, но и упрощает код, который мы создаем при реализации REST API.

Код, на который ссылается эта статья, доступен на GitHub .

Editor’s note: This article was updated on September 5, 2022, by our editorial team. It has been modified to include recent sources and to align with our current editorial standards.

The ability to handle errors correctly in APIs while providing meaningful error messages is a desirable feature, as it can help the API client respond to issues. The default behavior returns stack traces that are hard to understand and ultimately useless for the API client. Partitioning the error information into fields enables the API client to parse it and provide better error messages to the user. In this article, we cover how to implement proper Spring Boot exception handling when building a REST API .

Building REST APIs with Spring became the standard approach for Java developers. Using Spring Boot helps substantially, as it removes a lot of boilerplate code and enables auto-configuration of various components. We assume that you’re familiar with the basics of API development with those technologies. If you are unsure about how to develop a basic REST API, you should start with this article about Spring MVC or this article about building a Spring REST Service.

Making Error Responses Clearer

We’ll use the source code hosted on GitHub as an example application that implements a REST API for retrieving objects that represent birds. It has the features described in this article and a few more examples of error handling scenarios. Here’s a summary of endpoints implemented in that application:

GET /birds/{birdId} Gets information about a bird and throws an exception if not found.
GET /birds/noexception/{birdId} This call also gets information about a bird, except it doesn’t throw an exception when a bird doesn’t exist with that ID.
POST /birds Creates a bird.

The Spring framework MVC module has excellent features for error handling. But it is left to the developer to use those features to treat the exceptions and return meaningful responses to the API client.

Let’s look at an example of the default Spring Boot answer when we issue an HTTP POST to the /birds endpoint with the following JSON object that has the string “aaa” on the field “mass,” which should be expecting an integer:

{
 "scientificName": "Common blackbird",
 "specie": "Turdus merula",
 "mass": "aaa",
 "length": 4
}

The Spring Boot default answer, without proper error handling, looks like this:

{
 "timestamp": 1658551020,
 "status": 400,
 "error": "Bad Request",
 "exception": "org.springframework.http.converter.HttpMessageNotReadableException",
 "message": "JSON parse error: Unrecognized token 'three': was expecting ('true', 'false' or 'null'); nested exception is com.fasterxml.jackson.core.JsonParseException: Unrecognized token 'aaa': was expecting ('true', 'false' or 'null')n at [Source: java.io.PushbackInputStream@cba7ebc; line: 4, column: 17]",
 "path": "/birds"
}

The Spring Boot DefaultErrorAttributes-generated response has some good fields, but it is too focused on the exception. The timestamp field is an integer that doesn’t carry information about its measurement unit. The exception field is only valuable to Java developers, and the message leaves the API consumer lost in implementation details that are irrelevant to them. What if there were more details we could extract from the exception? Let’s learn how to handle exceptions in Spring Boot properly and wrap them into a better JSON representation to make life easier for our API clients.

As we’ll be using Java date and time classes, we first need to add a Maven dependency for the Jackson JSR310 converters. They convert Java date and time classes to JSON representation using the @JsonFormat annotation:

<dependency>
   <groupId>com.fasterxml.jackson.datatype</groupId>
   <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

Next, let’s define a class for representing API errors. We’ll create a class called ApiError with enough fields to hold relevant information about errors during REST calls:

class ApiError {

   private HttpStatus status;
   @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
   private LocalDateTime timestamp;
   private String message;
   private String debugMessage;
   private List<ApiSubError> subErrors;

   private ApiError() {
       timestamp = LocalDateTime.now();
   }

   ApiError(HttpStatus status) {
       this();
       this.status = status;
   }

   ApiError(HttpStatus status, Throwable ex) {
       this();
       this.status = status;
       this.message = "Unexpected error";
       this.debugMessage = ex.getLocalizedMessage();
   }

   ApiError(HttpStatus status, String message, Throwable ex) {
       this();
       this.status = status;
       this.message = message;
       this.debugMessage = ex.getLocalizedMessage();
   }
}
  • The status property holds the operation call status, which will be anything from 4xx to signal client errors or 5xx to signal server errors. A typical scenario is an HTTP code 400: BAD_REQUEST when the client, for example, sends an improperly formatted field, like an invalid email address.

  • The timestamp property holds the date-time instance when the error happened.

  • The message property holds a user-friendly message about the error.

  • The debugMessage property holds a system message describing the error in detail.

  • The subErrors property holds an array of suberrors when there are multiple errors in a single call. An example would be numerous validation errors in which multiple fields have failed. The ApiSubError class encapsulates this information:

abstract class ApiSubError {

}

@Data
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
class ApiValidationError extends ApiSubError {
   private String object;
   private String field;
   private Object rejectedValue;
   private String message;

   ApiValidationError(String object, String message) {
       this.object = object;
       this.message = message;
   }
}

The ApiValidationError is a class that extends ApiSubError and expresses validation problems encountered during the REST call.

Below, you’ll see examples of JSON responses generated after implementing these improvements.

Here is a JSON example returned for a missing entity while calling endpoint GET /birds/2:

{
 "apierror": {
   "status": "NOT_FOUND",
   "timestamp": "22-07-2022 06:20:19",
   "message": "Bird was not found for parameters {id=2}"
 }
}

Here is another example of JSON returned when issuing a POST /birds call with an invalid value for the bird’s mass:

{
 "apierror": {
   "status": "BAD_REQUEST",
   "timestamp": "22-07-2022 06:49:25",
   "message": "Validation errors",
   "subErrors": [
     {
       "object": "bird",
       "field": "mass",
       "rejectedValue": 999999,
       "message": "must be less or equal to 104000"
     }
   ]
 }
}

Spring Boot Error Handler

Let’s explore some Spring annotations used to handle exceptions.

RestController is the base annotation for classes that handle REST operations.

ExceptionHandler is a Spring annotation that provides a mechanism to treat exceptions 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 implementation is to use @ExceptionHandler on methods of @ControllerAdvice classes so that the Spring Boot exception handling will be applied globally or to a subset of controllers.

ControllerAdvice is an annotation in Spring and, as the name suggests, is “advice” for multiple controllers. It enables the application of a single ExceptionHandler to multiple controllers. With this annotation, we can define how to treat such an exception in a single place, and the system will call this handler for thrown exceptions on classes covered by this ControllerAdvice.

The subset of controllers affected can be defined by using the following selectors on @ControllerAdvice: annotations(), basePackageClasses(), and basePackages(). ControllerAdvice is applied globally to all controllers if no selectors are provided

By using @ExceptionHandler and @ControllerAdvice, we’ll be able to define a central point for treating exceptions and wrapping them in an ApiError object with better organization than is possible with the default Spring Boot error-handling mechanism.

Handling Exceptions

Next, we’ll create the class that will handle the exceptions. For simplicity, we call it RestExceptionHandler, which must extend from Spring Boot’s ResponseEntityExceptionHandler. We’ll be extending ResponseEntityExceptionHandler, as it already provides some basic handling of Spring MVC exceptions. We’ll add handlers for new exceptions while improving the existing ones.

Overriding Exceptions Handled in ResponseEntityExceptionHandler

If you take a look at the source code of ResponseEntityExceptionHandler, you’ll see a lot of methods called handle******(), like handleHttpMessageNotReadable() or handleHttpMessageNotWritable(). Let’s see how can we extend handleHttpMessageNotReadable() to handle HttpMessageNotReadableException exceptions. We just have to override the method handleHttpMessageNotReadable() in our RestExceptionHandler class:

@Order(Ordered.HIGHEST_PRECEDENCE)
@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {

   @Override
   protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
       String error = "Malformed JSON request";
       return buildResponseEntity(new ApiError(HttpStatus.BAD_REQUEST, error, ex));
   }

   private ResponseEntity<Object> buildResponseEntity(ApiError apiError) {
       return new ResponseEntity<>(apiError, apiError.getStatus());
   }

   //other exception handlers below

}

We have declared that in case of a thrownHttpMessageNotReadableException, the error message will be “Malformed JSON request” and the error will be encapsulated in the ApiError object. Below, we can see the answer of a REST call with this new method overridden:

{
 "apierror": {
   "status": "BAD_REQUEST",
   "timestamp": "22-07-2022 03:53:39",
   "message": "Malformed JSON request",
   "debugMessage": "JSON parse error: Unrecognized token 'aaa': was expecting ('true', 'false' or 'null'); nested exception is com.fasterxml.jackson.core.JsonParseException: Unrecognized token 'aaa': was expecting ('true', 'false' or 'null')n at [Source: java.io.PushbackInputStream@7b5e8d8a; line: 4, column: 17]"
 }
}

Implementing Custom Exceptions

Next, we’ll create a method that handles an exception not yet declared inside Spring Boot’s ResponseEntityExceptionHandler.

A common scenario for a Spring application that handles database calls is to provide a method that returns a record by its ID using a repository class. But if we look into the CrudRepository.findOne() method, we’ll see that it returns null for an unknown object. If our service calls this method and returns directly to the controller, we’ll get an HTTP code 200 (OK) even if the resource isn’t found. In fact, the proper approach is to return a HTTP code 404 (NOT FOUND) as specified in the HTTP/1.1 spec.

We’ll create a custom exception called EntityNotFoundException to handle this case. This one is different from javax.persistence.EntityNotFoundException, as it provides some constructors that ease the object creation, and one may choose to handle the javax.persistence exception differently.

That said, let’s create an ExceptionHandler for this newly created EntityNotFoundException in our RestExceptionHandler class. Create a method called handleEntityNotFound() and annotate it with @ExceptionHandler, passing the class object EntityNotFoundException.class to it. This declaration signalizes Spring that every time EntityNotFoundException is thrown, Spring should call this method to handle it.

When annotating a method with @ExceptionHandler, a wide range of auto-injected parameters like WebRequest, Locale, and others may be specified as described here. We’ll provide the exception EntityNotFoundException as a parameter for this handleEntityNotFound method:

@Order(Ordered.HIGHEST_PRECEDENCE)
@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {
  
   //other exception handlers
  
   @ExceptionHandler(EntityNotFoundException.class)
   protected ResponseEntity<Object> handleEntityNotFound(
           EntityNotFoundException ex) {
       ApiError apiError = new ApiError(NOT_FOUND);
       apiError.setMessage(ex.getMessage());
       return buildResponseEntity(apiError);
   }
}

Great! In the handleEntityNotFound() method, we set the HTTP status code to NOT_FOUND and usethe new exception message. Here is what the response for the GET /birds/2 endpoint looks like now:

{
 "apierror": {
   "status": "NOT_FOUND",
   "timestamp": "22-07-2022 04:02:22",
   "message": "Bird was not found for parameters {id=2}"
 }
}

The Importance of Spring Boot Exception Handling

It is important to control exception handling so we can properly map exceptions to the ApiError object and inform API clients appropriately. Additionally, we would need to create more handler methods (the ones with @ExceptionHandler) for thrown exceptions within the application code. The GitHub code provides more more examples for other common exceptions like MethodArgumentTypeMismatchException, ConstraintViolationException.

Here are some additional resources that helped in the composition of this article:

  • Error Handling for REST With Spring

  • Exception Handling in Spring MVC

Further Reading on the Toptal Engineering Blog:

  • Top 10 Most Common Spring Framework Mistakes
  • Spring Security with JWT for REST API
  • Using Spring Boot for OAuth2 and JWT REST Protection
  • Building an MVC Application With Spring Framework: A Beginner’s Tutorial
  • Spring Batch Tutorial: Batch Processing Made Easy with Spring

Understanding the basics

  • Why should the API have a uniform error format?

    A uniform error format allows an API client to parse error objects. A more complex error could implement the ApiSubError class and provide more details about the problem so the client can know which actions to take.

  • How does Spring know which ExceptionHandler to use?

    The Spring MVC class, ExceptionHandlerExceptionResolver, performs most of the work in its doResolveHandlerMethodException() method.

  • What information is important to provide to API consumers?

    Usually, it is helpful to include the error origination, the input parameters, and some guidance on how to fix the failing call.

В части 1 мы рассмотрели варианты обработки исключений, выбрасываемых в контроллере.

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

В первой части мы создавали @ControllerAdvice с нуля, но в Spring Boot существует заготовка — ResponseEntityExceptionHandler, которую можно расширить. В ней уже обработаны многие исключения, например: NoHandlerFoundException, HttpMessageNotReadableException, MethodArgumentNotValidException и другие (всего десяток-другой исключений).

Приложение

Обрабатывать исключения будем в простом Spring Boot приложении из первой части. Оно предоставляет REST API для сущности Person:

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

    @Size(min = 3, max = 10)
    private String name;
    
}

Только в этот раз поле name аннотировано javax.validation.constraints.Size.

А также перед аргументом Person в методах контроллера стоит аннотация @Valid:

@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 @Valid Person person) {
        return personRepository.save(person);
    }

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

}

Аннотация @Valid заставляет Spring проверять валидность полей объекта Person, например условие @Size(min = 3, max = 10). Если пришедший в контроллер объект не соответствует условиям, то будет выброшено MethodArgumentNotValidException — то самое, для которого в ResponseEntityExceptionHandler уже задан обработчик. Правда, он выдает пустое тело ответа. Вообще все обработчики из ResponseEntityExceptionHandler выдают корректный код ответа, но пустое тело.

Мы это исправим. Поскольку для MethodArgumentNotValidException может возникнуть несколько ошибок (по одной для каждого поля сущности Person), добавим в наше пользовательское тело ответа список List с ошибками. Он предназначен именно для MethodArgumentNotValidException (не для других исключений).

Итак, ApiError по сравнению с 1-ой частью теперь содержит еще список errors:

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

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private List<String> errors;

    public ApiError(String message, String debugMessage){
        this.message=message;
        this.debugMessage=debugMessage;
    }
}

Благодаря аннотации @JsonInclude(JsonInclude.Include.NON_NULL) этот список будет включен в ответ только в том случае, если мы его зададим. Иначе ответ будет содержать только message и debugMessage, как в первой части.

Класс обработки исключений

Например, на исключение MyEntityNotFoundException ответ не поменяется, обработчик такой же, как в первой части:

@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {

   ...

    @ExceptionHandler({MyEntityNotFoundException.class, EntityNotFoundException.class})
    protected ResponseEntity<Object> handleEntityNotFoundEx(MyEntityNotFoundException ex, WebRequest request) {
        ApiError apiError = new ApiError("Entity Not Found Exception", ex.getMessage());
        return new ResponseEntity<>(apiError, HttpStatus.NOT_FOUND);
    }
   ...
}

Но в отличие от 1 части, теперь RestExceptionHandler расширяет ResponseEntityExceptionHandler.  А значит, он наследует различные обработчики исключений, и мы их можем переопределить. Сейчас они все возвращают пустое тело ответа, хотя и корректный код.

HttpMessageNotReadableException

Переопределим обработчик, отвечающий за HttpMessageNotReadableException. Это исключение возникает тогда, когда тело запроса, приходящего в метод контроллер, нечитаемое — например, некорректный JSON.

За это исключение отвечает метод handleHttpMessageNotReadable(), его и переопределим:

@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
                                                              HttpHeaders headers, HttpStatus status, WebRequest request) {
    ApiError apiError = new ApiError("Malformed JSON Request", ex.getMessage());
    return new ResponseEntity(apiError, status);
}

Проверим ответ, сделав запрос с некорректным JSON-телом запроса (он пойдет в метод updatePerson() контроллера):

PUT localhost:8080/persons/1
{
   11"name": "alice"
}

Получаем ответ с кодом 400 (Bad Request) и телом:

{
    "message": "Malformed JSON Request",
    "debugMessage": "JSON parse error: Unexpected character ('1' (code 49)): was expecting double-quote to start field name; nested exception is com.fasterxml.jackson.core.JsonParseException: Unexpected character ('1' (code 49)): was expecting double-quote to start field namen at [Source: (PushbackInputStream); line: 2, column: 5]"
}

Теперь ответ содержит не только корректный код, но и тело с информативными сообщениями. Если бы мы не переопределяли обработчик, вернулся бы только код 400.

А если бы не расширяли класс ResponseEntityExceptionHandler, все эти обработчики в принципе не были бы задействованы и вернулся бы стандартный ответ из BasicErrorController:

{
    "timestamp": "2021-03-01T16:53:04.197+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "JSON parse error: Unexpected character ('1' (code 49)): was expecting double-quote to start field name; nested exception is com.fasterxml.jackson.core.JsonParseException: Unexpected character ('1' (code 49)): was expecting double-quote to start field namen at [Source: (PushbackInputStream); line: 2, column: 5]",
    "path": "/persons/1"
}

MethodArgumentNotValidException

Как говорилось выше, чтобы выбросилось это исключение, в контроллер должен прийти некорректный Person. В смысле корректный JSON, но условие @Valid чтоб не выполнялось: например, поле name имело бы неверную длину (а она должна быть от 3 до 10, как указано в аннотации @Size).

Попробуем сделать запрос с коротким name:

POST http://localhost:8080/persons
{ 
   "name": "al" 
}

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

{
    "message": "Method Argument Not Valid",
    "debugMessage": "Validation failed for argument [0] in public ru.sysout.model.Person ru.sysout.controller.PersonController.createPerson(ru.sysout.model.Person): [Field error in object 'person' on field 'name': rejected value [al]; codes [Size.person.name,Size.name,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [person.name,name]; arguments []; default message [name],10,3]; default message [размер должен находиться в диапазоне от 3 до 10]] ",
    "errors": [
        "размер должен находиться в диапазоне от 3 до 10"
    ]
}

Тут пошел в ход список ошибок, который мы добавили в ApiError. Мы его заполняем в переопределенном обработчике исключения:

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
                                                              HttpHeaders headers, HttpStatus status, WebRequest request) {
    List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(x -> x.getDefaultMessage())
            .collect(Collectors.toList());

    ApiError apiError = new ApiError("Method Argument Not Valid", ex.getMessage(), errors);
    return new ResponseEntity<>(apiError, status);
}

Вообще говоря, стандартный ответ, выдаваемый BasicErrorController, тоже будет содержать этот список ошибок по полям, если в application.properties включить свойство:

server.error.include-binding-errors=always

В этом случае (при отсутствии нашего RestExceptionHandler  с @ControlleAdvice) ответ будет таким:

{
    "timestamp": "2021-03-01T17:15:37.134+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "Validation failed for object='person'. Error count: 1",
    "errors": [
        {
            "codes": [
                "Size.person.name",
                "Size.name",
                "Size.java.lang.String",
                "Size"
            ],
            "arguments": [
                {
                    "codes": [
                        "person.name",
                        "name"
                    ],
                    "arguments": null,
                    "defaultMessage": "name",
                    "code": "name"
                },
                10,
                3
            ],
            "defaultMessage": "размер должен находиться в диапазоне от 3 до 10",
            "objectName": "person",
            "field": "name",
            "rejectedValue": "al",
            "bindingFailure": false,
            "code": "Size"
        }
    ],
    "path": "/persons/"
}

Мы просто сократили информацию.

MethodArgumentTypeMismatchException

Полезно знать еще исключение MethodArgumentTypeMismatchException, оно возникает, если тип аргумента неверный. Например, наш метод контроллера получает Person по id:

@GetMapping(value = "/{personId}")
   public Person getPerson(@PathVariable("personId") Long personId) throws EntityNotFoundException {
       return personRepository.getOne(personId);
   }

А мы передаем не целое, а строковое значение id:

GET http://localhost:8080/persons/mn

Тут то и возникает исключение MethodArgumentTypeMismatchException. Давайте его обработаем:

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
protected ResponseEntity<Object> handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException ex,HttpStatus status,
                                                                  WebRequest request) {
    ApiError apiError = new ApiError();
    apiError.setMessage(String.format("The parameter '%s' of value '%s' could not be converted to type '%s'",
            ex.getName(), ex.getValue(), ex.getRequiredType().getSimpleName()));
    apiError.setDebugMessage(ex.getMessage());
    return new ResponseEntity<>(apiError, status);
}

Проверим ответ сервера (код ответа будет 400):

{
    "message": "The parameter 'personId' of value 'mn' could not be converted to type 'long'",
    "debugMessage": "Failed to convert value of type 'java.lang.String' to required type 'long'; nested exception is java.lang.NumberFormatException: For input string: "mn""
}

NoHandlerFoundException

Еще одно полезное исключение — NoHandlerFoundException. Оно возникает, если на данный запрос не найдено обработчика.

Например, сделаем запрос:

GET http://localhost:8080/pers

По данному адресу у нас нет контроллера, так что возникнет NoHandlerFoundException.  Добавим обработку исключения:

@Override
protected ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException ex, HttpHeaders headers,
                                                               HttpStatus status, WebRequest request) {
    return new ResponseEntity<Object>(new ApiError("No Handler Found", ex.getMessage()), status);
}

Только учтите, для того, чтобы исключение выбрасывалось, надо задать свойства в файле application.properties:

spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false

Проверим ответ сервера (код ответа 404):

{
    "message": "No Handler Found",
    "debugMessage": "No handler found for GET /pers"
}

Если же не выбрасывать NoHandlerFoundException и не пользоваться нашим обработчиком, то ответ от BasicErrorController довольно непонятный, хотя код  тоже 404:

{
    "timestamp": "2021-03-01T17:35:59.204+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/pers"
}

Обработчик по умолчанию

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

@ExceptionHandler(Exception.class)
    protected ResponseEntity<Object> handleAllExceptions(Exception ex, WebRequest request) {
        ApiError apiError = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, "prosto exception", ex);
        return new ResponseEntity<>(apiError, HttpStatus.INTERNAL_SERVER_ERROR);
    }

Заключение

Мы рассмотрели:

  • как сделать обработку исключений в едином классе, аннотированном @ControllerAdvice;
  • как переопределить формат  JSON-ответа, выдаваемого при возникновении исключения;
  • как воспользоваться классом-заготовкой ResponseEntityExceptionHandler и переопределить его обработчики так, чтобы тело ответов не было пустым;

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

Код примера доступен на GitHub.

Понравилась статья? Поделить с друзьями:
  • Rest error code 500
  • Rest error body
  • Rest error 401
  • Rest error 400
  • Rest api выдал ошибку wordpress