スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

GraphQLで極力GET Requestを使いつつRequest-URI Too Largeを回避したい

こんにちは、 Web フロントエンドエンジニアの @progfay です。

今回はプロジェクトで遭遇した URL 長による GraphQL Request の失敗と Apollo Link による解決方法を紹介します。

引数に配列を受け取る GraphQL field

私の所属するスタディサプリ中学講座の開発プロジェクト (通称: tara) では通信に GraphQL を採用しています。

その中で、以下のような field を実装しています。

type Query {
  entities(ids: [ID!]!): [Entity!]!
}

これに対して、以下のような Query を叩きます。

query specificEntities($ids: [ID!]!) {
  entities(ids: $ids) {
    name
  }
}

Request-URI Too Large

ある日、この field を使った GraphQL Request でエラーが発生したという通知が Sentry から届きました。 エラーの詳細を調査していくと、 Nginx から "414 Request-URI Too Large" が返ってきていることがわかりました。

RFC2616 には以下のように記載されています。

The HTTP protocol does not place any a priori limit on the length of a URI. Servers MUST be able to handle the URI of any resource they serve, and SHOULD be able to handle URIs of unbounded length if they provide GET-based forms that could generate such URIs. A server SHOULD return 414 (Request-URI Too Long) status if a URI is longer than the server can handle (see section 10.4.15).

要約すると、 URL が長すぎて Server が処理できない場合に返ってくる Status Code のようです。

このエラーは Web Application 上に限らず、 AndroidiOS の Native Application 上でも発生していました。

tara ではセキュリティやパフォーマンス観点で Persisted Query を採用しています。 そして useGETForHashedQueries option を有効化することで GraphQL Query の HTTP Request には GET Request が送られるようになっています。

今回 414 が発生したリクエストは以下のような URL になっていました。

https://api.example.com/graphql?operationName=specificEntities&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2206e6960d1a96c99e7d995d38ef380c91b7e09ea80d454267282225cc560275c7%22%7D%7D&variables=%7B%22ids%22%3A%5B%220%22%2C%221%22%2C%222%22%2C%223%22%2C%224%22%2C%225%22%2C%226%22%2C%227%22%2C...

見づらいので Query Parameter を整理すると、以下のようになっています。

  • operationName : specificEntities
  • extensions : {"persistedQuery":{"version":1,"sha256Hash":"06e6960d1a96c99e7d995d38ef380c91b7e09ea80d454267282225cc560275c7"}}
  • variables : {"ids":["0","1","2","3","4","5","6","7",...]}

URL が長くなってしまっている原因は variablesids に大量の ID が含まれているからでした。

Nginx であれば large_client_header_buffers を設定することで処理可能な URL 長を伸ばせますが、根本的な解決にはならなさそうです。

Client 側からのアプローチを検討する

HTTP キャッシュを活かしつつ variables が長い GraphQL Request で 414 を引き起こさないためには、「URL が長くなりそうな場合には POST Request を使う」ができればよさそうです。

これを実現するためには以下の 3 つが必要です。

  1. Middleware 的な存在を GraphQL Request を送る直前に噛ませる
  2. 条件に応じて GraphQL Request に POST Method を使わせる
  3. Request を送る前に URL を組み立てて長さを確認する

Middleware 的な存在を GraphQL Request を送る直前に噛ませる

Apollo Client には Apollo Link という Middleware があります。 これを利用することで Request する直前や Response が返ってきた直後に挟むことができます。

Apollo Link を作成する方法の一つとして setContext があります。 これは各 GraphQL が持つ Context に任意の値をセットすることができる Apollo Link を作成する関数です。 例えば以下のような Apollo Link を利用することで GraphQL Request に対して Header を追加/上書きすることができます。

const setCustomHeaderLink = setContext((_, { headers }) => ({
  headers: {
    ...headers,
    'X-Client-Platform': 'Web',
  },
}))

条件に応じて GraphQL Request に POST Method を使わせる

Apollo Client のドキュメント曰く、 context.fetchOption.method"POST" をセットすることで POST method を利用させることができるようです。

https://www.apollographql.com/docs/react/networking/advanced-http-networking/#overriding-options

const usePostMethodConditionallyLink = setContext((request) => ({
  fetchOption: {
    ...fetchOption,
    method: shouldUsePostMethod(request) ? 'POST' : 'GET',
  },
}))

Request を送る前に URL を組み立てて長さを確認する

GraphQL Request を送る前に URL 長を算出する方法については調査しても見つかりませんでした。 しかし、 Apollo Client からは実際に GET Request の URL が生成されているため、処理のどこかで URL を組み立てているはずです。 そこで Apollo Client の実装を見てみることとしました。

Apollo Client から提供されている HTTP Client では実際に GraphQL Request を行います。 ここの実装を見れば URL の組み立てを行なっているコードが見つかるはずです。

実際の data fetch はここで実行されていそうです。 https://github.com/apollographql/apollo-client/blob/d470c964db46728d8a5dfc63990859c550fa1656/src/link/http/createHttpLink.ts#L121

fetch に渡されている chosenURIrewriteURIForGET で生成されています。 https://github.com/apollographql/apollo-client/blob/8f79bb2547e549dadb9451c0541f174668a92bf1/src/link/http/createHttpLink.ts#L145

このコードを参考に URL を組み立てて長さを確認する Apollo Link を書いてみましょう。

import { Operation, Context, selectURI, selectHttpOptionsAndBody, fallbackHttpConfig, rewriteURIForGET } from '@apollo/client'

export const calculateGetURI = (operation: Operation, context: Context) => {
  // https://github.com/apollographql/apollo-client/blob/8f79bb2547e549dadb9451c0541f174668a92bf1/src/link/http/createHttpLink.ts#L55
  const uri = selectURI(operation, 'https://example.com/graphql') as string

  // https://github.com/apollographql/apollo-client/blob/8f79bb2547e549dadb9451c0541f174668a92bf1/src/link/http/createHttpLink.ts#L82-L87
  const contextConfig = {
    http: context.http,
    options: context.fetchOptions,
    credentials: context.credentials,
    headers: context.headers,
  }

  // https://github.com/apollographql/apollo-client/blob/8f79bb2547e549dadb9451c0541f174668a92bf1/src/link/http/createHttpLink.ts#L90
  // apollo-client の実装内では `selectHttpOptionsAndBodyInternal` が使われていますが、この関数は `@apollo/client` から export されていません。
  // `selectHttpOptionsAndBody` は `selectHttpOptionsAndBodyInternal` を薄く wrap しただけの関数であり、これを利用しても結果に差はでませんでした。
  const { body } = selectHttpOptionsAndBody(operation, fallbackHttpConfig, {}, contextConfig)

  // https://github.com/apollographql/apollo-client/blob/8f79bb2547e549dadb9451c0541f174668a92bf1/src/link/http/createHttpLink.ts#L145
  const { newURI } = rewriteURIForGET(uri, body)
}

const operation = { /* ... */ } as Operation
const context = { /* ... */ } as Context
const uri = calculateGetURI(operation, context)

console.log(uri)
// => https://example.com/graphql?operationName=testQuery&variables=...

上記のコードで利用した selectURI, selectHttpOptionsAndBody, fallbackHttpConfig, rewriteURIForGET の 4 つは undocumented な定数や関数であるため、 @apollo/client のアップデートによって変更されたり今回の意図した処理が実現できなくなったりする可能性があることに注意してください。 @apollo/client@3.4.11 では上記のコードで意図した通りに動くことを確認しているため、ここでは一旦解決とします。

実装

「GraphQL Request の URL が長すぎる時に POST Request を利用する」を実現するために必要な検討が終わり、実装が可能そうだということがわかりました。 最終的な実装は以下のようになりました。

import { Operation } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { calculateGetURI } from './calculateGetURI'

const POST_FALLBACK_URI_LENGTH_THRESHOLD = 1024 * 4

const isMutation = (operation: Operation) =>
  operation.query.definitions.some((d) => d.kind === 'OperationDefinition' && d.operation === 'mutation')

export const checkUriLengthLink = setContext((request, context) => {
  const operation: Operation = {
    ...request,
    variables: request.variables ?? {},
    operationName: request.operationName ?? '',
    extensions: request.extensions ?? [],
    getContext: () => context,
    setContext: () => {
      throw new Error('not implemented `setContext` was called')
    },
  }

  if (isMutation(operation) || context.fetchOptions.method === 'POST') {
    return {}
  }

  const uri = calculateGetURI(operation, context)
  if (uri === undefined) return {}

  const { size } = new Blob([uri], { type: 'text/plain' })
  if (POST_FALLBACK_URI_LENGTH_THRESHOLD < size) {
    return {
      fetchOptions: {
        ...context.fetchOptions,
        method: 'POST',
      },
    }
  }

return {}
})

POST_FALLBACK_URI_LENGTH_THRESHOLD の値は Nginx に 414 を返されないようにするためであれば 8KB 以下であれば問題ありません。 しかし Google ChromeSafari では 5KB 辺りを超えると Connection Close されてしまうため、ここでは余裕を持って 4KB を設定しています。

おわりに

本記事では、 GraphQL Request の URL が長すぎる時に POST Request を利用する checkUriLengthLink を紹介しました。

Android では apollo-kotlin, iOS では apollo-ios を利用しており、 @apollo/client と設計思想が似ているので基本的には同様の手順で修正できました。 問題の発見や Native App での修正方針 、対応まで @nkmrh さんがやってくださいました! Special Thanks!!!

スタディサプリでは Application の技術的課題を一緒に解決していく仲間を募集しています。

https://brand.studysapuri.jp/career/