Spring BootでTomcatデフォルトのエラーページをカスタマイズする

# 環境
- JDK 21
- Spring Boot 3.2.4
- Embedded Tomcat 10.1.19

# やりたいこと
Spring Bootにもエラーページの機能があります。具体的にはsrc/main/resources/templates直下にerror.html、src/main/resources/templates/error直下に404.html・500.htmlなどを作成すればOKです（[公式ドキュメント](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto.actuator.customize-whitelabel-error-page)）。

しかし、サーブレットフィルターのレベルで例外がスローされると、前述のエラーページではなくTomcatデフォルトのエラーページが表示されてしまいます。

![スクリーンショット 2024-04-11 16.11.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58650/d9cbceb5-339a-fa33-e4d0-5be5cceea0e8.png)

これを自作のエラーページに差し替えたいです。

# 自作エラーページの作成
静的なHTMLを作成して、適当な場所・適当なファイル名で配置します。今回はsrc/main/resources/templates/tomcatフォルダにtomcat-error.htmlという名前で配置することにします。

# `ErrorReportValve`のカスタマイズ
Tomcatデフォルトのエラーページをレスポンスしているのが、Tomcatの`ErrorReportValve`クラスです。これを継承＋オーバーライドすることで、作成した自作エラーページをレスポンスするよう変更します。

```java:CustomErrorReportValve.java
package com.example;

import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.util.IOTools;
import org.apache.catalina.valves.ErrorReportValve;
import org.apache.coyote.ActionCode;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Tomcatエラーページをカスタマイズします。
 */
public class CustomErrorReportValve extends ErrorReportValve {

    /**
     * カスタマイズしたエラーページをレスポンスします。
     * @see ErrorReportValve#report(Request, Response, Throwable)
     * @param request   The request being processed
     * @param response  The response being generated
     * @param throwable The exception that occurred (which possibly wraps a root cause exception
     */
    @Override
    protected void report(Request request, Response response, Throwable throwable) {
        int statusCode = response.getStatus();

        // Do nothing on a 1xx, 2xx and 3xx status
        // Do nothing if anything has been written already
        // Do nothing if the response hasn't been explicitly marked as in error
        // and that error has not been reported.
        if (statusCode < 400 || response.getContentWritten() > 0 || !response.setErrorReported()) {
            return;
        }

        // If an error has occurred that prevents further I/O, don't waste time
        // producing an error report that will never be read
        AtomicBoolean result = new AtomicBoolean(false);
        response.getCoyoteResponse().action(ActionCode.IS_IO_ALLOWED, result);
        if (!result.get()) {
            return;
        }
        // ここまでのコード👆は、ErrorReportValve#report()からコピーしました

        // カスタマイズしたエラーページをレスポンス
        response.setContentType("text/html");
        response.setCharacterEncoding("UTF-8");
        try (OutputStream os = response.getOutputStream();
             InputStream is = this.getClass().getResourceAsStream("/templates/tomcat/tomcat-error.html")) {
            IOTools.flow(is, os);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}
```

# Tomcatが`CustomErrorReportValve`を使うように設定
```java:ServerConfig.java
package com.example;

import org.apache.catalina.Container;
import org.apache.catalina.core.StandardHost;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 組み込みサーバーに関する設定です。
 */
@Configuration
public class ServerConfig {

    /**
     * Tomcatのエラー画面を自作のものに変更します。
     */
    @Bean
    public WebServerFactoryCustomizer<TomcatServletWebServerFactory> webServerFactoryCustomizer() {
        return factory -> factory.addContextCustomizers(context -> {
            Container parent = context.getParent();
            if (parent instanceof StandardHost host) {
                host.setErrorReportValveClass(CustomErrorReportValve.class.getName());
            }
        });
    }
}
```

# 参考URL
- [Spring BootでTomcatのデフォルトエラーページが出るのを抑止する](https://yukihane.github.io/blog/202105/21/spring-boot-avoid-tomcat-error-page/)
- [Allow custom ErrorReportValve to be used with Tomcat and provide whitelabel version #21257](https://github.com/spring-projects/spring-boot/issues/21257)
- [ErrorReportValveのソースコード](https://github.com/apache/tomcat/blob/main/java/org/apache/catalina/valves/ErrorReportValve.java)