録音ファイルの文字起こしを、音声の長さで課金される外部 API に投げています。
この種の API では、「失敗したら投げ直す」がいつも安全とは限りません。タスクの提出が受理された時点で、その音声ぶんの課金は決まります。だから、レスポンスが返ってきたあとの失敗を投げ直すと、同じ音声に二回払います。
反対に、接続そのものが確立できなかった失敗は、リクエストが 1 バイトも届いていません。投げ直しても課金は増えません。
同じ「fetch が失敗した」でも、段階によって意味がまるで違うわけです。
実際に運用していて、2026-08-27〜09-16 の 3 週間に出た失敗は 114 件、うち 51 件が TCP ハンドシェイクのタイムアウトでした。当時は何も投げ直していなかったので、それらはそのまま「音声認識に失敗しました」として利用者に出ていました。救えた失敗を、救っていなかったわけです。
この記事では、失敗を「届く前 / 届いた後 / 照会」の 3 段階に分けて、どの段階の失敗だけ投げ直してよいのかを整理します。あわせて、この分類を fetch の既定値が壊す話をします。
前提:どこで課金されるか
録音ファイルの文字起こしは、典型的には 2 つのエンドポイントでできています。
| 呼び出し | 何をするか | 課金 |
|---|---|---|
POST /submit |
認識タスクを作る。音声の URL を渡す | 音声の長さで課金される(タスクが作られた時点で決まる) |
POST /query |
タスクの状態と結果を取りに行く | 無料 |
つまり、お金が動くのは提出の 1 回だけです。ここから 2 つの帰結が出ます。
- 提出のリクエストが届いたかどうかが、そのまま「投げ直すと二重に払うかどうか」になる
- 提出さえ済んでいれば、照会は何回失敗してもよい。むしろ照会で諦めると、払ったぶんの結果を捨てることになる
「リトライは冪等な操作にだけ」という原則はよく言われます。ただ、この API には冪等キーがありません。代わりに使える判別材料が、失敗がどの段階で起きたかです。
失敗を 3 つの段階に分ける
| 段階 | 典型 | リクエストは届いたか | 課金 | 投げ直してよいか |
|---|---|---|---|---|
| ① 届く前 | DNS 解決の失敗、TCP ハンドシェイクのタイムアウト、接続拒否 | 1 バイトも届いていない | まだされていない | 投げ直してよい |
| ② 届いた後 | ステータスコードが想定外、ヘッダ欠落、接続が途中で切れた | 届いた(タスクが作られた可能性がある) | 音声の長さで確定している | 投げ直すと二重課金 |
| ③ 照会 | 2 回目以降の query が一時的なゲートウェイエラー |
届いている(タスクはすでに存在する) | 照会は無料 | 同じタスクを照会し続ける |
大事なのは、「失敗した」ではなく「届いたか」で切ることです。エラーを「種類」で分類すると、ECONNRESET のような「接続はできたが、そのあと切れた」ものを取りこぼします。
「1 バイトも届いていない」をエラーコードで判定する
Node.js の fetch(undici)は、失敗の理由を error.cause.code に載せてくれます。これを使って「確実に届いていない」ものだけを通します。
/** リクエストが「1 バイトも届いていない」と断定できるエラーコードだけを入れる */
const PRE_DELIVERY_ERROR_CODES = new Set([
'UND_ERR_CONNECT_TIMEOUT',
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EAI_AGAIN',
]);
/**
* リクエストが「確実に届いていない」かどうか。
* `ECONNRESET` や `UND_ERR_SOCKET` は「つながってから切れた」なので入れない。
*/
function preDeliveryFailureCode(error: unknown): string | undefined {
const cause = (error as { cause?: { code?: unknown } } | null)?.cause;
const code = typeof cause?.code === 'string' ? cause.code : undefined;
return code && PRE_DELIVERY_ERROR_CODES.has(code) ? code : undefined;
}
入っていないコードのほうが重要です。
-
ECONNRESET/UND_ERR_SOCKET— ソケットは確立していて、そのあと切れています。リクエストを書き終えていた可能性があるので、除外します。ECONNRESETは「相手が切った」であって「届かなかった」ではありません。 -
UND_ERR_HEADERS_TIMEOUT— リクエストは送信済みで、応答ヘッダを待って切れています。これも除外です。
つまりこの集合は「接続フェーズで失敗した」ではなく、送信を始められなかったを表しています。名前ではなく意味で選んでください。
なお、undici の connectTimeout の既定値は 10 秒です(公式ドキュメントの connectTimeout の Default は 10e3)。つまり 1 回ハンドシェイクで失敗した時点で、すでに 10 秒使っています。だから試行回数は欲張りません。
/** 試行回数は初回を含む。1 回のハンドシェイク失敗で既に 10 秒使っている */
const CONNECT_ATTEMPTS = 3;
/** 少し待ってから名前解決をやり直す。TTL は約 60 秒だが、返るノードの順番自体が回っている */
const CONNECT_RETRY_DELAY_MS = 300;
相手のドメインは CDN の GSLB 配下で、A レコードの TTL は約 60 秒、返るエッジノードは毎回変わります。だから待ち時間は TTL ぶんである必要はなく、次のハンドシェイクが別のノードに当たることだけを期待して短くします。
既定の redirect: 'follow' が、この判定を壊す
ここが本題です。
fetch の redirect の既定値は 'follow' です。このままだと、② の失敗が ① の顔をして現れます。
具体的にはこうです。1 ホップ目のサーバが POST を受け取ってタスクを作り、307 で転送先を指した。その転送先への接続に失敗した。このとき undici が返すのは cause.code = 'ECONNREFUSED' です。ECONNREFUSED は ① の集合に入っています。
つまり、「タスクは作られたのに、届いていない扱いで投げ直す」が成立してしまいます。
ローカルで確かめました。307 を返すサーバを立て、転送先を閉じたポートにします。1 ホップ目が受け取った POST の回数を数えます。
redirect |
1 ホップ目が受け取った POST | 最後に返ってきたもの |
|---|---|---|
'follow'(既定) |
3 回 |
fetch failed / cause: ECONNREFUSED
|
'error' |
1 回 |
fetch failed / cause: unexpected redirect
|
redirect: 'follow' では、サーバが POST を 3 回受け取りました。課金される API なら、タスクを 3 つ作った可能性があります。音声の長さで課金される以上、それは同じ音声に 3 回払うということです。
redirect: 'error' にすると 1 回で止まります。307 が「リダイレクトは扱わない」というエラーになり、cause にコードが付かないので ① の集合に当たりません。1 回目の POST はすでに届いているので「成功したかもしれない」まま失敗しますが、二重課金にはなりません。
だから redirect: 'error' は「念のためのおまじない」ではなく、この分類が成り立つための前提です。既定の follow を残したまま「接続エラーだけ投げ直す」を書くと、二重課金の経路を自分で作ります。
実際のベンダー API はリダイレクトを返しません。返ってきたら、それは中間層(CDN や WAF)のエラーページです。追いかけるより、失敗したほうが早いという判断でもあります。
ランタイムが変わると、コードの在り処も変わる
同じことを Bun 1.4.2 で確かめると、結果が変わります。Bun の fetch は cause を付けず、トップレベルの code に載せます。
########## BUN ##########
--- redirect=follow ---
name : TypeError
code : ConnectionRefused
cause.name: undefined / cause.code: undefined / cause.message: undefined
preDeliveryFailureCode は error.cause.code しか見ないので、Bun では**常に「投げ直さない」**に倒れます。安全側ではありますが、リトライの仕組みが黙って死にます。エラーコードで分類するコードは、動かすランタイムで一度確かめる必要があります。
照会段階:すでに払ったタスクを捨てない
照会は無料なので、ここでの失敗は「投げ直し」ではなく「同じタスクを照会し続ける」です。タスクはすでに存在し、課金も済んでいます。ここで諦めると、払ったぶんのテキストが手に入りません。
判定はこうなります。
/** クエリ段階で「同じタスクを照会し続ける」か「終端として投げる」か */
function pollVerdict(statusCode: string | undefined, httpStatus: number): string {
if (statusCode === '20000000') return '成功';
if (statusCode === '20000001' || statusCode === '20000002') return '処理中 → 待つ';
if (statusCode === '20000003') return '無音';
const transientGateway =
(!statusCode && [502, 503, 504].includes(httpStatus)) ||
statusCode === '55000000';
if (transientGateway) return '一時的なゲートウェイ障害 → 同じタスクを照会し続ける';
return '終端(投げる)';
}
55000000 はプロバイダ側の内部エラー(kiteX の THRIFT_EGRESS など)です。502 / 503 / 504 で X-Api-Status-Code ヘッダが欠けているのも同じ扱いにします。ヘッダが無い=プロバイダの API ではなく中間層が返した応答で、タスクの状態とは無関係です。
一方 4500xxxx は業務的な拒否(音声 URI が不正など)なので、照会を続けても変わらず、終端です。
照会の予算は「払い損」から逆算する
もともと照会は「1 秒 × 30 回 = 30 秒」で打ち切っていました。短い回答には足りますが、数分の回答には足りません。タスクは作られているので、音声の長さぶんの代金は確定しています。テキストだけが手に入らない——払い損です。
しかも、落ちていたのは長い回答ばかりでした。2026-08-27〜09-02 の 6 日間だけで、クライアントから報告された該当は 31 セグメント・合計 3.3 時間ぶんの音声に達しています。長い回答ほど、あとから見返す価値が高いのに、そこが落ちていました。
そこで予算を 5 分に伸ばし、待ち方を 2 段にしました。最初の 30 秒は 1 秒間隔、その後は 2 秒間隔です。ほとんどのタスクは 30 秒以内に終わるので、長いものだけ間隔を広げます。
/** 認識結果を待つ総予算 */
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
/** ほとんどのタスクは 30 秒以内に終わる。そこから先は間隔を広げる */
const POLL_FAST_PHASE_MS = 30 * 1000;
const POLL_INTERVAL_MS = 1000;
const POLL_SLOW_INTERVAL_MS = 2000;
照会の呼び出し自体にはお金がかからないので、照会のハンドシェイクを投げ直すのはタダです。逆に、無料の呼び出しをケチって有料の結果を捨てるのは、いちばん割の悪いケチり方でした。
失敗の「中身」を残す:本文も上限つきで読む
失敗を分類するには、失敗の情報が要ります。ところが当時、ログに出ていたのはこれだけでした。
タスク投入に失敗: null タスク投入に失敗
3 週間の 114 件のうち 23 件は、X-Api-Status-Code も X-Api-Message も null でした。どちらも中間層がエラーページを返したときです。HTTP ステータスすら出していなかったので、プロバイダの API が拒否したのか、CDN が落としたのかが分かりませんでした。
そこで、ヘッダが無いときは本文の先頭だけを読んでログに載せるようにしました。
/** エラーを 1 行にするために読むだけ。読む量と時間の両方に上限を置く */
const BODY_SNIPPET_MAX_BYTES = 2048;
const BODY_SNIPPET_TIMEOUT_MS = 2000;
const BODY_SNIPPET_MAX_CHARS = 200;
response.text() を使ってはいけません。応答が返ってこない相手というのは、たいてい挙動がおかしい中間層で、本文が巨大かもしれないし、少しずつ吐き続けるかもしれません。読み切るまで返らない text() に任せると、すぐ失敗すべきリクエストがそこで引きずられます。読む量と時間の両方に上限を置き、超えたら残りは捨てます。
ついでに、例外を投げ直すときは cause を落とさないことも同じ話です。API 層で throw new TRPCError({ message: '音声認識に失敗しました' }) のように原因を落とすと、エラー集計にはその一言しか届きません。実際、集計の上位 50 件を中身で分けると 32 件は STT の失敗で、別の失敗とひとつの塊にまとめられていました。
実測:どの失敗が多かったか
3 週間の 114 件の内訳です。
| 種類 | 件数 |
|---|---|
| TCP ハンドシェイクのタイムアウト(提出 46 / 照会 5) | 51 |
X-Api-Status-Code ヘッダが無い |
23 |
| 照会の予算切れ | 25 |
| プロバイダのエラーコード | 15 |
いちばん多いのが ① の「届く前」でした。分類を入れる前は、ここが全件そのまま失敗していました。
残りは「自分の経路が原因ではないか」と考えたくなりますが、そうではありませんでした。当時登録されていた 8 つのエッジノードに curl --resolve で 3 周ぶん当てたところ、すべて 19ms でハンドシェイクできました。同時期にこの形のタイムアウトが出ていたのは、同じ CDN 網を使うそのプロバイダのドメインだけです。
名前解決のたびに違うノードが返る構成では、手元の経路が原因なら全ノードで失敗するはずです。一部のノードだけが落ちるなら、疑うべきはこちらのネットワークではなく、名前解決とノードの当たり外れです。ここを取り違えると、関係のないプロキシ設定をいじる方向に時間を使います。
付録:検証に使ったコード全体
本文の抜粋は、この 1 ファイルからの引用です。依存ゼロなので、そのまま動きます。
/**
* 課金される外部 API のリトライ境界を確かめる最小デモ。依存ゼロ。
*
* node retry-boundary.ts (Node 22.18 以降。それ未満は --experimental-strip-types を付ける)
* bun retry-boundary.ts
*/
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
/** リクエストが「1 バイトも届いていない」と断定できるエラーコードだけを入れる */
const PRE_DELIVERY_ERROR_CODES = new Set([
'UND_ERR_CONNECT_TIMEOUT',
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EAI_AGAIN',
]);
/**
* リクエストが「確実に届いていない」かどうか。
* `ECONNRESET` や `UND_ERR_SOCKET` は「つながってから切れた」なので入れない。
*/
function preDeliveryFailureCode(error: unknown): string | undefined {
const cause = (error as { cause?: { code?: unknown } } | null)?.cause;
const code = typeof cause?.code === 'string' ? cause.code : undefined;
return code && PRE_DELIVERY_ERROR_CODES.has(code) ? code : undefined;
}
type FetchLike = typeof fetch;
/** 本番と同じ形。`redirect: 'error'` が「投げ直してよい」の前提になっている */
async function fetchWithConnectRetry(
url: string,
init: RequestInit,
label: string,
attempts = 3,
delayMs = 300,
redirect: RequestRedirect = 'error',
fetchImpl: FetchLike = fetch,
): Promise<Response> {
for (let attempt = 1; ; attempt++) {
try {
return await fetchImpl(url, { ...init, redirect });
} catch (error) {
const code = preDeliveryFailureCode(error);
if (!code || attempt >= attempts) throw error;
console.log(` ${label}${attempt} 回目は ${code} → 投げ直す`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
/** 空いているポートを 1 つ確保して、すぐ手放す(=閉じたポートを作る) */
function reserveClosedPort(): Promise<number> {
return new Promise((resolve) => {
const probe = createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address() as AddressInfo;
probe.close(() => resolve(port));
});
});
}
/**
* 1 ホップ目は POST を受け取り 307 を返す。転送先は閉じたポート。
* 「1 ホップ目は確かに届いたのに、2 ホップ目で接続エラーになる」状況を作る。
*/
async function countDeliveredPosts(redirect: RequestRedirect): Promise<{
hits: number;
failure: string;
}> {
const closedPort = await reserveClosedPort();
let hits = 0;
const server = createServer((_req, res) => {
hits++;
res.writeHead(307, { location: `http://127.0.0.1:${closedPort}/submit` });
res.end();
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', () => resolve()),
);
const { port } = server.address() as AddressInfo;
let failure = '(失敗しなかった)';
try {
const response = await fetchWithConnectRetry(
`http://127.0.0.1:${port}/submit`,
{ method: 'POST' },
`redirect=${redirect}: `,
3,
50,
redirect,
);
failure = `HTTP ${response.status}`;
} catch (error) {
const cause = (error as { cause?: { code?: unknown; message?: unknown } })
?.cause;
const code = typeof cause?.code === 'string' ? cause.code : undefined;
const message = typeof cause?.message === 'string' ? cause.message : '';
failure = `${(error as Error).message} / cause: ${code ?? message}`;
}
await new Promise<void>((resolve) => server.close(() => resolve()));
return { hits, failure };
}
/** クエリ段階で「同じタスクを照会し続ける」か「終端として投げる」か */
function pollVerdict(statusCode: string | undefined, httpStatus: number): string {
if (statusCode === '20000000') return '成功';
if (statusCode === '20000001' || statusCode === '20000002') return '処理中 → 待つ';
if (statusCode === '20000003') return '無音';
const transientGateway =
(!statusCode && [502, 503, 504].includes(httpStatus)) ||
statusCode === '55000000';
if (transientGateway) return '一時的なゲートウェイ障害 → 同じタスクを照会し続ける';
return '終端(投げる)';
}
async function main(): Promise<void> {
console.log('=== 1. 「まだ届いていない」と断定してよいエラーコードか ===');
const cases = [
'UND_ERR_CONNECT_TIMEOUT',
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EAI_AGAIN',
'ECONNRESET',
'UND_ERR_SOCKET',
'UND_ERR_HEADERS_TIMEOUT',
];
for (const code of cases) {
const error = Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(code), { code }),
});
const verdict = preDeliveryFailureCode(error)
? '投げ直す'
: '投げ直さない';
console.log(` ${code.padEnd(24)} ${verdict}`);
}
console.log(
` ${'(cause に code が無い)'.padEnd(24)}${
preDeliveryFailureCode(new TypeError('fetch failed'))
? '投げ直す'
: '投げ直さない'
}`,
);
console.log('\n=== 2. redirect を既定(follow)のまま投げ直すと ===');
for (const redirect of ['follow', 'error'] as RequestRedirect[]) {
const { hits, failure } = await countDeliveredPosts(redirect);
console.log(
` redirect=${redirect.padEnd(6)} → サーバが受け取った POST は ${hits} 回` +
` / 最終的な失敗: ${failure}`,
);
}
console.log('\n=== 3. クエリ段階で、同じタスクを照会し続けるか ===');
const polls: Array<[string | undefined, number]> = [
['20000000', 200],
['20000001', 200],
['20000003', 200],
['55000000', 200],
[undefined, 502],
[undefined, 503],
['45000006', 200],
['40000001', 200],
];
for (const [statusCode, httpStatus] of polls) {
const label = statusCode ?? `(X-Api-Status-Code なし, HTTP ${httpStatus})`;
console.log(` ${label.padEnd(36)} ${pollVerdict(statusCode, httpStatus)}`);
}
}
void main();
実行結果です(Node.js v24.21.0)。
=== 1. 「まだ届いていない」と断定してよいエラーコードか ===
UND_ERR_CONNECT_TIMEOUT 投げ直す
ECONNREFUSED 投げ直す
EHOSTUNREACH 投げ直す
ENETUNREACH 投げ直す
ENOTFOUND 投げ直す
EAI_AGAIN 投げ直す
ECONNRESET 投げ直さない
UND_ERR_SOCKET 投げ直さない
UND_ERR_HEADERS_TIMEOUT 投げ直さない
(cause に code が無い) 投げ直さない
=== 2. redirect を既定(follow)のまま投げ直すと ===
redirect=follow: 1 回目は ECONNREFUSED → 投げ直す
redirect=follow: 2 回目は ECONNREFUSED → 投げ直す
redirect=follow → サーバが受け取った POST は 3 回 / 最終的な失敗: fetch failed / cause: ECONNREFUSED
redirect=error → サーバが受け取った POST は 1 回 / 最終的な失敗: fetch failed / cause: unexpected redirect
=== 3. クエリ段階で、同じタスクを照会し続けるか ===
20000000 成功
20000001 処理中 → 待つ
20000003 無音
55000000 一時的なゲートウェイ障害 → 同じタスクを照会し続ける
(X-Api-Status-Code なし, HTTP 502) 一時的なゲートウェイ障害 → 同じタスクを照会し続ける
(X-Api-Status-Code なし, HTTP 503) 一時的なゲートウェイ障害 → 同じタスクを照会し続ける
45000006 終端(投げる)
40000001 終端(投げる)
まとめ
- 課金される API では、**「失敗した」ではなく「届いたか」**でリトライの可否を決めます。提出で課金、照会は無料、という構造がそのまま判断材料になります。
- 「確実に届いていない」と言えるのは DNS / 接続フェーズの失敗だけです。
ECONNRESETやUND_ERR_SOCKETは「つながってから切れた」なので入れません。 -
fetchのredirectの既定値は'follow'で、② の失敗が ① の顔をして現れます。実測で 1 ホップ目のサーバは POST を 3 回受け取りました。redirect: 'error'は分類の前提です。 - エラーコードの在り処はランタイムで変わります。Bun では
causeが付かず、同じコードが黙って「常に投げ直さない」に倒れました。動かすランタイムで確かめてください。 - すでに払ったタスクは、照会の失敗で捨ててはいけません。照会は無料なので、続けるほうが常に得です。
- 失敗を分類するには失敗の情報が要ります。ヘッダが無いときは本文を上限つきで読み、投げ直すときは
causeを落とさない。
「リトライしてよいか」は、リクエストがどこまで進んだかで決まります。 エラーの名前ではなく、相手に何が届いたかを見てください。
参考
-
Client | Node.js Undici —
connectTimeoutの既定値10e3 -
Request.redirect - Web API | MDN —
redirectの 3 つの値と既定値
この音声経路は、筆者が開発に関わっているリアルタイム面接 AI(AceRound)で運用しているものです。録音の文字起こしは面接の振り返りに使うので、二重課金も「払ったのに結果が無い」も、どちらもそのまま困ります。
