🚥

Spring GraphQLでエラーコードを返す

に公開

はじめに

GraphQLではエラーが発生しても200を返すのが慣例となっています。しかし、外形監視などではHTTPステータスコードでエラーを判定することが多く、すべてのレスポンスが200だとエラーの検知が難しくなります。

この記事では、Spring GraphQLでエラー内容に応じて適切なHTTPステータスコードを返す方法を紹介します。

なぜGraphQLは200を返すのか

GraphQL over HTTPでは、well-formedなリクエストに対して200を返すことが推奨されています。

主な理由は以下の通りです。

1. 部分的なレスポンスの存在

GraphQLでは、クエリの一部でエラーが発生しても、他の部分は正常にデータを返すことができます。レスポンスにはdataerrorsの両方が含まれることがあり、従来のHTTPステータスコードでは「部分的な成功」を表現できません。

{
  "data": {
    "user": {
      "name": "山田太郎"
    },
    "orders": null
  },
  "errors": [
    {
      "message": "注文情報の取得に失敗しました",
      "path": ["orders"]
    }
  ]
}

2. 中間層による改ざん防止

4xx5xxステータスコードは、プロキシやロードバランサーなどの中間層から生成される可能性があります。200を返すことで、レスポンスが確実にGraphQLサーバーから返されたものであることを保証できます。

標準実装でのレスポンス

まず、成功時のレスポンス例を示します。

curl -s -w '\n%{http_code}' http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ goods(id: \"1\") { id name type } }"}' \
    | { read -r body; read -r code; echo "$body" | jq .; echo "HTTP: $code"; }
{
  "data": {
    "goods": {
      "id": "1",
      "name": "ダウン",
      "type": "アウター"
    }
  }
}
HTTP: 200

次に、エラーが発生した場合のレスポンスを確認します。エラーが発生してもHTTPステータスコードは200のままです。

構文エラー

curl -s -w '\n%{http_code}' http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ goods(id: \"1\") { id name }"}' \
    | { read -r body; read -r code; echo "$body" | jq .; echo "HTTP: $code"; }

{
  "errors": [
    {
      "message": "Invalid syntax with offending token '<EOF>' at line 1 column 29",
      "locations": [
        {
          "line": 1,
          "column": 29
        }
      ],
      "extensions": {
        "classification": "InvalidSyntax"
      }
    }
  ]
}
HTTP: 200

バリデーションエラー(存在しないフィールドをリクエスト)

curl -s -w '\n%{http_code}' http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ goods(id: \"1\") { id name price } }"}' \
    | { read -r body; read -r code; echo "$body" | jq .; echo "HTTP: $code"; }

{
  "errors": [
    {
      "message": "Validation error (FieldUndefined@[goods/price]) : Field 'price' in type 'Goods' is undefined",
      "locations": [
        {
          "line": 1,
          "column": 28
        }
      ],
      "extensions": {
        "classification": "ValidationError"
      }
    }
  ]
}
HTTP: 200

外形監視での課題

しかし、この慣例は外形監視との相性が良くありません。外形監視ではHTTPステータスコードでエラーを判定することが一般的なため、GraphQLエンドポイントがすべて200を返すと、以下のような課題が発生します。

  • 認証エラーやサーバーエラーが検知できない
  • アラートの設定が複雑になる

Spring GraphQLでHTTPステータスコードを返す

HTTPステータスコードを変更する方法を調査し、GraphQlHttpHandlerの拡張が最も適していると判断しました。

方法 HTTPステータス変更 課題
Servlet Filter 可能 レスポンスボディを読む必要があり、パフォーマンスが低下する可能性がある
WebGraphQlInterceptor 不可 レスポンスヘッダーは変更できるが、ステータスコードは変更できない
DataFetcherExceptionResolver 不可 エラー内容のカスタマイズ用で、HTTPステータスコードは変更できない
GraphQlHttpHandler 可能 エラー情報にアクセスでき、ステータスコードも設定可能

GraphQlHttpHandlerprepareResponseメソッドは、GraphQLレスポンスをHTTPレスポンスに変換する最終段階です。このメソッドではWebGraphQlResponseにアクセスでき、エラー情報を取得してHTTPステータスコードを設定できます。

以下が実装例です。作成したPRはこちらです。

package com.example.app.controller.handler;

import graphql.ErrorClassification;
import java.util.Comparator;
import java.util.Map;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlResponse;
import org.springframework.graphql.server.webmvc.GraphQlHttpHandler;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import reactor.core.publisher.Mono;

@Component
public class CustomGraphQlHttpHandler extends GraphQlHttpHandler {

  public CustomGraphQlHttpHandler(final WebGraphQlHandler handler) {
    super(handler);
  }

  @Override
  protected ServerResponse prepareResponse(
      final ServerRequest request, final Mono<WebGraphQlResponse> responseMono) {

    return ServerResponse.async(
        responseMono.map(
            response -> {
              final var httpStatus =
                  response.getErrors().stream()
                      .map(error -> toHttpStatus(error.getErrorType()))
                      // 複数のエラーが発生した場合は、最も優先度の高いステータスコードを返す
                      .max(Comparator.comparingInt(this::getErrorPriority))
                      .orElse(HttpStatus.OK);

              final Map<String, Object> body = response.toMap();
              final var contentType = MediaType.APPLICATION_JSON;
              final var writer = getWriteFunction(body, contentType);

              final var builder =
                  ServerResponse.status(httpStatus)
                      .headers(headers -> headers.addAll(response.getResponseHeaders()))
                      .contentType(contentType);

              return (writer != null) ? builder.build(writer) : builder.body(body);
            }));
  }

  private HttpStatus toHttpStatus(ErrorClassification errorClassification) {
    // Spring GraphQL の ErrorType
    if (errorClassification instanceof final ErrorType errorType) {
      return switch (errorType) {
        case BAD_REQUEST -> HttpStatus.BAD_REQUEST;
        case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED;
        case FORBIDDEN -> HttpStatus.FORBIDDEN;
        case NOT_FOUND -> HttpStatus.NOT_FOUND;
        case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR;
      };
    }

    // graphql-java の ErrorType
    if (errorClassification instanceof final graphql.ErrorType graphqlErrorType) {
      return switch (graphqlErrorType) {
        case InvalidSyntax, ValidationError, NullValueInNonNullableField, OperationNotSupported ->
            HttpStatus.BAD_REQUEST;
        case DataFetchingException, ExecutionAborted -> HttpStatus.INTERNAL_SERVER_ERROR;
      };
    }

    return HttpStatus.INTERNAL_SERVER_ERROR;
  }

  private int getErrorPriority(HttpStatus status) {
    return switch (status) {
      case UNAUTHORIZED -> 5;
      case FORBIDDEN -> 4;
      case INTERNAL_SERVER_ERROR -> 3;
      case NOT_FOUND -> 2;
      case BAD_REQUEST -> 1;
      default -> 0;
    };
  }
}

実装のポイント

1. エラータイプに応じたHTTPステータスコードのマッピング

toHttpStatusメソッドで、Spring GraphQLのErrorTypeとgraphql-javaのErrorTypeの両方に対応しています。

2. 複数エラー時の優先度

GraphQLでは1つのリクエストで複数のエラーが発生することがあります。getErrorPriorityメソッドで優先度を定義し、最も重要なエラーのステータスコードを返すようにしています。

動作確認

今回の例だとどちらのレスポンスも400が返るようになります。

構文エラー

curl -s -w '\n%{http_code}' http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ goods(id: \"1\") { id name }"}' \
    | { read -r body; read -r code; echo "$body" | jq .; echo "HTTP: $code"; }

{
  "errors": [
    {
      "message": "Invalid syntax with offending token '<EOF>' at line 1 column 29",
      "locations": [
        {
          "line": 1,
          "column": 29
        }
      ],
      "extensions": {
        "classification": "InvalidSyntax"
      }
    }
  ]
}
HTTP: 400

バリデーションエラー

curl -s -w '\n%{http_code}' http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ goods(id: \"1\") { id price } }"}' \
    | { read -r body; read -r code; echo "$body" | jq .; echo "HTTP: $code"; }
{
  "errors": [
    {
      "message": "Validation error (FieldUndefined@[goods/price]) : Field 'price' in type 'Goods' is undefined",
      "locations": [
        {
          "line": 1,
          "column": 23
        }
      ],
      "extensions": {
        "classification": "ValidationError"
      }
    }
  ]
}
HTTP: 400

おわりに

この実装により、GraphQLエンドポイントでも外形監視でステータスコードによるエラー検知ができるようになります。ただし、GraphQLの慣例から外れることになるため、クライアント側でステータスコードを意識した実装が必要になる点には注意が必要です。

参考

GitHubで編集を提案

Discussion