Introduction and practice of global unified exception handling

Keywords: Java Spring Spring Boot Back-end

PS: This article is detailed, so it is long. Reading time: 30m~1h. Please read it carefully. I hope you can have a clear understanding of unified exception handling in an hour.

1. Background

In the process of software development, it is inevitable to deal with all kinds of exceptions. For myself, at least more than half of the time is dealing with all kinds of exceptions, so a large number of try {...} catch {...} finally {...} code blocks will appear in the code, which not only has a large number of redundant code, but also affects the readability of the code. Compare the following two figures to see which style of code you are writing now? Then which coding style do you prefer?
Ugly try catch code block

Elegant Controller

The above example is only in the Controller layer. If it is in the Service layer, there may be more try catch code blocks. This will seriously affect the readability and "Aesthetics" of the code.

So if it's me, I definitely prefer the second one. I can focus more on the development of business code, and the code will become more concise.

Since the business code does not explicitly capture and handle exceptions, exceptions must be handled. Otherwise, the system will crash all the time. Therefore, there must be other places to capture and handle these exceptions.

So the question is, how to handle all kinds of exceptions gracefully?

2. What is unified exception handling

Spring added an annotation @ ControllerAdvice in version 3.2, which can be combined with @ ExceptionHandler, @ InitBinder, @ ModelAttribute   Annotations are used together. I won't elaborate on the functions of these annotations here. If you don't understand them, you can refer to the new annotation @ ControllerAdvice of spring 3.2 for a general understanding.

Only the annotation @ ExceptionHandler is related to exception handling. Literally, it means exception handler. Its actual function is to define an exception handling method and add the annotation to the method. When a specified exception occurs during the execution of the Controller method, the exception handling method will be executed.
It can use the data binding provided by spring MVC, such as injecting HttpServletRequest, and can also accept a Throwable object currently thrown.

However, in this way, you must define a set of such exception handling methods in each Controller class, because exceptions can be various. In this way, a lot of redundant code will be generated, and if you need to add an exception handling logic, you must modify all Controller classes, which is not elegant.
Of course, you might say, then define a base class similar to BaseController. Although this approach is correct, it is still not perfect, because such code is invasive and coupling. Simple Controller, why do I have to inherit such a class? What if I have inherited other base classes. As we all know, Java can only inherit one class.

Is there a solution to apply the defined exception handler to all controllers without coupling with the Controller? We can use the annotation @ ControllerAdvice. In short, the annotation can apply exception handlers to all controllers instead of a single Controller.
With this annotation, we can define a set of handling mechanisms for various exceptions in an independent place, such as a single class, and then add the annotation @ ControllerAdvice to the class signature to handle different exceptions at different stages.

This is the principle of unified exception handling.

Note that exceptions are classified by stages, which can be roughly divided into exceptions before entering the Controller and Service layer exceptions. For details, please refer to the following figure:
Anomalies at different stages

3. Target

Eliminate more than 95% of the try catch code blocks, verify the business exceptions in an elegant assert way, focus only on the business logic, and don't spend a lot of energy writing redundant try catch code blocks.

4. Actual combat

Note: because there are many codes involved in the unified exception handling scheme, it is not convenient to post all the codes here, but only the key parts. Therefore, it is recommended to clone the source code locally for easy viewing. Source address : https://github.com/sprainkle/spring-cloud-advance ļ¼› The projects involved include spring cloud advance common and unified exception handling.
Before defining a unified exception handling class, let's introduce how to gracefully determine the exception and throw the exception.

4.1 replace throw exception with assert

Everyone must be familiar with assert. For example, org.springframework.util.Assert of the Spring family is often used when we write test cases. Using assertion can make us feel very smooth when coding, such as:

   @Test
    public void test1() {
        ...
        User user = userDao.selectById(userId);
        Assert.notNull(user, "user does not exist.");
        ...
    }

    @Test
    public void test2() {
        // Another way of writing
        User user = userDao.selectById(userId);
        if (user == null) {
            throw new IllegalArgumentException("user does not exist.");
        }
    }

Do you feel that the first way to judge non emptiness is very elegant; The second is the relatively ugly if {...} code block.
So what's behind the magic Assert.notNull()? The following is part of the source code of Assert:

public abstract class Assert {
    public Assert() {
    }

    public static void notNull(@Nullable Object object, String message) {
        if (object == null) {
            throw new IllegalArgumentException(message);
        }
    }
}

It can be seen that Assert actually helps us encapsulate if {...}. Although it is very simple, it is undeniable that the coding experience has been improved to at least one level.
We should also imitate org.springframework.util.Assert and write an assertion class. However, the exceptions thrown after assertion failure are not built-in exceptions such as IllegalArgumentException, but our own defined exceptions. Let's try it.

4.1.1 interface method class and BaseException basic exception class

public interface IResponseEnum {
    int getCode();
    String getMessage();
}
@Getter
public class BaseException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    /**
     * Return code
     */
    protected IResponseEnum responseEnum;
    /**
     * Exception message parameters
     */
    protected Object[] args;

    public BaseException(IResponseEnum responseEnum) {
        super(responseEnum.getMessage());
        this.responseEnum = responseEnum;
    }

    public BaseException(int code, String msg) {
        super(msg);
        this.responseEnum = new IResponseEnum() {
            @Override
            public int getCode() {
                return code;
            }

            @Override
            public String getMessage() {
                return msg;
            }
        };
    }

    public BaseException(IResponseEnum responseEnum, Object[] args, String message) {
        super(message);
        this.responseEnum = responseEnum;
        this.args = args;
    }

    public BaseException(IResponseEnum responseEnum, Object[] args, String message, Throwable cause) {
        super(message, cause);
        this.responseEnum = responseEnum;
        this.args = args;
    }
}

4.1.2 custom Assert class

public interface Assert {
    /**
     * Create exception
     * @param args
     * @return
     */
    BaseException newException(Object... args);

    /**
     * Create exception
     * @param t
     * @param args
     * @return
     */
    BaseException newException(Throwable t, Object... args);

    /**
     * <p>Assert that the object < code > obj < / code > is not empty. If the object < code > obj < / code > is empty, an exception is thrown
     *
     * @param obj Object to be judged
     */
    default void assertNotNull(Object obj) {
        if (obj == null) {
            throw newException(obj);
        }
    }

    /**
     * <p>Assert that the object < code > obj < / code > is not empty. If the object < code > obj < / code > is empty, an exception is thrown
     * <p>Exception message < code > message < / code > supports parameter passing to avoid string splicing before judgment
     *
     * @param obj Object to be judged
     * @param args message Parameter list corresponding to placeholder
     */
    default void assertNotNull(Object obj, Object... args) {
        if (obj == null) {
            throw newException(args);
        }
    }
}

Note:
Only part of the source code of the Assert interface is given here. Please refer to for more assertion methods Source code.
BaseException is the base class for all custom exceptions.
Defining the default method in the interface is the new syntax of Java 8.

The above Assert assertion method is defined using the default method of the interface. The exception thrown after the assertion fails is not a specific exception, but is provided by two newException interface methods. Because the exceptions in the business logic basically correspond to specific scenarios. For example, the user information is obtained according to the user id, and the query result is null. At this time, the exception thrown may be UserNotFoundException, with specific exception code (such as 7001) and exception information "user does not exist". Therefore, the specific exception thrown is determined by the implementation class of Assert.

After seeing this, you may have such a question. According to the above statement, how many exceptions do you have to define the same number of assertion classes and exception classes? This is obviously anti human! Don't worry, just listen to me.

4.1.3 using Enum enumeration instances to implement custom exceptions

The custom exception BaseException has two attributes, code and message. Do you think any class will define these two attributes? Yes, enumeration classes. Let's see how I combine Enum and Assert. I believe I will brighten your eyes. As follows:

/** Main function: build custom exception object.
 */
public class BusinessException extends  BaseException {

    private static final long serialVersionUID = 1L;

    public BusinessException(IResponseEnum responseEnum, Object[] args, String message) {
        super(responseEnum, args, message);
    }

    public BusinessException(IResponseEnum responseEnum, Object[] args, String message, Throwable cause) {
        super(responseEnum, args, message, cause);
    }
}
/** Main functions: rewrite the method of Assert class, format exception information and throw exceptions.
 */
public interface BusinessExceptionAssert extends IResponseEnum, Assert {

    @Override
    default BaseException newException(Object... args) {
        String msg = MessageFormat.format(this.getMessage(), args);
        return new BusinessException(this, args, msg);
    }

    @Override
    default BaseException newException(Throwable t, Object... args) {
        String msg = MessageFormat.format(this.getMessage(), args);
        return new BusinessException(this, args, msg, t);
    }

}
/** Main function: create a custom enumeration instance.
 */
@Getter
@AllArgsConstructor
public enum ResponseEnum implements BusinessExceptionAssert {

    /**
     * Bad licence type
     */
    BAD_LICENCE_TYPE(7001, "Bad licence type."),
    /**
     * Licence not found
     */
    LICENCE_NOT_FOUND(7002, "Licence not found.")
    ;

    /**
     * Return code
     */
    private int code;
    /**
     * Return message
     */
    private String message;
}

Two enumeration instances are defined in the code example: BAD_LICENCE_TYPE,LICENCE_NOT_FOUND, corresponding to badlicensetype exception and licensenotfoundexception respectively. In the future, you only need to add an enumeration instance for each exception, and you no longer need to define an exception class for each exception.

Then let's look at how to use it. Suppose that the licenseservice has a method to verify whether the license exists, as follows:

    /**
     * Verify that {@ link license} exists
     * @param licence
     */
    private void checkNotNull(Licence licence) {
        ResponseEnum.LICENCE_NOT_FOUND.assertNotNull(licence);
    }

If you do not use assertions, the code might be as follows:

    private void checkNotNull(Licence licence) {
        if (licence == null) {
            throw new LicenceNotFoundException();
            // Or so
            throw new BusinessException(7001, "Bad licence type.");
        }
    }

Using enumeration classes in combination with (inheriting) Assert, you only need to define different enumeration instances according to specific exceptions, such as the above BAD_LICENCE_TYPE,LICENCE_NOT_FOUND can throw specific exceptions for different situations (this means carrying specific exception codes and exception messages), which not only does not need to define a large number of exception classes, but also has good readability of assertions.

Note: the above examples are for specific services, and some exceptions are common, such as server busy, network exception, server exception, parameter verification exception, 404, etc. Therefore, there are CommonResponseEnum, ArgumentResponseEnum and ServletResponseEnum, of which ServletResponseEnum will be described in detail later.

4.1.4 define a unified exception handler class

@Slf4j
@Component
@ControllerAdvice
@ConditionalOnWebApplication
@ConditionalOnMissingBean(UnifiedExceptionHandler.class)
public class UnifiedExceptionHandler {
    /**
     * production environment 
     */
    private final static String ENV_PROD = "prod"; 

    @Autowired
    private UnifiedMessageSource unifiedMessageSource;

    /**
     * Current environment
     */
    @Value("${spring.profiles.active}")
    private String profile;
    
    /**
     * Get internationalization message
     *
     * @param e abnormal
     * @return
     */
    public String getMessage(BaseException e) {
        String code = "response." + e.getResponseEnum().toString();
        String message = unifiedMessageSource.getMessage(code, e.getArgs());

        if (message == null || message.isEmpty()) {
            return e.getMessage();
        }

        return message;
    }

    /**
     * Business exception
     *
     * @param e abnormal
     * @return Abnormal results
     */
    @ExceptionHandler(value = BusinessException.class)
    @ResponseBody
    public ErrorResponse handleBusinessException(BaseException e) {
        log.error(e.getMessage(), e);

        return new ErrorResponse(e.getResponseEnum().getCode(), getMessage(e));
    }

    /**
     * Custom exception
     *
     * @param e abnormal
     * @return Abnormal results
     */
    @ExceptionHandler(value = BaseException.class)
    @ResponseBody
    public ErrorResponse handleBaseException(BaseException e) {
        log.error(e.getMessage(), e);

        return new ErrorResponse(e.getResponseEnum().getCode(), getMessage(e));
    }

    /**
     * Controller Upper layer related anomaly
     *
     * @param e abnormal
     * @return Abnormal results
     */
    @ExceptionHandler({
            NoHandlerFoundException.class,
            HttpRequestMethodNotSupportedException.class,
            HttpMediaTypeNotSupportedException.class,
            MissingPathVariableException.class,
            MissingServletRequestParameterException.class,
            TypeMismatchException.class,
            HttpMessageNotReadableException.class, 
            HttpMessageNotWritableException.class,
            HttpMediaTypeNotAcceptableException.class,
            ServletRequestBindingException.class,
            ConversionNotSupportedException.class,
            MissingServletRequestPartException.class,
            AsyncRequestTimeoutException.class
    })
    @ResponseBody
    public ErrorResponse handleServletException(Exception e) {
        log.error(e.getMessage(), e);
        int code = CommonResponseEnum.SERVER_ERROR.getCode();
        try {
            ServletResponseEnum servletExceptionEnum = ServletResponseEnum.valueOf(e.getClass().getSimpleName());
            code = servletExceptionEnum.getCode();
        } catch (IllegalArgumentException e1) {
            log.error("class [{}] not defined in enum {}", e.getClass().getName(), ServletResponseEnum.class.getName());
        }

        if (ENV_PROD.equals(profile)) {
            // When it is a production environment, it is not suitable to display specific exception information to users, such as 404
            code = CommonResponseEnum.SERVER_ERROR.getCode();
            BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
            String message = getMessage(baseException);
            return new ErrorResponse(code, message);
        }

        return new ErrorResponse(code, e.getMessage());
    }


    /**
     * Parameter binding exception
     *
     * @param e abnormal
     * @return Abnormal results
     */
    @ExceptionHandler(value = BindException.class)
    @ResponseBody
    public ErrorResponse handleBindException(BindException e) {
        log.error("Parameter binding verification exception", e);

        return wrapperBindingResult(e.getBindingResult());
    }

    /**
     * Parameter verification exception, which combines all exceptions that failed verification into an error message
     *
     * @param e abnormal
     * @return Abnormal results
     */
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {
        log.error("Parameter binding verification exception", e);

        return wrapperBindingResult(e.getBindingResult());
    }

    /**
     * Packaging binding exception result
     *
     * @param bindingResult Binding results
     * @return Abnormal results
     */
    private ErrorResponse wrapperBindingResult(BindingResult bindingResult) {
        StringBuilder msg = new StringBuilder();

        for (ObjectError error : bindingResult.getAllErrors()) {
            msg.append(", ");
            if (error instanceof FieldError) {
                msg.append(((FieldError) error).getField()).append(": ");
            }
            msg.append(error.getDefaultMessage() == null ? "" : error.getDefaultMessage());

        }

        return new ErrorResponse(ArgumentResponseEnum.VALID_ERROR.getCode(), msg.substring(2));
    }

    /**
     * Undefined exception
     *
     * @param e abnormal
     * @return Abnormal results
     */
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public ErrorResponse handleException(Exception e) {
        log.error(e.getMessage(), e);

        if (ENV_PROD.equals(profile)) {
            // When it is a production environment, it is not suitable to display specific exception information to users, such as database exception information
            int code = CommonResponseEnum.SERVER_ERROR.getCode();
            BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
            String message = getMessage(baseException);
            return new ErrorResponse(code, message);
        }

        return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), e.getMessage());
    }
}

You can see that there are many exceptions in the above, but there are actually only two categories: ServletException and ServiceException. Remember the classification by stage mentioned above, that is, the exceptions before entering the Controller   and   Service layer exception; Then ServiceException is divided into custom exception and unknown exception. The corresponding relationship is as follows:

  • Exceptions before entering the Controller: handleServletException, handleBindException, handlevallexception
  • Custom exception: handlebusinesexception, handleBaseException
  • Unknown exception: handleException

Next, these exception handlers are described in detail.

4.2 description of exception handler method

4.2.1 handleServletException()

Before an http request reaches the Controller, a series of checks will be made between the request information of the request and the information of the target Controller. Here is a brief description:

NoHandlerFoundException: first, find out whether there is a corresponding controller according to the request Url. If not, the exception will be thrown, that is, the familiar * * 404 * * exception;
HttpRequestMethodNotSupportedException: if it matches (the matching result is a list, but the http method is different, such as Get, Post, etc.), try to match the requested http method with the controller of the list. If there is no controller corresponding to the http method, throw the exception;
HttpMediaTypeNotSupportedException: then compare the request header with that supported by the controller, such as the content type request header. If the parameter signature of the controller contains the annotation @ RequestBody, but the value of the requested content type request header does not contain application/json, this exception will be thrown (of course, this exception will be thrown in more than this case);
MissingPathVariableException: no path parameters detected. For example, the url is: / licence/{licenceId}, and the parameter signature contains @ PathVariable("licenceId"). When the requested url is / licence, if the url is not clearly defined as / licence, it will be judged that the path parameter is missing;
MissingServletRequestParameterException: missing request parameter. For example, if the parameter @ RequestParam("licenceId") String licenceId is defined, but the parameter is not carried when the request is initiated, the exception will be thrown;
Typemismatch exception: parameter type matching failed. For example, if the received parameter is Long, but the value passed in is a string, the type conversion will fail, and this exception will be thrown;
HttpMessageNotReadableException: Contrary to the example of HttpMediaTypeNotSupportedException above, that is, the request header carries "content type: application / json; charset = UTF-8", but the receiving parameter does not add annotation @ RequestBody, or the json string carried by the request body fails in the process of deserialization into pojo;
HttpMessageNotWritableException: if the returned pojo fails in the process of serialization into json, throw the exception;
HttpMediaTypeNotAcceptableException: unknown;
ServletRequestBindingException: unknown;
ConversionNotSupportedException: unknown;
MissingServletRequestPartException: unknown;
AsyncRequestTimeoutException: unknown;

4.2.2 handleBindException()

Parameter verification is abnormal, which will be described in detail later.

4.2.3 handleValidException()

Parameter verification is abnormal, which will be described in detail later.

4.2.4 handleBusinessException(),handleBaseException()

Handle custom business exceptions. The difference is that all business exceptions except business exceptions are handled by handleBaseException. Can be merged into one.

4.2.5 handleException()

Handle all unknown exceptions, such as those that failed to operate the database.

Note: the exception information returned by the handleServletException and handleException processors above may be different in different environments, because these exception information are the exception information provided by the framework, which is generally in English and not easy to be displayed directly to users, so they are returned to server uniformly_ Error stands for exception information.

4.3 different from ordinary people 404

As mentioned above, when the request does not match the controller, a NoHandlerFoundException exception will be thrown, but this is not the case by default. By default, a page similar to the following will appear: Whitelabel Error Page

How does this page appear? In fact, when 404 occurs, it does not throw exceptions by default, but forward to the / error controller. spring also provides the default error controller, as follows: BasicErrorController

Then, how to make 404 throw exceptions? Just add the following configuration in the properties file:

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

In this way, it can be captured in the exception handler. Then, as long as the front end captures a specific status code, it can immediately jump to page 404. For details, please refer to Single Page Applications with Spring Boot. Catch the exception corresponding to 404

4.4 unified return result encapsulation

Before verifying the unified exception handler, by the way, the unified return result. To put it bluntly, it is actually to unify the data structure of the returned results. code and message are mandatory fields in all returned results. When data needs to be returned, another field data is required to represent it.

/**
 * Return result entity class
 * @ClassName Result
 * @Version 1.0
 **/
@Data
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {

    /**
     * Encrypted lock value
     */
    private Integer code;
    private String msg;
    private T data;
    private String lock;


    public Result(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public Result(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }


    /**
     * Custom return success message
     ** @param data data object
     * @return Success message
     */
    public static Result success(String msg, Object data , String lock) {
        return new Result(Constants.SUCCESS, msg , data , lock);
    }

    /**
     * Return success message
     ** @param data data object
     * @return Success message
     */
    public static Result success( Object data , String lock) {
        return new Result(Constants.SUCCESS, "Request succeeded" , data , lock);
    }

    /**
     * No return value
     * @param lock
     * @return
     */
    public static Result successNoData(String lock) {
        return success(null , lock);
    }


    /**
     * No lock, only data is returned
     *
     * @param data data object
     * @return Success message
     */
    public static Result successNoLock(Object data) {
        return success( data , null);
    }

    /**
     * Return information only
     * @return Success message
     */
    public static Result success() {
        return success( "Operation succeeded!" ,null , null);
    }

    /**
     * Return failure message
     *
     * @param data data object
     * @return Success message
     */
    public static Result error(Integer code , String msg ,Object data  , String lock) {
        return new Result(code , msg , data ,lock);
    }

    /**
     * Return failure message
     *
     * @param data data object
     * @return Success message
     */
    public static Result errorNoLock(Integer code , String msg , Object data) {
        return error(code ,msg ,data , null);
    }

    /**
     * No data type
     * @return
     */
    public static Result errorNoData(Integer code , String msg ,String lock) {
        return error(code, msg ,null , lock);
    }

    /**
     * No data type
     * @return
     */
    public static Result errorNoDataAndLock(Integer code , String msg) {
        return error(code , msg ,null , null);
    }


    /**
     * No data type
     * @return
     */
    public static Result errorCodeAndMsg(Integer code , String msg) {
        return error(code , msg ,null , null);
    }
}

The Result class returned by the above Result is not the class used by the original author, but the one I used when re editing this article. However, in order not to destroy the consistency with the source code of the original author, you can ignore the above code. It can also be used directly, ignoring the source code of the original author, but what I use is relatively simple and not so complex, but it can meet the needs of my project. I hereby remind you.

The encapsulation result classes of the original author are as follows:

  1. First define a   BaseResponse   As the base class for all returned results;
  2. Then define a general return result class CommonResponse, which inherits   BaseResponse, and the data field is added;
  3. In order to distinguish between success and failure return results, another one is defined   ErrorResponseļ¼›
  4. Finally, there is a common return result, that is, the returned data has paging information. Because this interface is common, it is necessary to define a separate return result class   QueryDataResponse, which inherits from   CommonResponse only limits the type of data field to QueryDdata. QueryDdata defines the corresponding fields of paging information, namely totalCount, pageNo, pageSize and records.
    Among them, only CommonResponse and QueryDataResponse are commonly used, but their names are long. Why not define two super simple classes to replace them? So it's abbreviated as R   and   QR. You only need to write new R < > (data) and new QR < > (querydata) when returning results in the future.

The definitions of all return result classes are not posted here. You can view the source code directly.

verification

Because this set of unified exception handling can be said to be universal, all can be designed into a common package. In the future, each new project / module only needs to introduce this package. Therefore, in order to verify, you need to create a new project and introduce the common package. The project structure is as follows:

In the future, you only need to introduce the common package as follows:

Summary

  • When testing, we can find that all exceptions in the test can be caught and returned in the form of code and message.
  • When defining business exceptions for each project / module, you only need to define an enumeration class, then implement the interface BusinessExceptionAssert, and finally define the corresponding enumeration instance for each business exception, instead of defining many exception classes. It is also very convenient to use. The usage is similar to assertion.

extend

In the production environment, if an unknown exception or ServletException is caught, because it is a long string of exception information, it is not professional enough to directly show it to the user. Therefore, we can do this: when it is detected that the current environment is the production environment, we can directly return the "network exception".
Production environment returns "network exception"

You can modify the current environment in the following ways:
Modify the current environment to production environment

summary
With the combination of assertion and enumeration classes and unified exception handling, most exceptions can be caught. Why do you say most exceptions? After spring cloud security is introduced, there will be authentication / authorization exceptions, service degradation exceptions of gateway, cross module call exceptions, remote call third-party service exceptions, etc. the capture methods of these exceptions are different from those described in this article, but they are limited to space. There will be a separate article in the future.

In addition, when internationalization needs to be considered, the exception information after exception capture generally cannot be returned directly and needs to be converted into the corresponding language. However, this paper has considered this. Internationalization mapping has been done when obtaining messages. The logic is as follows:
Get internationalization message

Since the knowledge related to internationalization does not belong to the scope introduced in this article, there will be a separate article in the future.

Posted by cheechm on Thu, 04 Nov 2021 23:24:19 -0700