HackToTech

Hack To Technology

Spring Bootのエラーページ周りを色々見たのでメモ

Spring bootを仕事で使っていて、
server.error.whitelabel.enabled を指定すると具体的にどうなるのかとか、
誰がどうレスポンスを生成しているのかとか、
この辺イマイチ浅い理解をしていてちゃんと説明出来そうにないのでちゃんとデバッガを動かしながら見てみた

読んでいたところのコードの各種バージョン

  • SpringBoot(2.7.6)
  • Tomcat(10.1.4)
  • SpringWebMvc(5.3.24)

理解する上で併せて読むと良い公式ブログ spring.io

server.error.whitelabel.enabled

trueを指定した場合(デフォルト)

WhitelabelErrorViewConfigurationが有効になる(defaultErrorViewのBeanが登録される)

ErrorMvcAutoConfiguration#WhitelabelErrorViewConfiguration

この状態でaccept: text/htmlのリクエストを投げた場合
Viewは↓で生成されたものが返される

ErrorMvcAutoConfiguration#StaticView

<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Sat Dec 10 16:54:07 JST 2022</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>

この状態でapplication/jsonのリクエストを投げた場合

{"timestamp":"2022-12-10T07:59:51.927+00:00","status":404,"error":"Not Found","path":"/base/test"}

falseを指定した場合

この状態でaccept: text/htmlのリクエストを投げた場合
↓でTomcat側で生成されたものが返される(Tomcatを使っている場合) ErrorReportValve#report

<!doctype html><html lang="en"><head><title>HTTP Status 404 – Not Found</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 404 – Not Found</h1></body></html>

この状態でapplication/jsonのリクエストを投げた場合
trueの時と同じものが返される

{"timestamp":"2022-12-10T07:57:47.967+00:00","status":404,"error":"Not Found","path":"/base/test"}

上の部分のレスポンス返すまでどういう挙動をしているのかが気になったので、ErrorController周りを見ることにした

ErrorController周り

ErrorControllerBean が無ければ BasicErrorController が登録される ErrorMvcAutoConfiguration#basicErrorController

ErrorControllerのインタフェース

ErrorControllerを実装した抽象クラスのAbstractErrorController

AbstractErrorControllerを継承したBasicErrorController

BasicErrorControllerの中身

htmlとその他のAcceptHeaderで呼び分けているところ
AcceptHeadertext/htmlがあるならBasicErrorController#errorHTML

それ以外の場合はBasicErrorController#error

HttpMediaTypeNotAcceptableExceptionが起きた場合にエラーステータスをそのまま返すだけのExceptionHandlerも登録している

大体何をやっているかがわかったので、ErrorControllerを実装してカスタムなErrorControllerを書いてみた

ErrorControllerを実装したカスタムなErrorController

実装自体は少し BasicErrorController をコピーしつつjavaからKotlinに直している
errorのメソッドは特に書くことはなくて、
mediaTypeNotAcceptableのメソッドの方は、ResponseEntity<Any>で返すと最終的にDefaultHandlerExceptionResolverに捕まってレスポンスボディがnullになるので(後述)、ObjectMapper使ってResponseEntity<String>で返させている
そして、仮にval status = getStatus(request)statusを受け取って渡さなかった場合、HttpMediaTypeNotAcceptableのレスポンスコードとして用意されている406が返される
text/htmlだろうがなんだろうがJSON的なレスポンスを強制的に返したかったのでやっているが、本来は406を返しつつ受け取れるメディアタイプを返す実装の方が正しいとは思う
(ただまあ404なエンドポイントに投げられて、統一したレスポンスボディ返しつつ406ではなく404を返したいケースはある気がする)

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.boot.web.servlet.error.ErrorController
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.HttpMediaTypeNotAcceptableException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import javax.servlet.RequestDispatcher
import javax.servlet.http.HttpServletRequest

@RestController
class ErrorController(
    private val objectMapper: ObjectMapper,
): ErrorController {

    @RequestMapping("/error")
    fun error(request: HttpServletRequest): ResponseEntity<Any> {
        val status = getStatus(request)
        val body = mapOf("message" to status.reasonPhrase, "status" to status.value())
        return ResponseEntity(body, status)
    }

    @ExceptionHandler(HttpMediaTypeNotAcceptableException::class)
    fun mediaTypeNotAcceptable(request: HttpServletRequest): ResponseEntity<String> {
        val status = getStatus(request)
        val body = mapOf("message" to status.reasonPhrase, "status" to status.value())
        return ResponseEntity(objectMapper.writeValueAsString(body), status)
    }

    private fun getStatus(request: HttpServletRequest): HttpStatus {
        val statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE) as Int? ?: return HttpStatus.INTERNAL_SERVER_ERROR
        return try {
            HttpStatus.valueOf(statusCode)
        } catch (ex: Exception) {
            HttpStatus.INTERNAL_SERVER_ERROR
        }
    }
}

accept application/json

curl -XGET http://localhost:8080/404 -H 'accept: application/json' -sS -v | jq
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /404 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> accept: application/json
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Sat, 10 Dec 2022 16:39:19 GMT
< 
{ [47 bytes data]
* Connection #0 to host localhost left intact
{
  "message": "Not Found",
  "status": 404
}

accept text/html

curl -XGET http://localhost:8080/404 -H 'accept: text/html' -sS -v | jq
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /404 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> accept: text/html
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Content-Type: text/html;charset=UTF-8
< Content-Length: 36
< Date: Sat, 10 Dec 2022 16:39:42 GMT
< 
{ [36 bytes data]
* Connection #0 to host localhost left intact
{
  "message": "Not Found",
  "status": 404
}

accept: text/html の場合にResponseEntity<Any>を返しても最終的にDefaultHandlerExceptionResolverに捕まってResponseのBodynullのステータス406で返ってくる部分

上のErrorControllerでエラーレスポンスを返した際に、 AbstractMessageConverterMethodProcessor#writeWithMessageConverters 辺りでmediaTypesapplication/json, application/*+jsonにマッチしない且つbodynullではないので、
HttpMediaTypeNotAcceptableException投げられることになる

で、その後にDefaultHandlerExceptionResolver側で捕捉されて、406,nullで返されることになる
DefaultHandlerExceptionResolver#doResolveException DefaultHandlerExceptionResolver#handleHttpMediaTypeNotAcceptable

実際にFramework側のコードを読みながら手を動かすと理解が進むので今後も暇を見つけてやっていきたい