冒頭まとめ
GCP の 400 Bad Request は、1つの意味を持つエラーではありません。Google が公開しているエラー区分の定義ファイルを見ると、400 に対応する区分は3つあります。引数が不正な場合、対象の状態がその操作を許さない場合、そして値が有効な範囲の外にある場合です。
この3つは、対処が根本的に違います。定義の説明文が、その違いをはっきり述べています。1つ目は「系の状態に関係なく問題のある引数」を指し、例として形式の壊れたファイル名が挙げられています。何度送っても結果は変わりません。2つ目は「系がその操作に必要な状態にない」ことを指し、例として空でないディレクトリの削除が挙げられています。状態を直せば同じ要求が通ります。3つ目は「有効な範囲を越えた操作」で、ファイルの終端を越えて読もうとした場合が例です。
したがって、GCP で 400 を受け取ったときに最初に読むべきは、状態コードではなく応答の status の値です。ここを読まずに要求の書式を疑うと、2つ目や3つ目の場合に見当違いの調査を続けることになります。
さらに、応答には details という配列が付きます。設計の指針には、すべてのエラー応答は機械が読める識別子を含まなければならない、と定められています。どの項目が悪いかを名指しする構造もこの中に入るため、原因の特定はここでほぼ完了します。
エラーの概要
実際の応答は、基本の4項目と details 配列で構成されます。
{
"error": {
"code": 400,
"message": "There was a problem with the request.",
"status": "INVALID_ARGUMENT",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "INVALID_ARGUMENT",
"domain": "example.googleapis.com",
"metadata": { "requestId": "t-a8896317-069f-4198-afed-182a3872a660" }
},
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{
"field": "destinations[0].login_account.account_id",
"description": "String is not a valid number.",
"reason": "INVALID_NUMBER_FORMAT"
}
]
}
]
}
}
details の中身は @type で種類が分かれます。ErrorInfo は機械が読める識別子で、reason に大文字と下線だけの短い語が入ります。設計の指針では、この語は63文字以内で、大文字・数字・下線の形式に従うと定められています。domain には、どのサービスが出したエラーかが入ります。
BadRequest は、どの項目が悪いかを名指しする構造です。field には項目の位置が点でつないだ形で入り、配列の要素なら添字も付きます。description に理由の説明、reason に項目ごとの識別子が入ります。上の例では、どの宛先のどの項目が数値として読めないかまで分かります。
設計の指針には、同じ種類は1つの応答に1回までとも書かれています。したがって BadRequest が2つ入ることはありませんが、BadRequest と PreconditionFailure が同時に入ることはあります。
なお、details の内容は要求時に送る利用者側の名乗りの種類によって出し分けられる場合があります。公式のソフトウェア開発キットを経由すると詳しい情報が得られるのに、手作業で叩くと簡素な応答しか返らない、ということが起こります。
まず最初に:status を読み、次に details を開く
第一に、status の値を読みます。INVALID_ARGUMENT なら送った値そのものが不正です。FAILED_PRECONDITION なら値は正しく、対象の状態が問題です。OUT_OF_RANGE なら形式は正しいが範囲の外です。
第二に、再試行の可否がここで決まります。定義の説明には判定の基準が書かれており、状態が明示的に直されるまで再試行すべきでないのが2つ目、上位の処理からやり直すべきなのが ABORTED、失敗した呼び出しだけを再試行してよいのが UNAVAILABLE とされています。1つ目は、そもそも要求を直さない限り変わりません。
第三に、details を開きます。fieldViolations があれば、どの項目が悪いかがそこに書かれています。ErrorInfo の reason は、自動化の中で分岐に使える識別子です。文言ではなくこの値で判定すれば、表示の変更に影響されません。
よくある原因と解決手順
原因1:項目の値が不正(INVALID_ARGUMENT)
最も多い形です。fieldViolations を見れば、どの項目かが分かります。
Before(応答を読まずに要求全体を疑う):
gcloud <サービス> <操作> --arg=value
# → 400 が返るが、どの項目が悪いか分からないまま設定を見直す
After(項目名を取り出してから直す):
curl -sS -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d @request.json \
"https://<サービス>.googleapis.com/v1/<資源>" | \
python3 -c "
import json,sys
d=json.load(sys.stdin)['error']
print(d['status'], '|', d['message'])
for x in d.get('details', []):
if x['@type'].endswith('BadRequest'):
for v in x['fieldViolations']:
print(' 項目:', v['field'], '/', v.get('reason'), '/', v['description'])
"
項目の位置は点でつないだ形で示されます。destinations[0].login_account.account_id であれば、宛先の1番目の、認証情報の中の、識別子の項目です。この表記を辿れば、送っている内容のどこを直すかが一意に決まります。
なお、資源の名前の形式が違う場合も、この区分になります。この場合 fieldViolations ではなく ErrorInfo の側に、形式が不正である旨の識別子が入ることがあります。正しい形式が message に併記されることも多いので、そちらも読んでください。
原因2:対象の状態が操作を許さない(FAILED_PRECONDITION)
送った値に誤りはありません。対象が今その操作を受け付けられる状態にないだけです。定義の説明にある空でないディレクトリの削除が、そのまま典型例です。
この場合、details に PreconditionFailure が入ることがあります。この構造には、違反の種類、対象、そして直し方の説明が含まれます。説明は開発者が対処を理解するためのもの、と定義に明記されています。
Before(値を疑って何度も送り直す):
for i in 1 2 3; do
gcloud <サービス> <操作> --arg=value && break
done
# → 状態が変わらない限り、何度でも同じ結果
After(対象の現在の状態を確認してから判断する):
gcloud <サービス> describe <対象> --format="yaml(state, status)"
対象が作成中や削除中であれば、完了を待てば通ります。依存する別の資源が未作成であれば、そちらを先に用意します。この区分は、待つか順序を変えるかで解決する種類のものです。
原因3:値が範囲の外(OUT_OF_RANGE)
形式は正しいが、許される範囲を越えています。定義には、1つ目との違いが具体的に書かれています。32ビットのファイル系で読み取り位置を指定する場合、そもそも表現できない値なら1つ目、表現はできるが今のファイルの大きさを越えているならこの区分になる、という例です。
つまり「値としては妥当だが、今の状態では届かない」という位置づけです。定義には2つ目との重なりが大きいことも述べられており、順に辿っていく処理で終端を検出しやすいよう、当てはまる場合はこちらを使うことが推奨されています。
対処は、上限を文書で確認して範囲に収めることです。一覧の取得であれば、位置を自分で指定するのではなく、続きを示す値を使う方式に切り替えると、範囲の管理が不要になります。
原因4:識別子ではなく文言で分岐している
自動化の中で 400 を扱う場合の設計の問題です。message の文言は表示用なので、変更されることがあります。文字列の一致で分岐していると、その時点で判定が外れます。
設計の指針には、機械が読める識別子を提供する目的が明記されています。ErrorInfo の reason と domain の組で判定してください。
Before(文言で判定する):
if "invalid argument" in str(err).lower():
...
After(識別子で判定する):
info = next((d for d in err.details
if d["@type"].endswith("ErrorInfo")), None)
if info and info["reason"] == "INVALID_NUMBER_FORMAT":
...
reason は区分名より細かい粒度を持ちます。同じ INVALID_ARGUMENT でも、reason は個別の原因を指すため、分岐を細かく書けます。
原因5:詳細が返ってこない
details が空、または ErrorInfo しか入っていない場合です。前述のとおり、利用者側の名乗りの種類によって出し分けられることがあります。
公式の開発キットを経由して同じ要求を行うと、詳しい情報が得られる場合があります。手作業で確認する場合は、コマンド行の道具に詳細表示の指定を付けると、送受信の内容がそのまま見えます。
gcloud <サービス> <操作> --log-http 2>&1 | sed -n '/== body start ==/,/== body end ==/p'
それでも項目が特定できない場合は、要求を最小構成まで削って通ることを確認し、項目を1つずつ戻す方法が確実です。
補足:似ているが別のもの
同じ 400 でも区分が3つあるのは前述のとおりです。加えて、対象が見つからない場合は 404、権限が足りない場合は 403 になります(GCP の 404 の記事、403 の記事)。認証そのものが通っていない場合は 401 です(GCP の 401 の記事)。
要求の頻度や量が上限を超えた場合は 429 です(GCP の 429 の記事)。範囲の超過と量の超過は別の区分なので、status で見分けてください。
検証の失敗に 422 を探しても見つかりません。GCP の区分の定義に 422 は存在せず、検証は 400 として返ります(GCP の 422 の記事)。他のサービスから移ってきた場合、ここで手が止まりやすい箇所です。
処理が内部で失敗した場合は 500、一時的に処理できない場合は 503、時間切れは 504 です(GCP の 500 の記事、503 の記事、504 の記事)。
切り分けの順序
statusの値を読む。3つのうちどれかで、次にやることが決まる。INVALID_ARGUMENTならdetailsのfieldViolationsを開き、項目名を特定する。FAILED_PRECONDITIONなら対象の状態を確認する。値を直しても変わらない。OUT_OF_RANGEなら上限を文書で確認する。位置指定をやめて続きの値を使う方式も検討する。ErrorInfoのreasonを控える。自動化の分岐は、この値で書く。detailsが乏しい場合は、公式の開発キット経由で試すか、送受信の内容をそのまま表示させる。- それでも特定できない場合は、要求を最小構成まで削り、項目を1つずつ戻す。
確認コマンド集
# 1. status と message、項目ごとの違反をまとめて取り出す
curl -sS -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" -d @request.json \
"https://<サービス>.googleapis.com/v1/<資源>" | python3 -c "
import json,sys
d=json.load(sys.stdin)['error']
print('status:', d['status'])
print('message:', d['message'])
for x in d.get('details', []):
t=x['@type'].split('.')[-1]
if t=='ErrorInfo': print(' reason:', x.get('reason'), '/ domain:', x.get('domain'))
if t=='BadRequest':
for v in x['fieldViolations']: print(' field:', v['field'], '/', v.get('reason'))
if t=='PreconditionFailure':
for v in x['violations']: print(' precondition:', v.get('type'), '/', v.get('description'))
"
# 2. コマンドが実際に送っている内容を見る
gcloud <サービス> <操作> --log-http 2>&1 | sed -n '/== body start ==/,/== body end ==/p'
# 3. 対象の現在の状態を確認する(FAILED_PRECONDITION のとき)
gcloud <サービス> describe <対象> --format="yaml(state, status)"
# 4. 記録から 400 を抽出し、内訳を数える
gcloud logging read 'severity=ERROR AND httpRequest.status=400' \
--limit=100 --format="value(protoPayload.status.message)" | sort | uniq -c | sort -rn
# 5. 最小構成で通ることを確認してから、項目を1つずつ戻す
curl -sS -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" -d '{"<必須項目>": "<値>"}' \
"https://<サービス>.googleapis.com/v1/<資源>"
Editor’s Note
GCP のエラー応答の設計には、明文化された指針があります。読むと、この記事で述べてきた読み方が偶然の産物ではないことが分かります。
指針には、すべてのエラー応答は details の中に機械が読める識別子を含まなければならない、と書かれています。理由も添えられていて、利用者がエラーの特定の側面に対してコードを書けるようにするため、とされています。つまり、文言で分岐するのではなく識別子で分岐することが、最初から想定された使い方です。
もう1つ、種類ごとに1回までという制約も定められています。同じ応答に BadRequest が2つ入ることはなく、しかし BadRequest と PreconditionFailure が同時に入ることはあります。これは、応答を読む側の処理を単純にする配慮です。種類で探せば、必ず0個か1個しか見つかりません。
こうした設計があるにもかかわらず、実務では message の文言を読んで手作業で対処することが多くなります。理由の1つは、詳細が利用者側の名乗りによって出し分けられることでしょう。手元で確かめようとして手作業で叩いたときに簡素な応答しか返らなければ、そういう仕組みだと気付く機会がありません。
名札の貼り替えが実際に起きている記録もあります。開発支援の道具から Gemini の窓口を呼んだ際の報告(Aider-AI/aider Issue #3817)では、返ってきた応答そのものは status が INVALID_ARGUMENT、ErrorInfo の reason が API_KEY_INVALID の 400 でした。鍵という引数が、それ自体として不正だという通知です。ところが間に挟まった部品はこれを認証のエラーとして包み直し、提供元があなたを認証できない、という文言で表示しています。この表示から 401 を想定して認証の仕組みを調べ始めれば、遠回りです。応答の status と reason を直接読む、という本記事の順序は、途中の道具が貼り替えた名札に惑わされないための順序でもあります。
400 は、エラーの中では情報量が多い部類です。区分が3つに分かれ、項目名が名指しされ、識別子まで付いてきます。それを受け取っているのにもかかわらず文言だけを読むのは、もったいない使い方です。まず status、次に details。この順序を習慣にしてください。
免責事項:本記事の内容は、執筆時点の公開情報をもとに作成したものです。ソフトウェアの仕様は予告なく変更されることがあります。最新の情報は各ツールの公式サポートページをご確認ください。本記事の情報を利用した結果生じたいかなる損害についても、著者および運営者は責任を負いかねます。
この記事でエラーは解決しましたか?