claude -p --output-format stream-json を定期タスクで動かしています。2026-11-01 の朝から4回続けて、次の結果行が返りました。必要な欄だけ抜き出します。
{"type":"result","subtype":"success","is_error":true,"terminal_reason":"api_error","num_turns":1,"total_cost_usd":0,"result":"Failed to authenticate: OAuth session expired and could not be refreshed"}
subtype は success、is_error は true です。この2つは同じことを言っていません。subtype だけを見る判定は、この回を成功と読みます。
私の記録と、認証が失敗する前提の形での再現を数えました。
1. 自分の記録 233回
朝・昼・夜などの名前が付いた実行記録のうち、2026-11-02 の昼までのものを全部読みました。result 行があったのは 233 回です。記録ファイルは同じ時間帯の起動で上書きするなどの事情があるので、起動した回そのものの数ではありません(別に持っている起動の記録は同じ期間に 254 回)。ファイルの中に result 行が複数あるものは 24 個あり、ここでは最後の1行を数えました。
| subtype | is_error | 回数 |
|---|---|---|
success |
true |
13 |
success |
false |
220 |
**subtype が success 以外の回は、この記録には1回もありません。**この値は、この記録の中では何も区別していませんでした。
失敗の 13 回の内訳です。
| 記録された error | 回数 | result の文 |
|---|---|---|
billing_error |
7 | Credit balance is too low |
authentication_failed |
5 | Failed to authenticate: OAuth session expired and could not be refreshed |
rate_limit |
1 | You've hit your session limit(以下略) |
13 回とも、次の3つがそろっていました。
- プロセスの終了コードは 1
- ツールの実行は 0 回
-
total_cost_usdは 0
所要は duration_ms で 143〜982 ミリ秒です。
is_error が false の 220 回では、ツールの実行は少ない回でも 10 回でした。終了コードを実行記録と結び付けられた 217 回は、すべて 0 です。残る 3 回は同じ日・同じ時間帯の起動の記録が複数あり、結び付けていません。
ほかに result 行そのものが無い実行記録が 16 個あります。13 個は result 行の前で終わっていて、3 個は 0 バイトです。判定を result 行だけに頼ると、この形を読めません。
2. 標準エラーに失敗の文は出ていなかった
OAuth の4回があった日の標準エラーの記録は、0 バイトと 0 バイトでした。理由の文は標準出力にしかありません。
残高不足の 2026-08-11 の標準エラーの記録には 576 バイトあり、中身は同じ1行の3回分です。
⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and takes precedence over your claude.ai login · Unset it to load your organization's connectors
Credit の語は入っていません。ただし、API 鍵かそれに類する認証が優先されている、という文はここに出ていて、私はこの行を当時読めていませんでした。
失敗の文を標準エラーのログで探す作りだと、この形では見つかりません。
3. 認証が失敗する形で再現する
OAuth の期限切れそのものを起こす方法は試していません。そこで別の認証失敗を2通り作りました。どちらも空の設定フォルダで動かします。
- 偽の API 鍵を環境変数で渡す
- 鍵を渡さない(空の設定フォルダなので、私の環境では未ログインになった)
Claude Code 2.1.316・Node v24.18.1・Windows 11 で、各形式1回ずつ、合わせて2回流しました。2回の出力は1バイトも同じです。Claude Code の中から起動して測っており、直接起動は測っていません。
| 作った失敗 | 形式 | 終了コード | 文は標準出力に | 文は標準エラーに | 標準エラー(バイト) | subtype | is_error |
|---|---|---|---|---|---|---|---|
| 偽の API 鍵 | text | 1 | ある | ない | 0 | (欄なし) | (欄なし) |
| 偽の API 鍵 | json | 1 | ある | ない | 0 | success |
true |
| 偽の API 鍵 | stream-json | 1 | ある | ない | 0 | success |
true |
| 未ログイン | text | 1 | ある | ない | 0 | (欄なし) | (欄なし) |
| 未ログイン | json | 1 | ある | ない | 0 | success |
true |
| 未ログイン | stream-json | 1 | ある | ない | 0 | success |
true |
3つのことが読めます。
-
jsonとstream-jsonは、どちらもsubtypeがsuccess、is_errorがtrueでした -
textには欄がありません。失敗の文は正常な応答と同じ標準出力へ出ます。終了コードを見ないと、その文を応答として受け取ります - 標準エラーは6通りとも 0 バイトでした。空の設定フォルダでの値です。本物の設定フォルダのまま偽の鍵を渡した前の測定では 192 バイトありました(
Invalid API keyは入っておらず、中身は保存していません)
4. 判定は終了コードと is_error の両方で書く
再現スクリプトの中の判定です。
function judgeRun(exit, result) {
if (!result) return "結果行なし";
if (exit !== 0 || result.is_error !== false) return "失敗";
return "成功";
}
上の4通りの json と stream-json は、これで全部『失敗』になります。is_error が無い入力も成功にしません。
私の記録では、終了コードと結び付けた 230 回で両者が食い違った回は 0 回です。**片方だけでは困る実例を見たわけではありません。**両方を見るのは、確かめていない組み合わせを成功にしないためです。
5. 私の場合、何が足りなかったか
私の実行記録は終了コードを見ていたので、4回とも failed と記録できていました。誤って成功と読んだわけではありません。ただし読みやすい形のログの終了行は、4回とも [終了] subtype=success と出していました。
足りなかったのは次の2つです。
- 稼働監視の通知は、2回目の失敗のあとに出ました。最初の失敗は 7:05 で、通知は同じ日の 14:07 です
- 通知の本文は『無人運転が2回連続で失敗しています』までで、**認証という語がありません。**理由は通知には載らず、ログの中にしかありませんでした
結果として、記事を出すはずだった回を含む4回が何もせずに終わりました。その後の 11/2 昼の回で認証は通りましたが、なぜ戻ったのかは確かめていません。
通知に理由の文を載せる直しは、まだしていません。
測っていないこと
-
subtypeがsuccess以外になる条件は測っていません。私の起動のしかたでは一度も出ていません。ターン数の上限などを付けた起動は試していません - これがすべての版で同じかは分かりません。記録の失敗は版 2.1.225・2.1.247・2.1.299・2.1.316 のもので、再現で動かしたのは 2.1.316 だけです
- 再現で作ったのは偽の鍵と未ログインで、OAuth の期限切れそのものではありません
-
is_errorがfalseの回が、頼んだ仕事を正しく終えたかは見ていません。これは『認証などで始められなかった回を見分ける』話までです - 他の環境では測っていません。1台だけです
-
result行が無い 16 個が、なぜそうなったかは調べていません -
result行が複数あるファイルがなぜできるかは調べていません - 再現で渡さないようにした環境変数は私が挙げたものだけです。組織の管理設定など、これ以外の手段がある環境は測っていません
- 再現スクリプトの
timeoutが子の子まで止めるかは確かめていません。止まらなければ一時フォルダが残ることがあります
再現スクリプト
依存はありません。claude が PATH にあれば node repro-headless-auth-fail.mjs で走ります。一時フォルダを作って最後に消します。空の設定フォルダを使います。私の環境では、2回流した前後で ~/.claude/projects の直下のフォルダ数は同じでした。偽の鍵の回は認証の要求を送ります。認証が失敗する前提の形ですが、**認証が通る手段が残っている環境では実際の要求になり、利用の上限や料金を消費しえます。**想定した認証失敗の形でない回が出た時点で終了コード1で止まるので、そこまでの回は実行されます(全部で最大6回)。
// claude -p が認証で失敗したとき、3つの出力形式で何が返るかを数える。依存0。
// 認証が失敗する前提の形だけを使う: (a) 偽の API 鍵 (b) 鍵なし(未ログイン)。どちらも空の設定フォルダで動かす。
// 本物の設定フォルダを使わせない。作業は一時フォルダの中で、最後に消す。
// 想定した認証失敗の形(終了コード1と失敗の文)でない回が出たら、実際の要求になった可能性があるので止めて終了コード1で終わる。
// 本文は保存せず、失敗の文が標準出力と標準エラーのどちらにあったかだけを出す。
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// 終了コードと result 行から、その回を判定する
function judgeRun(exit, result) {
if (!result) return "結果行なし";
if (exit !== 0 || result.is_error !== false) return "失敗";
return "成功";
}
function resultOf(stdout) {
for (const line of stdout.split(/\r?\n/).reverse()) {
try {
const o = JSON.parse(line);
if (o.type === "result") return o;
} catch { /* JSON でない行 */ }
}
return null;
}
const MESSAGES = { fakeKey: "Invalid API key", notLoggedIn: "Not logged in" };
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "headless-auth-"));
const empty = path.join(tmp, "empty-config");
fs.mkdirSync(empty);
const cases = {
fakeKey: { ANTHROPIC_API_KEY: "sk-ant-api03-not-a-real-key" },
notLoggedIn: {},
};
// 次の一覧だけを子へ渡さない(これで全部とは限らない)
const AUTH_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
"ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "HTTPS_PROXY", "HTTP_PROXY", "AWS_BEARER_TOKEN_BEDROCK",
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "CLAUDECODE"];
const rows = [];
let version = null;
try {
const v = spawnSync("claude --version", { encoding: "utf8", shell: true });
if (v.status !== 0) throw new Error("claude が起動できません");
version = v.stdout.trim();
for (const [name, env] of Object.entries(cases)) {
for (const format of ["text", "json", "stream-json"]) {
const verbose = format === "stream-json" ? " --verbose" : "";
const childEnv = { ...process.env };
for (const k of AUTH_ENV) delete childEnv[k];
Object.assign(childEnv, env, { CLAUDE_CONFIG_DIR: empty });
const r = spawnSync(`claude -p --output-format ${format}${verbose}`, {
cwd: tmp, env: childEnv, input: "say ok", encoding: "utf8", shell: true, timeout: 60000,
});
const res = resultOf(r.stdout ?? "");
rows.push({
case: name, format, exit: r.status,
messageInStdout: (r.stdout ?? "").includes(MESSAGES[name]),
messageInStderr: (r.stderr ?? "").includes(MESSAGES[name]),
stderrBytes: Buffer.byteLength(r.stderr ?? ""),
subtype: res?.subtype ?? null, isError: res?.is_error ?? null,
terminalReason: res?.terminal_reason ?? null,
judged: format === "text" ? null : judgeRun(r.status, res),
});
if (r.status !== 1 || !(r.stdout ?? "").includes(MESSAGES[name])) throw new Error("想定した認証失敗ではありません。止めます");
}
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
console.log(JSON.stringify({ node: process.version, claude: version, rows }, null, 1));
この実行を定期タスクで動かす構成の落とし穴を、読むだけで調べる道具を無料で置いています: https://h26005.tailf680df.ts.net:10000/free.html