APIUserAbortError extends APIError を知らずに CI を 1 件落とした話
TL;DR
- OpenAI SDK の
APIUserAbortErrorはAPIErrorの サブクラス -
if (err instanceof APIError) {} else if (err instanceof APIUserAbortError) {}の順で書くと、abort 系のエラーが APIError 側に先に捕まり、後続の分岐に到達しない -
instanceofチェックは「サブクラス → 親クラス」の順に書く。当たり前なんだが、外部 SDK の継承構造を知らないと踏む
ローカルで全部 pass、push、CI で 1 件落ちた。「TIMEOUT 期待で TRANSIENT が来た」。これだけ見て原因が思いつくなら読まなくていい。
何を作っていたか
AI でゲームを生成するサービスをやっていて、その中の「prompt-rewriter」というコンポーネントを書いていた。ユーザーが書いた自然言語のゲーム指示 (例: 「もっと難しくして、敵増やして」) を、Sonnet 4.6 で provider 別に最適化された structured spec に書き換える内部処理。Anthropic 向けには XML + Chain-of-Thought、OpenAI 向けには outcome-oriented な短文、と 1 回の呼び出しで 2 variants を同時生成する。
外向きに表示しないバックエンド処理なので、堅牢性が全て。per-call timeout 5s を設定しておいて、Sonnet が遅延したり落ちたりしたときは raw prompt にフォールバック する設計にした。
エラー分類は 4 段階:
- parent abort signal (ユーザーがキャンセル) → throw
- per-call 5s timeout → raw fallback (warning:
rewrite-timeout) - 5xx / 429 (transient) → raw fallback (warning:
rewrite-transient) - 401 / 403 / その他 4xx → throw
ここまでは普通。テストも書いた。
何が起きたか
ローカルで pnpm typecheck lint format build 全部 pass。CI に push。
戻ってきた 1 件の fail がこれ。
FAIL src/core/llm/prompt-rewriter.test.ts >
rewritePrompt — failure classification >
per-call timeout (parent abort なし、AbortError) → raw fallback (transient)
AssertionError: expected 'rewrite-transient' to be 'rewrite-timeout'
Expected: "rewrite-timeout"
Received: "rewrite-transient"
「per-call timeout」のテストで、warning が rewrite-timeout ではなく rewrite-transient になっている。TIMEOUT 分岐に到達していない。
該当テストはこう書いていた。
it("per-call timeout (parent abort なし、AbortError) → raw fallback (transient)", async () => {
// parent signal は aborted ではない、独立の AbortError を APIUserAbortError として throw
mockCreate.mockRejectedValueOnce(new APIUserAbortError());
const { repo, inserted } = makeFakeRepo();
const result = await rewritePrompt(
{ rawPrompt: "x", tier: "pro" },
{ apiKey: "k", repo },
);
expect(result.rewritten).toBe(false);
expect(result.warning).toBe(REWRITE_WARNING.TIMEOUT); // ← ここで fail
expect(inserted[0]?.status).toBe("timeout");
});
APIUserAbortError を投げているのに、TIMEOUT じゃなくて TRANSIENT に来る。なんで?
原因: SDK の継承構造を見ていなかった
catch ブロックはこう書いていた。APIError を先にチェックしていた。
} catch (err) {
// parent abort は最優先で throw
if (options.signal?.aborted) {
throw err instanceof Error ? err : new Error(String(err));
}
// 4xx / 401 / 403 → throw / 5xx / 429 → raw fallback (TRANSIENT)
if (err instanceof APIError) { // ← ここで先に捕まっている
const status = err.status ?? 0;
if (status === 401 || status === 403) throw err;
if (status >= 400 && status < 500 && status !== 408 && status !== 429) throw err;
// 5xx / 408 / 429 → raw fallback
return rawFallback(rawPrompt, REWRITE_WARNING.TRANSIENT);
}
// per-call 5s timeout → raw fallback (TIMEOUT)
if (err instanceof APIUserAbortError || ...) { // ← ここに到達しない
return rawFallback(rawPrompt, REWRITE_WARNING.TIMEOUT);
}
...
}
「APIError を最初に書いて、abort はその後」。ぱっと見、自然な順序に見える。
罠はここ。OpenAI SDK の error class の継承構造はこうなっている。
| Error class | 継承元 | いつ起きる |
|---|---|---|
APIError |
Error |
base class |
APIUserAbortError |
APIError |
request abort / timeout |
APIConnectionError |
APIError |
network |
APIConnectionTimeoutError |
APIConnectionError |
network timeout |
RateLimitError |
APIError |
429 |
AuthenticationError |
APIError |
401 |
BadRequestError |
APIError |
400 |
全部 APIError のサブクラス。APIUserAbortError も例外じゃない。
つまり if (err instanceof APIError) は abort error も network error も rate limit error も全部捕まえる。先に書いた分岐が後続を全部食う。
status プロパティで分岐していたから、abort error (status undefined → 0) は「5xx でも 429 でもない」 → 「その他 4xx」判定にも引っかからず → 最後の return rawFallback(..., TRANSIENT) に流れていた。
const status = err.status ?? 0;
if (status === 401 || status === 403) throw err;
if (status >= 400 && status < 500 && status !== 408 && status !== 429) throw err;
// status === 0 はここを通過
// 5xx / 408 / 429 → raw fallback (TRANSIENT) ← abort も巻き込まれる
return rawFallback(rawPrompt, REWRITE_WARNING.TRANSIENT);
status が 0 のままでも TRANSIENT 判定。つまり API call が一度も走ってない abort 状態が「サーバーが 5xx 返した」と同じ扱いになっていた。やばい。
ローカルの typecheck / lint は通る。型は正しい。意味だけ違う。CI test までやらないと永遠に気づかない種類のバグ。
修正
APIUserAbortError チェックを APIError の 前 に置く。これだけ。
} catch (err) {
const latencyMs = Date.now() - start;
// parent abort は最優先で throw (per-call timeout / 4xx と区別)
if (options.signal?.aborted) {
throw err instanceof Error ? err : new Error(String(err));
}
// per-call 5s timeout (parent abort は上で除外済) → raw fallback
// 注: OpenAI SDK では `APIUserAbortError extends APIError` のため、
// この判定を APIError ブランチより **先に** 行わないと TIMEOUT 分岐に
// 到達せず TRANSIENT に誤判定する (CI fail で発覚、修正)。
if (
err instanceof APIUserAbortError ||
(err instanceof Error && err.name === "AbortError")
) {
return rawFallback(rawPrompt, REWRITE_WARNING.TIMEOUT);
}
// 4xx / 401 / 403 → throw / 5xx / 429 → raw fallback
if (err instanceof APIError) {
const status = err.status ?? 0;
if (status === 401 || status === 403) throw err;
if (status >= 400 && status < 500 && status !== 408 && status !== 429) throw err;
return rawFallback(rawPrompt, REWRITE_WARNING.TRANSIENT);
}
// network / 未知 → raw fallback (TRANSIENT)
return rawFallback(rawPrompt, REWRITE_WARNING.TRANSIENT);
}
順序を 「parent abort signal → APIUserAbortError → APIError → 未知 Error」 の 4 段階に統一。継承の深い順から書く。
CI が緑になった。1 行も削らず、2 ブロックを入れ替えただけ。
教訓
instanceof チェックは サブクラス → 親クラス の順。これが基本。
JavaScript / TypeScript で書いていると、自分が定義した class なら継承構造を覚えているから順序を間違えない。問題は 外部 SDK の class。OpenAI SDK の APIUserAbortError を見ても、名前から「abort 用の専用 class」だと思い込む。「APIError のサブクラスである」という事実は、SDK の source を覗かないとわからない。
防御策をいくつか。
1. SDK の error class 構造を表にして仕様書に貼る
私は ARCHITECTURE.md に、上の表をそのまま転記した。次に書く人 (未来の自分) は、catch ブロックを書く前にその表を見れば順序を間違えない。
2. テストで実 SDK の error class を投げる
mock で素の Error を投げると、継承関係の差分が出ない。テストは必ず実 SDK の error をそのまま使う。
// ❌ 継承構造を再現できないので罠を発見できない
mockCreate.mockRejectedValueOnce(new Error("aborted"));
// ✅ 実 SDK の class、`instanceof` 判定が正しく動く
mockCreate.mockRejectedValueOnce(new APIUserAbortError());
私の場合これは合っていた。が、catch 順序が間違っていたから fail した。順序の方を直せばよかった、というオチ。
3. ローカル typecheck だけで安心しない
「pnpm typecheck lint format build 全部 green、push」と書いた瞬間に少し安心していた。型は通る。lint も通る。build も通る。でも CI test まで走らせないと 意味の正しさ は検証されない。
これは Cloudflare Workers の sandbox 制約で pnpm test がローカルで走らないプロジェクトだったので、CI 任せで運用していた。普段ならローカルで test 流して気づけたはず。「ローカルで全部 pass」の射程をちゃんと意識する。
余談: 他の SDK でも同じ罠
OpenAI SDK 以外でも、HTTP client / SDK の error 階層は深い継承を持つことが多い。
-
Anthropic SDK:
APIError配下にBadRequestErrorAuthenticationErrorPermissionDeniedErrorなど、OpenAI と似た構造 -
AWS SDK v3:
ServiceException配下に各種 service error -
Stripe SDK:
StripeError配下にStripeCardErrorStripeRateLimitErrorなど
いずれも instanceof チェック順で同じ罠を踏む。SDK を採用したら error class の継承図を最初に確認する のが結局一番早い。
参考
- OpenAI SDK の error class: openai/openai-node
- TypeScript narrowing と
instanceof: TypeScript Handbook - Narrowing - 私の実装した prompt-rewriter (Enova):
src/core/llm/prompt-rewriter.tsの catch 順序が今回の核 (修正後 PR-15 で確定)
ローカルで全部 pass、push、CI で落ちる。30 分くらい原因分からなかった。「型は正しい、なのに動きが違う」って一番気持ち悪い種類のバグ。
APIUserAbortError extends APIError、たった 1 行の継承宣言。これを見落としただけで、abort error が timeout error になり、サーバーエラーと同じ raw fallback に流される。
書いてみると当たり前すぎる教訓だが、当たり前を踏むのが一番悔しい。
Discussion