OpenAIの残高切れ(429 insufficient_quota)をn8nで検知する方法

n8nでOpenAIを使うワークフローが、そろって「生成に失敗しました」のような代わりの文を返すことがあります。実行一覧を見ると全部 success で、どこが壊れたのか分かりません。弊社の本番では、その原因がOpenAI APIの残高切れ(HTTP 429・insufficient_quota)でした。
弊社は同じ止まり方を6回起こし、見張りのワークフローを建てたあとも気づけなかったことがあります。この記事では、止まったものを戻す方法と、残高切れを見分ける見張りの作り方を、ソースと公式ドキュメントで確かめた範囲で書きます。
この記事の結論
- いま止まっているなら、応答の本文の
error.codeを見て直す画面を選びます。credit_balance_exhausted(残高切れ)なら Billing の画面でクレジットを足します。待っても戻りません。organization_usage_limit_exceededなどの上限の 429 なら、直すのは上限の画面です。 - OpenAIの 429 は「送るのが速すぎる」ときにも「残高や上限が尽きた」ときにも返ります。429 という数字だけでは見分けられません。
- n8n 2.19.2 のソースで確かめた範囲では、Codeノードから
this.helpers.httpRequestを呼ぶと、例外で受け取れるのはRequest failed with status code 429などの文だけで、OpenAIの本文は入っていません。returnFullResponse: trueとignoreHttpStatusErrors: trueを付けて、本文を返り値として受け取ります。 - 見張りの確かめ先は
/v1/modelsではなく、実際に生成する呼び出しにします。合否は実行の success ではなく、返り値と、本物の残高切れでの答え合わせで決めます。
実際に起きたこと
2026年7月17日、OpenAIが insufficient_quota を返し、弊社で動かしているワークフローのうちAIを使う47本が同時に止まりました。n8nの実行は success でしたが、OpenAIへの呼び出しは 429 を返していました。失敗すると代わりの文を返して最後まで走る作りだったためです。中身はレート制限(送る速さの制限)ではなく残高切れで、時間をおいても直りません。チャージするとすぐに戻りました。
7月18日には、チャージした翌日なのにまた残高が切れました。このときは弊社より先に、デモ画面を触った方が「生成に失敗しました」を見つけています。プロンプトもモデルも違う3本がそろって落ちていたので、共通するのはOpenAIだけだと絞り込めました。復旧のあとは応答時間が0.5秒(すぐに失敗)から4.1秒(実際に生成)に変わり、これが決め手になりました。
APIキーの置き場は環境変数のファイル1つで、OPENAI_API_KEY は1本だけです。この1本に、客先に出しているAIの窓口も含めて全部がぶら下がっていました。残高が切れると全部が同時に止まる作りです。
そこで8月1日に、30分おきにOpenAIを呼んで確かめる見張りのワークフロー(以下、見張り)を建てました。ところが8月15日、4回目の残高切れで、この見張りが役に立ちませんでした。
- 6時の実行が最後の正常で、6時45分に最初のワークフローが error になりました。この日の error は、朝10時までに10件になっていました。
- OpenAIに頼っていて稼働中のワークフローは、47本から55本に増えていました。
- 画像の生成も文章の生成も、HTTP 429 で
type: insufficient_quota、code: credit_balance_exhausted、本文はYou have no credits remaining.でした。一方でキーは生きていて、/v1/modelsは 200 を返しました。
見張りの最後の出力はこうでした。
status: "warn" detail: "プローブ失敗: Request failed with status code 429"
state: "ok" warnCount: 8 lastAlert: 2026-08-15 07:30:50
判定は「止まっている(dead)」ではなく「様子見(warn)」に落ちていました。通知自体は7:30:50に出ていましたが、文面は「OpenAIプローブが3回連続で失敗」と 429 だけです。残高が切れたとも、チャージの場所とも書いていません。ネットワークの一時的な不調にしか読めず、実際に見過ごされました。結局、別の作業でAPIを呼んだときに初めて分かりました。
原因
待てば戻る429と、戻らない429
OpenAIの公式のエラー一覧には、429 の項目がいくつも並んでいます。送る速さの問題と、お金や上限の問題が、同じ 429 で返ります。
| 見出し | error.code | 原因(公式の説明) | 待てば戻るか |
|---|---|---|---|
| Rate limit reached for requests | — | You are sending requests too quickly. | 戻る。間をあけて送る |
| Slow down | slow_down | Your request rate increased too quickly. | 戻る。送る量を下げ、少しずつ上げる |
| Credit balance exhausted | credit_balance_exhausted | Your organization has no prepaid credits remaining. | 戻らない。クレジットを足す |
| Organization spend limit reached | organization_spend_limit_exceeded | Your organization reached its enforced spend limit. | 月の上限が切り替わるまで戻らない。組織の支出の上限を上げるか外す |
| Project spend limit reached | project_spend_limit_exceeded | Your project reached its enforced spend limit. | 月の上限が切り替わるまで戻らない。プロジェクトの設定で上限を上げるか外す |
| Organization usage limit reached | organization_usage_limit_exceeded | Your organization reached its OpenAI-assigned usage limit. | 待てば戻るとは書かれていない。上限の引き上げを申請するか、サポートに連絡する |
表の下半分(残高と上限)は、支払いと上限にかかわるエラーです。同じページには、請求まわりのエラーは error.code を見て原因を見分けること、広い分類の error.type は insufficient_quota のままのことがある、と書かれています。さらに「Retrying billing, spend, or quota errors won’t restore API access.」とあり、送り直しでは戻りません。429 という数字だけでは、待てば戻るのか、お金や上限の手当てが要るのかが決まりません。
モデル一覧のAPIは残高が無くても200を返す
7月17日の時点で使っていた社内の点検は、モデル一覧の /v1/models に問い合わせて生死を見ていました。この呼び出しは残高が無くても 200 を返します。8月15日も、キーは生きていて /v1/models は 200 でした。枯れていたのは残高だけです。キーが有効かどうかと、生成できるかどうかは別の問いです。点検は7月18日に、chat.completions を gpt-4o-mini・max_tokens 1 で実際に呼ぶ形へ変えました。
実行が success のまま終わる
弊社のワークフローの多くは、OpenAIの呼び出しが失敗しても「生成に失敗しました」を返して最後まで走ります。そのためn8nの実行ステータスは success のままで、実行一覧を見ても異常に見えません。見張りのCodeノードも同じです。Codeノードは最後まで走れば success になるので、判定は返り値の中にしかありません。
Codeノードの helpers.httpRequest は本文を持ち帰らない
8月15日に判定が warn に落ちたのは、例外の文字列で分岐していたからです。旧版は例外の中から insufficient_quota、credit_balance_exhausted、no credits、invalid_api_key、401 を探していました。どれ1つも当たらず、最後の else の warn に落ちています。受け取れた文は Request failed with status code 429 だけで、本文は e.response.data にも e.cause.error にも入っていませんでした。
なぜ本文が消えるのかは、n8n 2.19.2 の実装で追えます(これより新しい版で同じかは確かめていません)。同梱の axios(HTTP通信のライブラリ)は 1.15.1 です。
まず、エラーの文は axios が作っています。状態コードが成功の範囲に無いと、この文で例外を投げます。例外には本文を含む response が付いています。
// axios/dist/node/axios.cjs(settle)
reject(new AxiosError('Request failed with status code ' + response.status,
[AxiosError.ERR_BAD_REQUEST, AxiosError.ERR_BAD_RESPONSE][Math.floor(response.status / 100) - 4],
response.config, response.request, response));
// axios/dist/node/axios.cjs(AxiosError の constructor)
this.response = response;
this.status = response.status;
n8n本体の httpRequest は、この例外を包み直さずにそのまま投げます。
// n8n-core/dist/execution-engine/node-execution-context/utils/request-helper-functions.js
async function invokeAxios(axiosConfig, authOptions = {}) {
try {
return await (0, axios_1.default)(axiosConfig);
}
catch (error) {
if (authOptions.sendImmediately !== false || !(error instanceof axios_1.default.AxiosError))
throw error;
ここまでは本文が残っています。消えるのは、Codeノードの中身が別のプロセスで動いているからです。2.19.2 のCodeノードは、JavaScriptを必ずタスクランナー(ユーザーが書いたコードをn8n本体から切り離して動かす仕組み)で実行します。設定の既定は internal で、公式ドキュメントは「In internal mode, the n8n instance launches the task runner as a child process」と説明しています。
// n8n-nodes-base/dist/nodes/Code/Code.node.js
const sandbox = new JsTaskRunnerSandbox_1.JsTaskRunnerSandbox(workflowMode, this);
// @n8n/config/dist/configs/runners.config.js
this.mode = 'internal';
タスクランナーの中の helpers.httpRequest は、本体へ処理を頼む呼び出し(RPC)に置き換えられています。
// @n8n/task-runner/dist/runner-types.js(EXPOSED_RPC_METHODS の末尾)
'helpers.binaryToString',
'helpers.httpRequest',
'helpers.request',
];
// @n8n/task-runner/dist/js-task-runner/js-task-runner.js
(0, set_1.default)(rpcObject, rpcMethod.split('.'), async (...args) => await this.makeRpcCall(taskId, rpcMethod, args));
本体の側で例外が起きると、その例外を返事の data に載せます。返事は仲介役(ブローカー)が受け取り、data をそのままタスクランナーへ回します。
// n8n/dist/task-runners/task-managers/task-requester.js(handleRpc)
catch (e) {
this.sendMessage({
type: 'requester:rpcresponse',
taskId,
callId,
status: 'error',
data: e,
});
}
// n8n/dist/task-runners/task-broker/task-broker.service.js(handleRequesterRpcResponse)
await this.messageRunner(runner.id, {
type: 'broker:rpcresponse',
taskId,
callId,
status,
data,
});
タスクランナーへはWebSocketで送るので、ここで文字列へ変換されます。変換の途中で、toJSON() を持つものはその形に置き換わります。
// n8n/dist/task-runners/task-broker/task-broker-ws-server.js(sendMessage)
this.runnerConnections.get(id)?.send((0, n8n_workflow_1.jsonStringify)(message, { replaceCircularRefs: true }));
// n8n-workflow/dist/cjs/utils.js(replaceCircularReferences)
if ('toJSON' in value && typeof value.toJSON === 'function')
return value.toJSON();
axios の例外の toJSON() はこうです(コメントは省略)。
// axios/dist/node/axios.cjs(AxiosError の toJSON)
toJSON() {
return {
message: this.message,
name: this.name,
description: this.description,
number: this.number,
fileName: this.fileName,
lineNumber: this.lineNumber,
columnNumber: this.columnNumber,
stack: this.stack,
config: utils$1.toJSONObject(this.config),
code: this.code,
status: this.status
};
}
返す項目に response がありません。タスクランナーの側は、届いた文字列を JSON.parse でメッセージに戻します。そのメッセージの data が、例外を toJSON() した値です。この値で呼び出しを失敗させるので、Codeノードの catch に届くのは、message や code といった項目だけのものです。本文の error.code で残高切れを見分けようとしても、例外からは見分けられません。
// @n8n/task-runner/dist/task-runner.js
// receiveMessage:届いた文字列をメッセージに戻す
const data = JSON.parse(message.data);
// onMessage(151行目):ここでの message は戻したメッセージ。その .data を渡す
this.handleRpcResponse(message.callId, message.status, message.data);
// handleRpcResponse(callId, status, data):data は上で渡した .data
call.reject(typeof data === 'string' ? new Error(data) : data);
直し方
手順1:止まっているものを戻す
いま止まっているなら、見張りより先にこちらです。生成のAPIを最小の量で1回呼び、応答の本文を見ます。
curl -s https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","max_tokens":1,"messages":[{"role":"user","content":"ping"}]}'
本文の error.code で、直す画面が決まります。
credit_balance_exhausted:Billing の画面(https://platform.openai.com/settings/organization/billing)でクレジットを足します。弊社もチャージするとすぐに戻りました。organization_spend_limit_exceeded:Limits の画面(https://platform.openai.com/settings/organization/limits)で、組織の支出の上限を上げるか外します。project_spend_limit_exceeded:プロジェクトの設定(https://platform.openai.com/settings/)で、支出の上限を上げるか外します。organization_usage_limit_exceeded:Limits の画面から上限の引き上げを申請するか、サポートに連絡します。
ワークフローを動かし直しても戻りません。支出の上限は、直さなければ月の上限が切り替わるまで止まったままです。直したら、同じ呼び出しが 200 を返すことを確かめます。弊社の10月1日の6回目も、チャージのあと HTTP 200 が返ることを確かめて復旧としました。
手順2:見張りの確かめ先を、実際に生成する呼び出しにする
ここからは、次に切れたときに気づくための見張りです。モデル一覧ではなく、生成のAPIを最小の量で1回だけ呼びます。弊社の点検は chat.completions を gpt-4o-mini、出力1トークンで呼んでいます。30分おきに1回なら、待てば戻る種類の 429 にはまず当たりません。
手順3:本文を返り値で受け取る
returnFullResponse: true を付けると、httpRequest は本文だけでなく、状態コードとヘッダーをまとめて返します。ignoreHttpStatusErrors: true を付けると、axios の validateStatus が常に通る設定になり、429 でも例外になりません。
// n8n-core(httpRequest)
const result = await invokeAxios(axiosRequest, requestOptions.auth);
if (requestOptions.returnFullResponse) {
return {
body: result.data,
headers: result.headers,
statusCode: result.status,
statusMessage: result.statusText,
};
}
// n8n-core(convertN8nRequestToAxios)
axiosRequest.validateStatus = () => true;
// axios/dist/node/axios.cjs(settle)
if (!response.status || !validateStatus || validateStatus(response.status)) {
resolve(response);
こうすると本文は、例外ではなく普通の返り値として戻ります。例外の形を通らないので、タスクランナーを経ても本文が残ります。弊社の新しい版も、9月13日の残高切れで本文の credit_balance_exhausted から判定できました(後の「直ったかを確かめる」)。
次は、8月16日に入れた新しい版の判定(本文と状態コードで分ける、429 が2回続けば dead、warn でも状態を書き換える、送れなかった通知を次の回に送り直す)を、Codeノード1つにまとめた例です。支出の上限と利用上限の分岐は、公式のエラー一覧に合わせて足しています。スケジュールのトリガー(30分おき)の後ろに、このCodeノードを1つ置きます。
// Codeノード「OpenAIの残高の見張り」(Mode: Run Once for All Items)
// Schedule Trigger(30分おき)→ このCodeノード、の2つだけのワークフロー
const sd = $getWorkflowStaticData('global');
// 1. 生成のAPIを最小の量で1回だけ呼ぶ(/v1/models は残高が無くても 200 を返す)
let res;
try {
res = await this.helpers.httpRequest({
method: 'POST',
url: 'https://api.openai.com/v1/chat/completions',
headers: { Authorization: 'Bearer ' + $env.OPENAI_API_KEY },
body: {
model: 'gpt-4o-mini',
max_tokens: 1,
messages: [{ role: 'user', content: 'ping' }],
},
json: true,
returnFullResponse: true, // 本文・状態コード・ヘッダーを返り値で受け取る
ignoreHttpStatusErrors: true, // 429 や 401 でも例外にしない
});
} catch (e) {
// ここに来るのは通信そのものの失敗(名前解決など)だけ
res = { statusCode: 0, body: null, note: String(e && e.message) };
}
// 2. 状態コードと本文の error.code で判定する(例外の文字列は見ない)
const err = (res.body && res.body.error) || {};
const code = err.code || '';
const type = err.type || '';
sd.seen429 = res.statusCode === 429 ? (sd.seen429 || 0) + 1 : 0;
let status = 'warn';
let reason = 'HTTP ' + res.statusCode + ' ' + (code || type || res.note || '');
let howto = '';
if (res.statusCode === 200) {
status = 'ok';
} else if (code === 'credit_balance_exhausted') {
status = 'dead';
reason = 'クレジット残高切れ';
howto = 'チャージ: https://platform.openai.com/settings/organization/billing';
} else if (code === 'organization_spend_limit_exceeded' || code === 'project_spend_limit_exceeded') {
status = 'dead';
reason = '支出の上限に到達(' + code + ')';
howto = '上限: https://platform.openai.com/settings/organization/limits';
} else if (code === 'organization_usage_limit_exceeded') {
status = 'dead';
reason = '組織の利用上限に到達(残高ではなく上限の問題)';
howto = '上限: https://platform.openai.com/settings/organization/limits';
} else if (type === 'insufficient_quota') {
status = 'dead';
reason = '請求まわりで停止(' + (code || type) + ')';
} else if (res.statusCode === 401) {
status = 'dead';
reason = 'APIキーが通らない(401)';
} else if (sd.seen429 >= 2) {
status = 'dead'; // 本文が読めなくても、30分おきの1回で 429 が2回続くなら止まっている
reason = '429 が' + sd.seen429 + '回続いている';
}
// 3. warn のときも状態を書き換える(書き換えないと「復旧」が出ない)
const prev = sd.state || 'ok';
sd.state = status;
const msgs = [];
if (status === 'dead' && prev !== 'dead') {
msgs.push('🔴 OpenAI API 停止中: ' + reason +
'\nAIを使うワークフローは止まっています(実行一覧は success のままです)' +
(howto ? '\n' + howto : ''));
}
if (status === 'warn' && prev === 'ok') msgs.push('⚠️ OpenAI の確認に失敗: ' + reason);
if (status === 'ok' && prev !== 'ok') msgs.push('✅ OpenAI API 復旧');
// 4. 通知を送る。送れなかった分は握り潰さず、次の回に送り直す
const queue = (sd.pendingNotify || []).concat(msgs);
const failed = [];
for (const text of queue) {
try {
await this.helpers.httpRequest({
method: 'POST',
url: 'https://api.chatwork.com/v2/rooms/XXXX/messages',
headers: {
'X-ChatWorkToken': $env.CHATWORK_TOKEN,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'body=' + encodeURIComponent(text),
});
} catch (e) {
failed.push(text);
}
}
sd.pendingNotify = failed;
// 5. 合否は返り値に出す(このノード自体は dead でも success で終わる)
return [{ json: { status, reason, httpStatus: res.statusCode, code, type, seen429: sd.seen429, pending: failed.length } }];
状態は $getWorkflowStaticData('global') に持たせています。タスクランナーの中でも使える変数です。Codeノードから $env で鍵を読むには、本体の設定で環境変数の読み取りを許しておく必要があります。詳しくはn8nのCodeノードで「access to env vars denied」が出る原因と直し方に書いています。
手順4:429 は本文の error.code で分ける
credit_balance_exhaustedなら残高切れです。止まっている(dead)と判定し、チャージを促します。organization_spend_limit_exceededとproject_spend_limit_exceededは、月の支出の上限に達した状態です。dead にして、上限の見直しを促します。organization_usage_limit_exceededの公式の説明は「Your organization reached its OpenAI-assigned usage limit.」で、OpenAIが決めた利用上限の問題です。これも dead にします。- ここまでのコード以外でも、
typeがinsufficient_quotaなら請求まわりで止まっているので dead にします。 - 本文が読めない 429 でも、2回続いたら dead にします。30分おきに1回しか呼ばない見張りで、速さの制限に続けて当たることはまず無いからです。
手順5:warn でも状態を書き換え、送れなかった通知は次の回へ
旧版は warn のときに状態を書き換えていませんでした。そのため状態は ok のまま残り、復旧しても「復旧」の知らせが出ません。新しい版は warn でも状態を書き換えます。
旧版の通知は、Chatwork でも LINE でも、送信の失敗を catch(e){} で握り潰していました。通知が届かなくても誰にも分かりません。新しい版は、送れなかった通知を pendingNotify に残し、次の回に送り直します。
手順6:通知の文に「何が起きたか」を書く
8月15日の通知は鳴っていましたが、「プローブが3回連続で失敗」では一時的な不調にしか読めませんでした。新しい版は「OpenAI API 停止中: クレジット残高切れ」と書きます。上のコードでは、チャージの画面の場所も添えています。読んだ人が、次に何をするかで迷わない文にします。
本番へ入れるとき
見張りの書き換えも、本番のワークフローの変更です。弊社では事前点検、写しでの並走、承認を経て本番に入れる決まりにしています。APIでPUTしたあとは deactivate → activate し直さないと、動いている版が変わりません。詳しくはn8nのAPIでワークフローを更新する方法と、反映されない・止まらない原因に書いています。
直ったかを確かめる
新しい版は8月16日に入れました。最初の確かめは、実行の出力に新しい項目 seen429 が出ていて、判定が ok であることでした。これは新しいコードが動いた証拠にはなりますが、残高切れを見分けられる証拠にはなりません。
答え合わせは、本物の残高切れで済みました。9月13日の5回目です。3時から3時30分のあいだに残高が切れ、見張りの判定は次のように動きました。
| 回 | 判定 | seen429 | メモ |
|---|---|---|---|
| 3:00:50 | ok | — | 正常 |
| 3:30:50 | dead | 1 | 本文の credit_balance_exhausted で判定し、通知を出した |
| 9:30:50 | dead | 13 | 止まったまま |
最初の 429 の回で、本文から残高切れと判定できています。通知「🔴 OpenAI API 停止中: クレジット残高切れ」も社内のチャットに出ました。弊社は、見張りは実際に落ちたときに1度は答え合わせをするまで、動いているとは見なさないと決めています。8月1日に建てた旧版は、建てただけでは気づける状態になっておらず、8月15日に正しく働きませんでした。
- 見張りの実行ステータスではなく、返り値の
statusを見ます。Codeノードは最後まで走れば success になるからです。 - 通知の経路は、写しのワークフローで判定を一時的に dead に固定して流せば、本番を止めずに確かめられます。
- 本物の残高切れが起きたら、どの回で dead になったかを記録と照らし合わせます。
それでも直らないとき
鳴っているのに、誰も見ていない。9月13日は3:30:50に正しく鳴りましたが、チャージしたのは9時台で、6時間見られていませんでした。9:30:50の回でも dead のままで、seen429 は 13 まで増えていました。通知は、その時間に人が実際に見る場所へ出します。夜中に切れたときは、朝いちばんに見る場所に残るようにしておきます。
30分より短い切れは見えない。10月1日の6回目は、18:35ごろから18:49までの約15分でした。社内の作業でn8nと同じ鍵を使って画像を生成し、最後の残高を使い切っていました。見張りは18:30:50と19:00:50の回がどちらも正常で、この切れを拾えていません。弊社は、画像の生成をn8nと同じ鍵で回さないことにしました。一部の道具は、同じモデルを別のサービス経由で呼ぶ形に切り替えています。ほかの道具は、使う前に切り替えるか、残高を確かめてから使います。
根本は、残高を切らさない側にある。6回とも、根本は自動チャージを設定していなかったことでした。見張りは「切れたら気づく」までで、「切らさない」仕組みではありません。残高が減ってきた段階で先にチャージする運用か、自動チャージが要ります。
error の件数で影響を数えない。失敗を握り潰して success で終わるワークフローは、残高切れのあいだも error を出しません。9月13日も、2時30分から4時30分のあいだに error になったのは1件だけでした。影響を調べるときは、各ワークフローの出力の中身を見ます。
点検の表が「正常」を出している。弊社が配っている点検の表も、9月13日は「全16項目 正常」を出していました。会話のAIを実際に呼ぶ項目が無かったためです。同じ日に「会話AIの応答(実応答テスト)」の項目を足しました。点検の表にも、AIを実際に呼ぶ項目を1つ入れておきます。
まとめ
- 止まっているときは、本文の
error.codeを見て直す画面を選びます。残高切れならクレジットを足し、上限の 429 なら上限の画面で直します。送り直しでは戻りません。 - OpenAIの 429 は、速さの制限でも残高や上限の不足でも返ります。
credit_balance_exhaustedは待っても戻りません。 /v1/modelsは残高が無くても 200 を返します。確かめるときは、実際に生成を1回呼びます。- n8n 2.19.2 のソースで確かめた範囲では、Codeノードの
helpers.httpRequestの例外にOpenAIの本文は入りません。タスクランナーへ渡るときに axios のtoJSON()の形になり、responseが落ちるためです。returnFullResponseとignoreHttpStatusErrorsで、本文を返り値として受け取ります。 - 見張りの合否は実行の success では決まりません。本物の残高切れで1度答え合わせをし、通知は人が見る場所に出し、根本は残高を切らさない側で直します。
参考にした公式ドキュメント
n8nの点検と構築のご相談
「AIのワークフローが止まっていたのに、実行は全部 success だった」といった見張りの穴の点検や、通知まで含めた監視の仕組みづくりをお引き受けしています。






