GeekFactory

int128.hatenablog.com

Spring Bootで例外発生時にJSONを返す

Spring BootのREST APIサーバで例外発生時のエラー情報をJSONで返す方法を調べたのでメモです。

やりたいこと

  • 例外が発生した場合は常にエラー情報をJSONで返したい。
  • 例外の種類によってステータスコードを分けたい。例えば、バリデーションエラーが発生した場合は400、その他は500を返す。
  • Spring MVCの本来の振る舞いは変更しない。例えば、認可エラーで401を返す振る舞いは維持する。

実現方法

  • ErrorController インタフェースを実装すると、例外時のレスポンスをカスタマイズできる。
  • キャッチした例外によってステータスコードを変更する。

ステータスコードを指定できる例外クラスを作成します。実際のプロダクトでは、エラーコードやエラーメッセージを含む業務例外クラスを作成するとよいでしょう。

class AppException extends RuntimeException {
    final int status

    def AppException(int status, String message) {
        super(message)
        this.status = status
    }

    def AppException(int status, String message, Throwable cause) {
        super(message, cause)
        this.status = status
    }
}

ErrorController インタフェースの実装クラスを作成します。 ErrorAttributes#getErrorAttributes() メソッドでエラー情報のMapを取得できます。

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.web.ErrorAttributes
import org.springframework.boot.autoconfigure.web.ErrorController
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.ServletRequestAttributes

import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

@RestController
@RequestMapping(produces = 'application/json')
class AppErrorController implements ErrorController {
    @Autowired
    ErrorAttributes errorAttributes

    @Value('${debug:false}')
    boolean debug

    @RequestMapping('/error')
    Map<String, Object> error(HttpServletRequest request, HttpServletResponse response) {
        // エラー情報を取得する
        def servletRequestAttributes = new ServletRequestAttributes(request)
        def attributes = errorAttributes.getErrorAttributes(servletRequestAttributes, debug)
        def cause = errorAttributes.getError(servletRequestAttributes)
        if (cause instanceof AppException) {
            // 例外に含まれるステータスコードとメッセージを返す
            response.status = cause.status
            attributes.status = cause.status
            attributes.error = HttpStatus.valueOf(cause.status).reasonPhrase
            attributes.message = cause.message
        }
        attributes
    }

    @Override
    String getErrorPath() {
        '/error'
    }
}

例えば、ステータスコード400を返す場合は下記のような例外を投げます。

    @GetMapping('/hello')
    void hello() {
        throw new AppException(400, 'Custom Validation Error')
    }

レスポンスの例

ステータスコードを指定した例外(上記のAppException)を投げた場合は下記になります。この場合はログにスタックトレースが出力されます。

{
  "timestamp":1480254775918,
  "status":400,
  "error":"Bad Request",
  "exception":"example.server.AppException",
  "message":"Intentionally 400 raised",
  "trace":"example.server.AppException: ...(debugが有効の場合のみスタックトレースが付く)",
  "path":"/hello"
}

401の場合:

{
  "timestamp":1480254600193,
  "status":401,
  "error":"Unauthorized",
  "message":"Bad credentials",
  "path":"/hello"
}

404の場合:

{
  "timestamp":1480255037435,
  "status":404,
  "error":"Not Found",
  "message":"No message available",
  "path":"/hello"
}

REST APIクライアントの考慮

以下のように例外処理を設計するとよいでしょう。

  • ステータス200でJSONが返ってきた場合、正常
  • ステータス200だがJSONを解釈できない場合、共通エラー
  • ステータス200以外でJSONが返ってきた場合、エラーコードに応じた例外処理
  • ステータス200以外でJSONを解釈できない場合、共通エラー
  • 接続失敗の場合、共通エラー

参考文献です。