プライベートでGitHubのGraphQL APIを使ってGitHub Project V2のデータを定期的に取得している
で、いつの間にか次のようなエラーが出るようになっていた
gh: API rate limit exceeded for user ID xxxxxxx.
ということでGitHubのGraphQL APIのリソース制限について調べたのでメモを残しておく
公式ドキュメント
これ読んで理解できて対応できるならもう特にこの先読むことはなさそう
APIのレート制限が異なっているのはなぜでしょうか? GraphQL では、1 つの GraphQL 呼び出しで複数の REST 呼び出しを置き換えることができます。 単一の複雑なGraphQLの呼び出しが、数千のRESTリクエストと等価なこともあります。 単一の GraphQL 呼び出しは REST API レート制限を大幅に下回りますが、クエリはGitHub のサーバーが演算するのと同等の負荷になる可能性があります。 サーバーでのクエリのコストを正確に表すために、GraphQL API では呼び出しのレート制限スコアを正規化されたポイントのスケールに基づいて計算します。 クエリのスコアは、親のコネクションやその子のfirst及びlast引数を計算に入れます。
ということで、定義されたポイントをもとに制限している
で、今回はその制限にかかってエラーが起きてたという話
以下は「色々試してみた」内容なのであんまり…
コストがどのくらいかかっているのか確認する
これもドキュメントにあるが次のようなクエリを追加することでRateLimit関連の情報を得られる
rateLimit { limit cost remaining resetAt }
とりあえずドキュメントのサンプルで試してみた結果から抜粋した
"rateLimit": { "limit": 5000, "cost": 1, "remaining": 4965, "resetAt": "2023-06-05T01:29:00Z" }
最低コストは1らしいので1がコストとして消費された
実際に多くコスト消費しているクエリをみてみる
- items_v2.graphql
これ
query($projectId: ID! $endCursor: String){ node(id: $projectId) { ... on ProjectV2 { items(first: 50, after: $endCursor) { pageInfo { hasNextPage endCursor } nodes{ id createdAt updatedAt isArchived type fieldValues(first: 20) { nodes{ ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldSingleSelectValue { name nameHTML optionId field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldIterationValue { iterationId startDate title titleHTML field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldLabelValue { labels(first: 5) { nodes { color description isDefault name url } } field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldMilestoneValue { milestone { id title } field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldPullRequestValue { pullRequests(first: 10) { nodes { title id number url closed closedAt createdAt merged mergedAt repository { name } assignees(first: 5) { nodes{ name login } } reviewRequests(first: 5) { nodes { requestedReviewer { ... on User { name } } } } } } field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldRepositoryValue { repository { name } field { ... on ProjectV2FieldCommon { id dataType name } } } ... on ProjectV2ItemFieldUserValue { users(first: 3) { nodes { id login name } } field { ... on ProjectV2FieldCommon { id dataType name } } } } } content{ ... on DraftIssue { title body createdAt } ...on Issue { title id number url closed closedAt createdAt repository { name } milestone { id title } assignees(first: 5) { nodes{ name login } } } ...on PullRequest { title id number url closed closedAt createdAt merged mergedAt repository { name } assignees(first: 5) { nodes{ name login } } reviewRequests(first: 5) { nodes { requestedReviewer { ... on User { name } } } } } } } } } } }
query直下にrateLimit
を入れて実行した結果
"rateLimit": { "limit": 5000, "cost": 210, "remaining": 4545, "resetAt": "2023-06-05T01:29:00Z" }
一度の実行でコスト210も消費している
プロジェクトの都合上処理の流れとしてプロジェクトにひもづくすべてのItemを再帰的に取得していて、今回のケースだと24回目の実行時にエラーが出ていた…
210 * 24= 5040
1時間にコスト5000までのGraphQLリクエストが可能、5000を超えるリクエストを実行しようとした時点でエラー
消費コストを下げられる箇所を探す
コスト計算のおさらい
コスト = データを取得するためにする接続要求の合計/100
公式から具体例が示されている
- 100 個のリポジトリが返されますが、API でリポジトリのリストを取得するために表示者のアカウントに接続する必要があるのは一度だけです。 したがって、リポジトリの要求 = 1 です
- 50 個の Issue が返されますが、API では Issue のリストを取得するために 100 個のリポジトリそれぞれに接続する必要があります。 したがって、Issue の要求 = 100 です
- 60 個のラベルが返されますが、API ではラベルのリストを取得するために潜在的に合計 5,000 個の Issue のそれぞれに接続する必要があります。 したがって、ラベルの要求 = 5,000 です
- 合計 = 5,101 です
試しに、公式に載っているクエリをたたいてみたら次のようになった
"rateLimit": { "limit": 5000, "cost": 51, "remaining": 4944, "resetAt": "2023-07-28T03:00:31Z" }
たしかにってところを確認した
実際のクエリで210ということは要求数でいうと21000くらいということになる
把握しやすくするため、とりあえず計算に影響しそうなところだけ残したクエリを準備
- sample.graphql
query($projectId: ID! $endCursor: String){ node(id: $projectId) { ... on ProjectV2 { items(first: 50, after: $endCursor) { pageInfo { hasNextPage endCursor } nodes{ id fieldValues(first: 20) { nodes{ ... on ProjectV2ItemFieldLabelValue { labels(first: 5) { nodes { name } } } ... on ProjectV2ItemFieldPullRequestValue { pullRequests(first: 10) { nodes { title repository { name } assignees(first: 5) { nodes{ name } } reviewRequests(first: 5) { nodes { requestedReviewer { ... on User { name } } } } } } } ... on ProjectV2ItemFieldUserValue { users(first: 3) { nodes { id } } } } } content{ ... on DraftIssue { title body } ...on Issue { id repository { name } milestone { id } assignees(first: 5) { nodes{ name } } } ...on PullRequest { title id repository { name } assignees(first: 5) { nodes{ name } } reviewRequests(first: 5) { nodes { requestedReviewer { ... on User { name } } } } } } } } } } rateLimit { limit cost remaining resetAt } }
- 結果
"rateLimit": { "limit": 5000, "cost": 210, "remaining": 4720, "resetAt": "2023-07-28T04:03:55Z" }
増減していないので大丈夫そう
削減箇所を決める
試しにitems以下で1つずつfirst
の数値を下げて実行してみた結果
- fieldValues
- fieldValues.nodes.pullRequests
の2つのみしかコストの値に変化がなかった
ChatGPTくんに聞いたら「実際のコストは取得されるノードの実際の数によって異なり」とのこと、クエリの結果を鑑みると正しそうだが公式に明記されているのを見つけられなかった
実際に他の部分ではUserだったりAssigneeだったりと複数取ってこれるが実際は自分だけなのでほぼ1件しか返ってこないからかな
ということで変化のあった数値に関してどうするか決める
fieldValues
fieldValuesはカスタムで設定した列やデフォルトで含まれている列などの情報
GitHub Projectsの仕様で追加される、自身で追加するなどの事象が起きない限りは増えなそう
現状を確認し最大12個データが存在した
元の数値が20だったのでちょっと余裕もたせて15で設定した
158まで落ちた
fieldValues.nodes.pullRquests
今回のケースでは1つのItemに最大8個PullRequestをひもづけているItemがあったので最大8個とした
128まで落ちた
fieldValues.pullRequests.assigneesとfieldValues.pullRequests.reviewRequests
fieldValues.pullRequestsにひもづく、reviewRequestsを消したらかなり下がった
ここまで削減した数値の変化はいったんなしで試した例を載せている
fieldValues: 20 pr: 10 reviewRequests 1 -> 0 210 - 110
-100
assigneeを消しても同様
両方消したら10になったw
やっとわかってきた、これまでの例を見ると
PullRequestsのデータを取得するのに
50 * 20 / 100 = 10
PullRequestにひもづくデータを見に行く場合(assignee, reviewRequestsを指定する場合)
50 * 20 * 10 / 100 = 100
これが2つに対してコストが掛かる
なので210
他の部分のコストは1未満ということになりそう
content以下1つずつ消してもcontent自体を消しても特に変化なし
下位のNodeを見に行く必要があるのでそこまでの積算分要求が必要ということのよう
query($projectId: ID! $endCursor: String){ node(id: $projectId) { ... on ProjectV2 { items(first: 50, after: $endCursor) { pageInfo { hasNextPage endCursor } nodes{ id } } } } rateLimit { limit cost remaining resetAt } }
これだとコスト1
query($projectId: ID! $endCursor: String){ node(id: $projectId) { ... on ProjectV2 { items(first: 50, after: $endCursor) { pageInfo { hasNextPage endCursor } nodes{ id fieldValues(first: 20) { nodes{ ... on ProjectV2ItemFieldPullRequestValue { pullRequests(first: 10) { nodes { title repository { name } } } } } } } } } } rateLimit { limit cost remaining resetAt } }
これでコスト10
今回の利用ケースではPullRequestのAssigneeはいるけどreviewRequests
は不要と判断し片方だけ消すことにした
最終的なコストの内訳
最終的な内訳はこんな感じ
fieldValues.pullRequestsが必要なので 50(items) * 15(fieldValues) / 100 = 7.5 fieldValues.pullRequests.assigneesが必要なので 50(items) * 15(fieldValues) * 8(pullRequests) / 100 = 60
他はコスト1未満
小数点切り上げで68
という感じで最終的なコストは68となった
他
Actionsで本クエリを実行しているが、GitHub AppsのOAuthで委譲しているから自分のユーザーIDでリソース消費している
なのでPAT増やしたりしても1ユーザーにつき1時間5000のコストまでしかリクエストできない
他にも何かやったりしたらそれだけコスト消費するので消費コストを抑えられれば抑えられただけ良い
おわり
色々試してやっと「なんとなくわかった」という感じだが勉強にはなった