И опыт, сын ошибок трудных: обрабатываем ошибки в Spring Boot

9aadd000413a269f21e184572bc4662e.jpg

Долгое время разрабатывая микросервисы в разных командах, я сталкивался с типовой задачей: созданием максимально информативного ответа на запрос, когда произошла какая-то ошибка. Особенно это актуально для систем с пользовательским фронтендом, большим количеством интеграций или систем, которые представляют свой API как продукт. Во многих случаях это решалось выдачей сообщения «Ошибка системы» с HTTP-кодом 500. Из раза в раз меня не покидало ощущение, что решению этой задачи не уделяется должного внимания и времени. В текущем проекте нам пришлось пройти все круги ада, изменить несколько подходов и реализаций. И здесь я постарался описать, как это было, и сформулировать выводы, которые мы сделали на каждом шаге решения проблемы.

Коротко о системе

Все микросервисы нашей системы унифицированы и построены по классическим канонам Spring Boot. Под капотом мы имеем Java 11/17 и Spring Boot 2.6. Так было до недавнего времени, пока мы не переехали на Spring Boot 3.2. Функциональность в чистом виде можно описать так: к нам приходят запросы с фронтенда или из других сервисов, данные обрабатываются с последующим чтением или записью в БД и синхронными и асинхронными запросами в другие системы. На уровне архитектуры кода есть три слоя:

  1. View — обработчики запросов, как правило это RestController.

  2. Service — обработчики бизнес‑логики, компоненты, мапперы.

  3. DAO — слой доступа к данным. Условно примем, что это классы для общения с БД или клиенты к другим системам для обмена сообщениями.

Архитектура приложения

Архитектура приложения

Так какие блоки кода могут инициировать ошибки, о которых мы будем говорить? Кроме вышеописанных слоёв, которые контролируются с помощью Spring, ошибки могут возникать в подключаемых стартерах, утилитарных классах со статическими методами, мапперах, прочих вспомогательных классах (вроде аспектов или HTTP-фильтров) и в самой Java Core.

Инструменты

Для обработки ошибок и составления корректного ответа не будем изобретать велосипед и остановимся на стандартных инструментах. Коротко их рассмотрим.

1. Исключения

В Java все обрабатываемые ошибки описываются классом Exception. Объекты этого класса immutable, поэтому все основные значения задаются через конструктор. Нам достаточно рассмотреть тот, что принимает аргумент message (сообщение) и cause (другое исключение, описывающее причину):

package java.lang;

public class Exception extends Throwable {

    public Exception(String message, Throwable cause) {
        super(message, cause);
    }

}

Сразу стоит вспомнить про иерархию исключений.

Иерархия исключений Java

Иерархия исключений Java

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

public class SystemException extends RuntimeException {
    
    private final String systemId;
    /*...*/
    public Exception(String message, Throwable cause, String systemId) {
        super(message, cause);
        this.systemId = systemId;
    }
    /*...*/
}

Исключения в приложении могут инициироваться в явном и неявном виде. В первом случае в Java используется оператор throw.

public class MyService {

    public void doThrowArifmethicException(int a, int b){
        return a/b
    }

    public void doThrowRuntimeException() {
        throw new RuntimeException("Something wrong");
    }
}

Для обработки ошибок в коде используется блок try-catch:

package com.company;

public class MyService {

    public void doSomething() {
        try {
            //call code with execution error 
            doSomethingWithError();
        } catch (CustomException e) {
            handleCustomException(e);
        } catch (NullPointerException e) {
            handleNullPointerException(e);
        } catch (Exception e) {
            throw new MyException(e);
        }
    }
}

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

Говоря об обработке исключений, стоит вспомнить о процессе «всплытия» ошибок: если исключение было выкинуто и не перехвачено, то оно перейдёт в вышестоящий метод, который прервётся на строчке вызова метода, инициировавшего исключение, и так произойдёт до блока try-catch или самого верхнего класса по иерархии вызовов.

Всплытие исключений Java

Всплытие исключений Java

2. Spring @ControllerAdvice

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

@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
    //...
    @ExceptionHandler
    public ResponseEntity handleNpeException(NullPointerException e, WebRequest webRequest){
        return new ResponseEntity<>("NpeException handled", HttpStatus.BAD_REQUEST);
    }
    
    @ExceptionHandler
    public ResponseEntity handleException(Exception e, WebRequest webRequest){
        return new ResponseEntity<>("Exception handled", HttpStatus.INTERNAL_SERVER_ERROR);
    }
    //...
}

В коде каждого микросервиса мы можем создать обработчик с нужными типами исключений. Для обработки типовых ошибок стоит унаследовать его от готовой реализации ResponseEntityExceptionHandler. Каждый из описанных методов в @ControllerAdvice принимает свой тип исключения и объект request, и возвращает объект, описывающий ошибку, который будет преобразован в JSON ответа. ExceptionHandler срабатывает на уровне аспектов, когда из контроллера «всплывает» ошибка:

Spring @ControllerAdvice

Spring @ControllerAdvice

При поступлении исключения в ExceptionHandler будет вызван соответствующий этому исключению метод, а в случае его отсутствия — метод с вышестоящим по иерархии типом исключения. Чтобы ExceptionHandle гарантированно выдал нужный ответ, следует иметь главный обработчик типа Exception.

3. Spring AOP

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

@Target(value = { ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
    String value() default "";
}

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

@Aspect
@Component
public class MyAspect {

    @AfterThrowing(value = "@annotation(myAnn)", throwing = "ex")
    public void handleMethodException(MyAnnotation myAnn, Throwable ex) throws Throwable {
        //здесь мы можем что-то сделать с объектом аннотации и исключения
        //в конце кидаем исключение, которое прокси вернет
        //как результат работы метода в случае ошибки
        throw new RuntimeException(myAnn.value(), ex);
    }
}

Теперь проксирование позволит нам использовать нашу аннотацию в объектах классов, созданных с помощью Spring:

@Component
public class MyService{
 
 @MyAnnotation("Message from annotation")
 public void someMethod(){ ... }
}

Мы рассмотрели инструментарий, которым будем пользоваться, а теперь перейдём к реализации проекта.

Шаг 0. Используем Spring «из коробки»

Как и практически любую современную систему, нашу создавали в сжатые сроки и инкрементально. На этапе MVP обработке ошибок уделяли минимум времени и внимания, основным требованием было выдавать текст ошибки в своём формате и stacktrace в журнал. Опустив лишние поля, ответ в случае ошибки выглядел так:

{
    "message": "Exception message"
}

Естественно, мы определили DTO ответа и @ControllerAdvice, который формировал ответы в нужном нам формате:

//DTO of error response
public class ErrorResponse {
    private final String message;
}


@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler
    public ResponseEntity exception(Exception e, WebRequest webRequest){
        return new ResponseEntity<>(
            new ErrorResponse("Ошибка сервиса"), HttpStatus.INTERNAL_SERVER_ERROR
        );
    }

    @ExceptionHandler
    public ResponseEntity mappingException(MappingException e, WebRequest webRequest){
        return new ResponseEntity<>(
            new ErrorResponse(e.getMessage()), HttpStatus.BAD_REQUEST
        );
    }
}

В местах, где это требовалось, мы писали собственные типы исключений, просто унаследовав их от RuntimeException:

public class MappingException implements RuntimeException {
    public MappingException(String message){
        super(message);
    }
}

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

public class OperationException implements RuntimeException {

    public static class FirstOperationException extends OperationException { ... }

    public static class SecondOperationException extends OperationException { ... }
}

Другой проблемой оказалось частое использование RuntimeException, просто потому что это быстрее и удобнее. Тем не менее, разбирать постоянные ошибки в логах с типом RuntimeException, по ощущениям, — как гадание на кофейной гуще. Кроме того, и сами сообщения об ошибках оставляли желать лучшего по информативности, а иногда такое сообщение могло быть пустым (поле message равнялось null). Это заставило нас в @ControllerAdvice выводить статичный текст в случае null-значений.

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

Шаг 1. Просто добавим код ошибки и текст сообщения

После окончания MVP и начала эксплуатации системы появилось требование детализировать сообщение, которое выдаётся при ошибочном запросе. Немного дополнили API и поменяли логику заполнения: поле message содержит текст на русском языке для представления на фронтенде, поле code содержит код ошибки (тоже для вывода на фронт), поле description содержит message из исключения:

{
    "code": "000000",
    "message": "Текст ошибки на русском языке",
    "description": "Exception message"
}

Класс, описывающий ответ, дополнили новыми полями и модифицировали @ControllerAdvice:

//DTO of error response
public class ErrorResponse {
    private final String code;
    private final String message;
    private final String description;
}

@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler
    public ResponseEntity mappingException(MappingException e, WebRequest webRequest){
        return new ResponseEntity<>(
            new ErrorResponse("000100", "Ошибка маппинга", e.getMessage()),
            HttpStatus.BAD_REQUEST
        );
    }
    //another handlers
}

Таким образом у нас получилось сделать уникальный код ошибки для каждого типа исключений. Коды ошибок, описанные в одном классе, было легко определить и задокументировать. Главным недостатком такого подхода стала невозможность определить точное место возникновения ошибки, так как одно и то же исключение могло возникнуть в совершенно разных местах (например, RestClientException возникает в каждом REST-клиенте). Да и обработчики не могли покрыть все типы исключений, поэтому основным оставался обработчик типа Exception.

Шаг 2. Очевидное и невероятное

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

public class SystemException implements RuntimeException{
    private final String code;

    public SystemException(String message, Throwable cause, String code) { /*...*/ } 
} 

Далее, в нужных местах мы будем выбрасывать это исключение или его потомков. По сути, мы просто «оборачиваем» возникшее исключение в SystemException, записывая его в cause. Маппинг с ответным сообщение происходит так:

{
    "code": "SystemException.code",
    "message": "SystemException.message",
    "description": "SystemException.cause.message"
}

Такое решение кажется простым и рабочим, но на самом деле мы сталкиваемся со множеством проблем:

  1. Для описания ошибок нужно, по возможности, использовать только SystemException или его потомков, что сужает используемую иерархию исключений.

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

  3. Сильно усложняется логика в классах проекта. При написании кода нужно думать больше о блоках try‑catch, чем о бизнес‑логике. Пример конструкций, которые встречаются повсеместно:

    //Пример плохого кода № 1
    public class MyClient {
        
        public Response call(){
            try{
                //call restTemplate.execute
            } catch (RestClientResponseException e){
                throw new IntegrationException("r111500", "Ошибка ответа", e);
            } catch (RestClientException e){
                throw new SystemException("r111501", e);
            } catch (Exception e){
                throw new RuntimeException("Ошибка интеграции с системой");
            }
        }
    }
    //Пример плохого кода № 2
    public class MyService{
        
        public void doOperation(){
            try{
                //Code of business logic
                if(badCondition){
                    throw new SystemException("r235900", "Операцию не выполняем");
                }
                //Code of buiness logic
            } catch (SystemException e){
                throw e;
            } catch (Exception e){
                throw new SystemException("r235901", "Ошибка операции", e);
            }
        }
    }
  4. Поначалу пытались систематизировать и описать в документации коды ошибок, но не получилось. По факту, при написании кода разработчик придумывал число от 000 001 до 999 999 и вписывал его в код ошибки. Естественно, невозможно сразу ответить на вопрос о месте её возникновения без поиска по тексту проекта.

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

  6. Логика @ControllerAdvice сильно усложняется. Возникшие исключения, которые мы сложили в cause для SystemException, в некоторых случаях требуют дополнительной обработки. И вот таким образом у нас появляются блоки instanceof и прочие «чудеса»:

    @ControllerAdvice
    public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
        //...
        @ExceptionHandler
        public ResponseEntity systemException(SystemException e, WebRequest webRequest){
            var code = isNull(e.getCode()) || e.getCode().isBlank() ? 
                        "r000200" : e.getCode();
            var message = isNull(e.getMessage()) || e.getMessage().isBlank() ? 
                        "Ошибка операции" : e.getMessage();
            
            String cause = "";
            if(e.getCause() != null && e.getCause().getMessage() != null){
                cause = e.getCause().getMessage();
            }
            if(e.getCause() instanceof ExecutionException ee){
                cause = e.getCause().getMessage();
            }
            if(e.getCause() instanceof RestClientResposeException ex){ /*...*/ }
            /*...*/
        }
    
    
        @ExceptionHandler
        public ResponseEntity extendedSystemException(
            ExtendedSystemException e, WebRequest webRequest
        ){
            //another handler for child SystemException
        }
    
        @ExceptionHandler
        public ResponseEntity handleException(Exception e, WebRequest webRequest){
            ErrorResponse errorResponse = new ErrorResponse("r000000", "Ошибка сервиса", e.getMessage());
            return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
        }
        //...
    }
  1. При создании SystemException нам ничто (кроме здравого смысла) не мешает записать null в поля code, message и cause. Теперь иногда мы начинаем получать ошибки при обработке ошибок.

  2. Неудобство при чтении stacktrace. На первом месте теперь наша ошибка, а до реальной трассировки проблемы нужно листать. Для сотрудников поддержки, которые читают журналы, это критичное неудобство.

Шаг 3. Попытка спасти код

Глядя на монструозность повсеместных блоков try-catch, возникла идея сделать рефакторинг и спрятать весь шаблонный код «обёртки» исключений в аспектах. Задача не сложная, для этого сделали следующее:

  1. Написали аннотацию, на которую будет срабатывать аспект.

    @Target(value = { ElementType.METHOD})
    @Retention(value = RetentionPolicy.RUNTIME)
    public @interface ErrorCode {
        //код ошибки
        String value();
        //сообщение об ошибке
        String message() default ""; 
        //тип исключения, который будет выброшен
        Class exception() default SystemException.class;
        //исключения, при которых сработает аспект
        //по умолчанию обрабатываем все
        Class[] on() default {};
        //список дополнительных правил
        Code[] codes() default {};
    
    }

    Для установки дополнительных правил обработки потребовалась ещё одна вложенная аннотация:

    @Target(value = { })
    @Retention(value = RetentionPolicy.RUNTIME)
    public @interface Code {
        //код ошибки
        String value();
        //сообщение об ошибке
        String message() default "";
        //тип исключения, который будет выброшен
        Class exception() default SystemException.class;
        //исключения, которые обработает эта аннотация
        Class[] on();
    }
  2. Написали аспект для обработки аннотаций со следующей логикой:

    @Aspect
    @Component
    public class MyAspect {
    
        @AfterThrowing(value = "@annotation(errorCode)", throwing = "ex")
        public void handleExeption(ErrorCode errorCode, Throwable ex) throws Throwable {
            try {
            /*
                Код этого аспекта принимает аннотацию метода и выброшенное исключение.
                На выходе метод должен выбросить новое исключение. 
                На всякий случай обернем весь код в блок try-catch
                Проверяем тип иключения ex:
                - Если типа SystemException или его потомков, выбрасываем без обработки
                - Если тип совпадает в одним из типов описанных в поле on во вложенных 
                  аннотациях @Code, то данные берем из этой аннотации.
                - Последним условием проверяем что тип исключения входит в список on 
                  аннотации @ErrorCode или спискок on пустой
                Если проверки прошли успешно то создаем через рефлексию и выбрасываем
                SystemException или его потомка указанного в поле exception аннотации. 
                При этом code заполняем из поля value, message из соответствующего поля
                аннотации, исключение ex записываем в cause.
                Если проверка не успешна, то выбрасывам исключение метода без обработки.
            */
            } catch (Exception e){
                throw ex
            }
        }
    }
  3. Пометили все наши методы аннотацией @ErrorCode:

    @Service
    public class MyService {
        
        @ErrorCode(value = "r100500", message="Ошибка операции 1", codes = {
            @Code(value="r100501", message="Нет данных", on = NullPointerException.class),       
            @Code(value="r100502", message="Ошибка расчета", on = CustomException.class)        
        })
        public void doSomething1() { /*...*/ }
    
        @ErrorCode(value = "r100600", message="Ошибка операции 2")
        public void doSomething2() { /*...*/ }
    }

В результате рефакторинга ситуация с обработкой ошибок стала менее прозрачной, но более контролируемой. Мы избавились от блоков try-catch и получили заполненные поля с кодом и текстом ошибки. Код выглядит гораздо лучше, но работает только в контексте Spring Boot и в том случае, если исключение было обработано аспектом.

Шаг 4. Переделываем всё

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

  1. Нужно всё упростить. Сделать обработку ошибок максимально удобной, перестать «выдумывать» коды ошибок, перейти к типизации ошибок через стандартные механизмы Spring и избавиться от instanceof в @ControllerAdvice.

  2. Код ошибки должен быть динамическим и информативным. Взглянув на него, каждый причастный должен понимать, что именно произошло в системе: отвалилась БД или интеграция, получен неожиданный ответ от другой системы, ошибка валидации или же это NullPointer. На каждую из таких ошибок необходима своя реакция:  либо срочно что‑то исправлять,  либо писать в другие команды,  либо просто подождать, пока поднимется инфраструктура. Это требование важно как для промышленной среды, так и для сред интеграционного и функционального тестирования.

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

  4. Нужна большая гибкость и возможность дополнять ответ новыми полями (например прокинуть ID системы, интеграция с которой отвалилась, или код SQL‑ошибки).

  5. Stacktrace должен быть удобочитаемым.

Было очевидно, что практика «обёртки» исключений нам не подходит. А значит, будем записывать нужную информацию о бизнес-ошибке в само всплывающее исключение.

  1. Для начала создали список с типами ошибок нашей системы:

    public enum ErrorType {
        DEFAULT,
        SYSTEM,
        SERVICE,
        REPOSITORY,
        COMPONENT,
        VIEW,
        AUTHORIZATION,
        INPUT_REQUEST,
        INPUT_REQUEST_VALIDATION,
        OUTPUT_RESPONSE,
        OUTPUT_RESPONSE_VALIDATION,
        MAPPING,
        INTEGRATION_SYNC,
        INTEGRATION_ASYNC,
        VALIDATION,
        EXTERNAL_SYSTEM;
    }
  1. Создали новые аннотации для нашего аспекта:

    @Target(value = { ElementType.METHOD})
    @Retention(value = RetentionPolicy.RUNTIME)
    public @interface ErrorDefinition {
        //Код ошибки, никогда не может быть пустым
        String value();
        //Тип ошибки, описан enum из п.1
        ErrorType type() default ErrorType.DEFAULT;
        //Список аннотаций ключ-значение для детализации ошибки
        Detail[] details() default {};
    }
    @Target(value = { })
    @Retention(value = RetentionPolicy.RUNTIME)
    public @interface Detail {
        //наименование параметра
        String key();
        //значение параметра
        String value();
    }
  1. Создали исключение, которое будет хранить нужную информацию:

    public class ErrorDefinitionException extends RuntimeException {
    
        private final String errorCode;
        private final ErrorType type;
        private final Map details;
    
        public ErrorDefinitionException(
                String message, String errorCode, ErrorType type, Map details
        ) { ... }
        
        @Override
        public String getMessage(){ ... }
  1. Написали аспект для обработки исключений методов. Принцип его работы заключается в том, чтобы добавить единственный ErrorDefinitionException в список suppressed возникшего в методе исключения. ErrorDefinitionException заполняется в методе buildException данными аннотации и информацией из объекта joinPoint. Если у нас не указан ErrorType в аннотации, то можем прочитать аннотации класса (@Service, @Repository, @Component и т. д.) и автоматически присвоить тип (слой) ошибки.

    @Aspect
    @Component
    public class ErrorDefinitionAspect {
    
        @AfterThrowing(value = "@annotation(errorDefinition)", throwing = "ex")
        public void handleMethodException(
                JoinPoint joinPoint, 
                ErrorDefinition errorDefinition, 
                Throwable ex
        ) throws Throwable {
            
            val errorDefinitionException = findErrorDefinitionException(ex);
            if(isNull(errorDefinitionException)){ 
                errorDefinitionException = buildException(joinPoint, errorDefinition); 
                ex.addSuppressed(errorDefinitionException); 
            }
            throw ex; 
        }
    }
  1. Для хранения текстов ошибок создали файл error.property в ресурсах проекта:

    r0000=Произошла внутренняя ошибка сервиса
    r0001=Ошибка при обработке входящего запроса
    #..............
    r0011=Не поддерживаемый метод для запрошенного URL
    r0012=Указанный mediaType не поддерживается для запроса
    #..............
  1. Создали MessageSource bean для чтения error.properties:

    @Bean
    @ConditionalOnMissingBean
    public MessageSource errorsMessageSource(){
        val messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setBasenames("classpath:errors");
        messageSource.setUseCodeAsDefaultMessage(true);
        messageSource.setDefaultLocale(Locale.getDefault());
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

Осталось только начать использовать нашу аннотацию в классах Spring, чтобы информация записалась в возникающие исключения:

@Service
public class MyService {
    
    @ErrorDefinition("r1566")
    public void doFirst() { /*...*/  }

    @ErrorDefinition(value = "r1566", type = INTEGRATION_SYNC, details = {
            @Detail(key = SYSTEM_ID, value = "SYS123"),
            @Detail(key = "someKey", value = "someValue")
    })
    public Object doSecond() { /*...*/ }
}

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

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

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

Теперь задача свелась к рекурсивному поиску ErrorDefinitionException в исключении suppressed и вытаскиванию из него нужной информации для создания ErrorResponse. По значению errorCode можно получить текст ошибки на русском языке, а если мы забыли описать его в .properties, то задать стандартный для нашего типа исключений.

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

@Data
@Accessors(fluent = true, chain = true)
@RequiredArgsConstructor
public class ErrorMessage {
    private final Exception ex;
    private final HttpStatus status;
    private final WebRequest request;

    //задать иную логику формирования errorCode в ответе
    private Supplier errorCode;
    //будет использовать код сообщения, если не найден
    private Supplier defaultMessageCode;
    //извлечь cause из сообщения
    private Supplier cause;
    /*...*/

}

Объекты данного класса подаются в фабрику, которая по определенной логике строит ответное сообщение. В нашем случае код @ControllerAdvice выглядит следующим образом:

@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
    
    @Autowired
    private ResponseEntityFactory responseEntityFactory;
    //...
    @ExceptionHandler
    public ResponseEntity executionException(
        ExcecutionException e, 
        WebRequest request
    ) {
        return responseEntityFactory.create(
                new ErrorMessage(e, BAD_REQUEST, request)
                    .defaultMessage(() -> "r0000")
                    .defaultType(() -> ErrorDefinitionException.Type.SERVICE)
                    .cause(()-> ex.getCause())
        );
    }
    //...
}

Естественно, можно обрабатывать и сам ErrorDefinitionException, а для добавления в suppressed использовать static-метод в блоке try-catch вручную, но нам на практике это не понадобилось.

Можно сказать, что этот подход решил все наши проблемы и дал невероятную гибкость. Бонусом мы получили возможность интернационализации сообщений об ошибках. Ещё одним бонусом стала потенциальная возможность вынести все сообщения и коды ошибок в SpringCloud, что позволит менять тексты «на лету».

Заключение

Я постарался описать наш длинный путь к осознанию такой, на первый взгляд, несущественной проблемы, как обработка ошибок. Её сложность неочевидна для бизнеса, зачастую не закладывается в бэклог. Этой проблеме всегда уделяется мало внимания до тех пор, пока поддержка и эксплуатация системы не станет невероятно сложной. В статье не упомянуто про Spring Boot 3.X, в котором @ControllerAdvice претерпел изменения (в нём теперь по умолчанию есть MessageSource, который по типу ошибки может подтянуть сообщения), так как он сложнее для описания, но работает на тех же принципах.

Посмотреть запись митапа, на котором я рассказал о нашем опыте с обработкой ошибок, можно здесь.

© Habrahabr.ru