フォーラムで、ある開発者の投稿を読んでいました。請求は有効になっており、Generative Language API も有効で、キーも正しいと確認されていました。モデル一覧の取得は 200 で返ってきます。それなのに生成リクエストだけが 403、エラーコードは permission_denied で、本文は「Your project has been denied access. Please contact support.」。
投稿には、解決の報告がありませんでした。
読み終えてから、胃のあたりが少し重くなりました。自分のプロジェクトで同じことが起きたら、私はおそらく最初の一時間を、キーを作り直して、リトライを回して、設定画面を行き来することに使うだろうと思ったからです。個人開発で Gemini API を呼んでいる身として、症状が出てから考えるのでは遅いと感じ、切り分けの順番を先に書き出しておくことにしました。
この記事は、その順番の記録です。私自身がこの 403 に遭遇したわけではなく、公式ドキュメントとフォーラムの報告から組み立てた手順だという点は、先にお伝えしておきます。
最初にお伝えしたいのは「リトライしない」ことです
公式のトラブルシューティングには、403 のようなクライアントエラーはリトライしないよう書かれています。理由として挙がっているのは、無効な API キー、Prepay クレジットの枯渇、リクエストの構文エラーといった「待っても直らない種類の問題」だからです。リトライが向くのは 429 や 5xx のような一時的なエラーです。
エラー表でも、permission_denied の説明は「API キーにこのリソースへの権限がない」、対処は「キーの権限とプロジェクトへのアクセスを確認する」とされています。つまり、直す場所はコードの外にあります。
| HTTP | コード | 待てば直るか | 最初に見る場所 |
|---|---|---|---|
| 400 | failed_precondition | いいえ | 請求が無効になっていないか |
| 402 | payment_required | いいえ | Prepay 残高 |
| 403 | permission_denied | いいえ | キーの権限・プロジェクトの状態 |
| 429 | rate_limit_exceeded | はい | 待って指数バックオフ |
| 503 | service_unavailable | はい | 待って指数バックオフ |
429 の側の見分け方は、別の記事に書いています。Gemini API の 429 を全部リトライしてはいけません — レート制限と Spend Cap 枯渇を見分けるリトライ設計も合わせてご覧ください。
「models が通る」ことは、生成が通る証拠になりません
モデル一覧の取得が 200 で返るのは、キーが認識され、プロジェクトが存在することまでしか示しません。生成リクエストは、そのプロジェクトに対する別の判定を通ります。ですから、一覧が返ったので鍵は問題ない、と結論するのは早すぎます。
私が先に決めた順番は、次の三段です。
- キー: そのキーは、想定のプロジェクトのものか。制限(HTTP リファラーなど)が付いていないか
- プロジェクト: 想定のプロジェクトは、AI Studio の Projects ページでどの状態に見えるか
- 請求: 請求アカウントに紐づいているか。Prepay なら残高があるか
キーの制限が原因なら、別の記事のGemini API キーに HTTP リファラー制限をかけたら本番サイトから 403 エラーになる原因と対処が当たります。こちらは設定を直せば解消する、救いのある 403 です。
厄介なのは、キーにも請求にも見える範囲で問題がないのに、プロジェクト単位で拒否されている場合です。フォーラムの投稿が、まさにこの形でした。
三段を機械的に確かめる小さなスクリプト
手で順番に叩くと、途中で気が変わります。そこで、モデル一覧と生成を同じキーで続けて叩き、結果を並べて出すだけのスクリプトを用意しました。複数のキーを渡せば、同じ表に並びます。
目的は、「どのキーで、どのエンドポイントが、どの status で返ったか」を一枚にすることです。サポートへ渡す材料にも、そのまま使えます。
import json
import os
import sys
import urllib.error
import urllib.request
BASE = "https://generativelanguage.googleapis.com/v1beta"
# 差し替えてください。確認したいモデル ID を環境変数で渡せます。
MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.8-flash")
def call(method, path, key, body=None):
"""1 回だけ呼ぶ。リトライはしない(403 は待っても直らないため)。"""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}{path}",
data=data,
method=method,
headers={"x-goog-api-key": key, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as res:
return res.status, ""
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
try:
err = json.loads(raw).get("error", {})
return e.code, f'{err.get("status", "")} {err.get("message", "")}'[:120]
except json.JSONDecodeError:
return e.code, raw[:120]
def probe(label, key):
models = call("GET", "/models?pageSize=1", key)
gen = call(
"POST",
f"/models/{MODEL}:generateContent",
key,
{"contents": [{"parts": [{"text": "ping"}]}],
"generationConfig": {"maxOutputTokens": 8}},
)
print(f"{label:<14} models={models[0]} generate={gen[0]} {gen[1]}")
if __name__ == "__main__":
# 使い方: python probe.py KEY_A_ENV KEY_B_ENV
# 引数は「キーが入っている環境変数名」。キー本体は渡さない。
for name in sys.argv[1:]:
value = os.environ.get(name)
if not value:
print(f"{name:<14} 環境変数が空です")
continue
probe(name, value)引数にキーそのものではなく環境変数の名前を渡すのは、シェルの履歴にキーを残さないためです。生成のリクエストは maxOutputTokens を 8 に絞っているので、請求への影響はごく小さく抑えられます。
出力の読み方は三通りです。
| models | generate | 読み取れること |
|---|---|---|
| 200 | 200 | このキーは通っている。呼び出し側のコードやモデル ID を疑う |
| 200 | 403 | キーは認識されているが、生成だけ拒否。上の三段を順に |
| 403 | 403 | キーそのものが拒否されている。流出で無効化された可能性も |
流出したキーは、公開された時点で止められることがあります。その場合のメッセージは「Your API key was reported as leaked. Please use another API key.」で、対処は AI Studio での再発行です。
別プロジェクトのキーを 1 本並べると、場所が絞れます
私がいちばん役に立つと考えているのは、診断の最後にもう 1 本、別のプロジェクトのキーを並べることです。
同じスクリプトに、動いているプロジェクトのキーと、止まっているキーを両方渡します。前者が generate=200、後者が generate=403 なら、コードでもモデル ID でもネットワークでもなく、止まっているプロジェクト側の問題だと言い切れます。ここまで絞れてから問い合わせるのと、絞らずに問い合わせるのとでは、返事の速さが変わるのだと思います。
請求の構造も、この切り分けに効いてきます。公式の請求ページによると、ティアと上限は請求アカウント単位で決まり、プロジェクトはリンクされた請求アカウントのティアを引き継ぎます。キー自体に請求設定はなく、プロジェクトの状態を引き継ぎます。また、Prepay の残高が 0 になると、その請求アカウントに紐づく全プロジェクトの全キーが同時に止まります。
つまり、「1 本だけ止まっている」なら残高ではなくプロジェクト単位の問題で、「全部止まっている」なら残高や請求アカウント単位の問題だ、という目安が立ちます。
請求の反映には時間差があります
請求を有効にした直後の確認では、もう一つ気をつけたい点があります。公式には、請求のシグナルや更新は必ずしもリアルタイムではないと書かれています。ティアの更新は通常 10 分ほどで反映され、銀行振込のような支払い方法では数日かかることもあります。
設定を変えてすぐ叩いて 403 だったとしても、そこで結論を出さないほうがよいでしょう。ただし、十数分待っても変わらず、AI Studio の Billing ページや Projects ページにも「Set up billing」や「No credits」のような案内が出ていないなら、設定の問題ではなく、プロジェクトに対する管理上の制限を疑う段階です。
フォーラムの案内でも、有料ティアでは AI Studio の Projects と Billing のページで警告バナーや異議申し立てのリンクを確認し、プロジェクトや請求アカウントが停止されている場合は Google Cloud Console から申し立てるよう示されていました。メールの受信箱にも通知が届いていることがあります。
サポートへ渡す材料
ここまで絞れたら、次は伝え方です。次の項目を、一通のメッセージにまとめておきます。
- 失敗しているエンドポイントと、成功しているエンドポイント(models は 200、generateContent は 403)
- エラーの本文、
status、codeの原文 - スクリプトの出力(キーの名前だけを載せ、キー本体は載せない)
- 別プロジェクトのキーでは成功していること
- 請求を有効にした日時と、Billing ページに表示されている状態
フォーラムの投稿では、サポートプランがないと Cloud Support に連絡できないことも、行き詰まりの一因になっていました。その場合に使える入口はフォーラムでの公開質問と、AI Studio 側の案内に限られます。そのため、最初の一通に事実をすべて載せておくことが、結局はいちばん近道になります。
私が引いた線
今回の整理で、私の中に残った線引きは一つです。
403 を見たら、コードを触る前に、二本のキーを並べる。
リトライも、キーの作り直しも、そのあとです。Prepay で動かしているアプリ(壁紙の分類処理など)に、このスクリプトを小さな確認コマンドとして置いておくつもりでおります。症状が出た夜に、手の止まった状態で順番を考えなくて済むからです。
まずは、ご自分のプロジェクトのキーに対して、上のスクリプトを一度だけ実行して、models と generate がどちらも 200 で返ることを確かめておいてください。止まっていない日の出力を、一枚残しておくことをおすすめします。