技術検証 2026年9月5日

「送信に失敗しました」と出たとき、本当に届いていないのか。フォームの再送が二重登録になる条件を実測した話

XECIN アーキテクチャ障害対応現場

きっかけは、問い合わせの取りこぼしがないかを確認していたときでした。

自社サイトのフォームから送られてきた内容と、広告経由のLPから来た内容を並べて眺めていたのですが、途中で送信処理のコードのほうが気になってしまい、そちらを読み始めてしまいました。エッジケースの話が好きなので、こういう脱線はよくやります。

手が止まったのは、失敗時の文言のところでした。

「送信に失敗しました。しばらく時間をおいて再度お試しください。」

この文章は、二つのことを利用者に伝えています。ひとつは「届いていない」ということ。もうひとつは「もう一度押してください」ということ。前者を根拠にして後者をお願いしているわけです。ところが、コードを追ってみると、前者を判断できる材料がブラウザ側に無いケースがありました。

届いていないから再送してください、と言っている。でも届いていないかどうかは分かっていない。

まず、同じ形のコードが何か所あるかを数えた

私はこういうとき、直す前に数えることにしています。1か所の話なのか、4か所の話なのかで、打ち手がまったく変わるからです。

# 送信の呼び出し箇所
grep -rn "await fetch(contactEndpoint" src --include=*.astro | wc -l
grep -rln "await fetch(contactEndpoint" src --include=*.astro

# 中断・再送に関わる仕掛けが入っているか
grep -rc "AbortController\|signal:" src --include=*.astro | grep -v ":0"
grep -rn "Retry-After\|retry-after" src | wc -l
grep -rni "idempotenc" src | wc -l

# 実行結果:
# 4
# src/components/sections/ContactForm.astro
# src/pages/lp/ai-web/index.astro
# src/pages/lp/recovery/index.astro
# src/pages/lp/subsidy-renewal/index.astro
# (AbortController/signal は一致ゼロ、grep は何も出力せず)
# 0
# 0

送信の口は4つ。コーポレートサイトの問い合わせフォームがひとつと、広告用のLPが3本です。そして、送信を途中で打ち切る仕組みも、サーバから返ってくる待機指示を読む処理も、再送を安全にするための仕組みも、4か所すべてでゼロでした。

エラー分岐のほうは、逆にきれいに揃っていました。VALIDATION_ERROR の分岐が4件、RECAPTCHA_FAILED の分岐が4件、ステータス429の分岐が4件。1か所につき1つずつ、同じ判定が並んでいます。コピー元がひとつあって、そこから3回複製された形ですね。

実際の送信部分はこうなっています。

// src/components/sections/ContactForm.astro (抜粋・4か所ともほぼ同型)
try {
  const response = await fetch(contactEndpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify(payload),
  });

  const data = await response.json().catch(() => ({}));

  if (!response.ok) {
    if (data.code === 'VALIDATION_ERROR' && data.errors) {
      // 入力内容の指摘を表示
    } else if (data.code === 'RECAPTCHA_FAILED') {
      // スパム判定の案内を表示
    } else if (response.status === 429) {
      // 混雑の案内を表示
    } else {
      this.errors.submit = '送信に失敗しました。しばらく時間をおいて再度お試しください。';
    }
    this.isSubmitting = false;   // ボタンが再び押せるようになる
    return;
  }

  this.isSubmitted = true;
} catch (err) {
  this.errors.submit = '送信に失敗しました。しばらく時間をおいて再度お試しください。';
  this.isSubmitting = false;     // ここでもボタンが再び押せるようになる
}

読んでいて気づいたのは、最後の else と catch が同じ文言を出している点です。コードの位置はまったく違うのに、利用者の画面には同じ文章が出る。この二つは、性質が正反対なんですよね。

失敗には「確定した失敗」と「分からない」がある

整理し直すとこうなります。

クライアントが観測したものサーバは受け取ったか性質いま出している文言
400 と VALIDATION_ERROR受け取って弾いた確定入力内容の指摘(正しい)
400 と RECAPTCHA_FAILED受け取って弾いた確定スパム判定の案内(正しい)
429受け取って断った確定混雑の案内(待つ長さは伝えていない)
500 系受け取ったかもしれない不確定送信に失敗しました(言い切っている)
例外(接続断・応答なし)受け取ったかもしれない不確定送信に失敗しました(言い切っている)

上の3行は問題ありません。サーバが返事をしてきた以上、サーバは受け取っています。受け取ったうえで弾いた。だから「届いていない」という前提で案内しても嘘にならないし、内容を直して送り直してもらえば済みます。

問題は下の2行です。特にいちばん下、通信そのものが切れた場合。ブラウザから見ると「応答が得られなかった」としか分かりません。リクエストがサーバに届く前に切れたのか、届いて処理が終わったあとで応答だけが失われたのかを、クライアント側で区別する方法はないんですよね。

そして今のコードは、この区別できない状態で、ボタンを押せる状態に戻しています。

ローカルで再現してみた

推測で話を進めても仕方がないので、手元で再現しました。外部には一切通信しない、127.0.0.1 だけで完結するサーバを立てます。

// .tmp-exp/probe.mjs (抜粋)
const received = [];              // サーバが「処理した」とみなしたもの
const idempotencyStore = new Map();

const server = http.createServer((req, res) => {
  let body = '';
  req.on('data', (c) => (body += c));
  req.on('end', () => {
    if (req.url === '/hang') {
      return;                     // 何も返さない。接続は張ったまま
    }

    if (req.url === '/lossy') {
      received.push(JSON.parse(body || '{}'));  // 業務処理は完了する
      res.socket.destroy();                     // そのあとで応答だけが失われる
      return;
    }

    if (req.url === '/idem') {
      const key = req.headers['idempotency-key'];
      if (key && idempotencyStore.has(key)) {
        const saved = idempotencyStore.get(key);   // 再送。処理はせず保存済みを返す
        res.writeHead(saved.status, { 'Idempotent-Replay': 'true' });
        res.end(saved.body);
        return;
      }
      received.push(JSON.parse(body || '{}'));
      const payload = JSON.stringify({ ok: true, receipt: received.length });
      if (key) idempotencyStore.set(key, { status: 200, body: payload });
      // 1回目だけ応答が届かない状況を作る
      res.socket.destroy();
    }
  });
});

// クライアント側
async function post(path, payload, { timeoutMs, idemKey } = {}) {
  const headers = { 'Content-Type': 'application/json' };
  if (idemKey) headers['Idempotency-Key'] = idemKey;

  const ac = timeoutMs ? new AbortController() : null;
  const timer = ac ? setTimeout(() => ac.abort(), timeoutMs) : null;
  try {
    const res = await fetch(base + path, {
      method: 'POST', headers, body: JSON.stringify(payload), signal: ac?.signal,
    });
    return { outcome: 'response', status: res.status, replay: res.headers.get('idempotent-replay') };
  } catch (err) {
    return { outcome: 'throw', name: err.name, cause: err.cause?.code ?? null };
  } finally {
    if (timer) clearTimeout(timer);
  }
}

// 実行結果: 応答が失われるエンドポイントに送り、失敗したので利用者が再送した想定
// {"outcome":"throw","name":"TypeError","cause":"UND_ERR_SOCKET","elapsed":4}
// {"outcome":"throw","name":"TypeError","cause":"UND_ERR_SOCKET","elapsed":1}
// サーバが処理した件数: 2

このサーバは改善の余地があります。socket.destroy() は「応答が失われた」の再現としては乱暴で、実際の現場で起きるのはもっと地味な事象です。ロードバランサのアイドルタイムアウト、モバイル回線の切り替え、タブを閉じた瞬間。それぞれ切れ方が違うので、本当はもう少し丁寧に分けるべきでした。ただ、クライアントから見た結果は今回の関心事のうえでは同じ(応答が得られない)なので、切り分けの検証としてはこれで足ります。重複排除の保存先も本来は Redis などになるところを、単一プロセスの Map で代用しています。

さて、実行結果です。利用者の画面には「送信に失敗しました」が2回出ています。1件も送れていないように見えている。実際には問い合わせが2件登録されています。

営業側から見ると、同じ会社名・同じ担当者名の問い合わせが数秒差で2件並ぶことになります。これ自体は名寄せすれば済む話ではあるのですが、それは受け取る側の手作業を増やしているだけなんですよね。

最初のアプローチはタイムアウトだった。そして足りなかった

私が最初に手を付けようとしたのは、タイムアウトです。応答が返らないエンドポイントに対して、今のコードは待ち続けます。上限をどこにも書いていないので、待つ長さは実行環境まかせです。

手元で計測したところ、上限を指定しない送信は305,637ミリ秒、およそ5分6秒で UND_ERR_HEADERS_TIMEOUT になりました。これは Node の fetch の既定値(ヘッダ受信の上限300秒)が効いた結果で、ブラウザの実装はまた別です。ただ、どちらにせよこちらは何も指定していないので、利用者が何分待たされるかを自分たちで決めていない、という点は変わりません。5分間、送信中の表示のまま待てる人はいないでしょう。

AbortController を足すと、そこはきれいに解決しました。

上限を2秒にして応答が返らないエンドポイントを叩いたところ、2009ミリ秒で AbortError になりました。待ち時間が有界になったわけです。ここまでは想定どおりでした。

問題はその次です。同じタイムアウトを付けた状態で、応答だけが失われるエンドポイントに対して再送をやってみると、サーバが処理した件数は2件のままでした。当たり前といえば当たり前で、タイムアウトは「いつ諦めるか」を決める仕組みであって、「諦めた処理が向こうでどうなったか」については何も言っていません。

今思えば、ここを混同していたのが良くなかったです。タイムアウト設計の議論では、呼び出し元の上限を呼び出し先の想定処理時間より長く取る、といった話が中心になります。それはそれで必要なのですが、上限をどう置いても「送ったかどうか分からない」という状態そのものは消えないんですよね。

では再送をやめればいいのか、という選択肢も一応考えました。押せないようにしてしまえば重複はゼロになります。ただ、これは採りませんでした。うちの規模だと問い合わせ1件の重みが大きく、本当に届かなかったものを黙って捨てるほうが痛いからです。重複してでも受け取って、受け取った側で潰す。その方針にするなら、潰すための鍵が要ります。

想定外だったこと。鍵はすでに手元にあったのに、使えなかった

ここで気づいたことがありました。広告用のLP3本は、送信時にすでに一意なIDを作っています。

// src/pages/lp/recovery/index.astro (抜粋)
lpForm.addEventListener("submit", async (e) => {
  // ...入力チェック...

  const eventId = window.crypto && typeof window.crypto.randomUUID === "function"
    ? window.crypto.randomUUID()
    : Date.now() + "-" + Math.random().toString(36).slice(2);

  const payload = {
    mode: "ai-web",
    lp_source: "wp-recovery",
    // ...
    event_id: eventId,
  };
  // ...この下で fetch する...
});

// 実行結果: 再送のたびに鍵を採番し直した場合
// {"outcome":"throw","name":"TypeError","cause":"UND_ERR_SOCKET"}
// {"outcome":"throw","name":"TypeError","cause":"UND_ERR_SOCKET"}
// サーバが処理した件数: 2

event_id は計測側の重複排除、つまり広告の成果計測が同じコンバージョンを二重に数えないための値です。UUIDで採番されていて、性質としては重複排除の鍵そのものに見えました。これをサーバに渡して突き合わせれば終わりだな、と一度は思ったんです。

ダメでした。

採番している場所が submit ハンドラの中なんですよね。利用者が失敗表示を見てもう一度ボタンを押すと、ハンドラがもう一度動いて、window.crypto.randomUUID() がもう一度呼ばれます。つまり再送のたびに別のIDになる。一意なIDではあるけれど、「同じ操作を指すID」ではなかった。上に貼った実測がそれで、鍵を毎回採番し直すと処理件数は2件のままです。

鍵の値そのものより、いつ採番していつまで保持するかのほうが仕様の本体だった、ということになります。ここは正直、読み違えました。UUIDが生成されているのを見た時点で「ある」と判断してしまった。

ついでに書いておくと、同じコードで一度疑って、そちらは杞憂だった箇所もあります。LP3本がどれも mode: "ai-web" を送っていて、これはコピー漏れだろうと思ったんです。ところが直前の行にコメントがありました。バックエンドが共通なので mode は意図的に揃えていて、流入元は lp_source と challenge で識別する、と。バックエンドを改修せずにLPを増やすための判断でした。

怪しく見えたほうは意図的で、正しく見えたほうは使えなかった。コードの見た目で当たりを付けるのは、やはり当てになりません。

改善後。送信前に採番して、再送でも同じ値を使う

方針は単純です。鍵は最初の送信の前に一度だけ作り、そのフォーム入力に紐づけて保持する。再送では作り直さない。サーバは鍵を見て、処理済みなら保存しておいた応答をそのまま返す。

// クライアント側: 鍵はハンドラの外で、その入力に対して一度だけ採番する
let submissionKey = null;

async function submit(payload) {
  if (!submissionKey) submissionKey = crypto.randomUUID();  // 再送では作り直さない

  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), 8000);
  try {
    const res = await fetch(contactEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': submissionKey },
      body: JSON.stringify(payload),
      signal: ac.signal,
    });
    if (res.ok) submissionKey = null;   // 成功が確認できた時点で破棄する
    return res;
  } finally {
    clearTimeout(timer);
  }
}

// サーバ側: 存在確認と登録を割り込まれない形で行う(擬似コード)
// const first = await store.setIfAbsent(key, { state: 'in_flight' }, ttl);
// if (!first) {
//   const saved = await store.get(key);
//   return saved.state === 'completed' ? saved.response : http(409);
// }
// const response = await handleInquiry(payload);
// await store.set(key, { state: 'completed', response }, ttl);

// 実行結果: 送信前に採番した鍵を再送でも使い回した場合
// {"outcome":"throw","name":"TypeError","cause":"UND_ERR_SOCKET"}
// {"outcome":"response","status":200,"replay":"true","text":"{\"ok\":true,\"receipt\":1}"}
// サーバが処理した件数: 1

2回目のレスポンスに Idempotent-Replay: true が付いていて、receipt は1回目と同じ値です。サーバは処理をやり直していません。利用者から見ると、1回目は失敗表示、2回目で成功表示。実際に登録されたのは1件です。

条件ごとの結果をまとめるとこうなりました。

条件クライアントが見たものサーバが処理した件数
応答が返らない相手・上限なし305,637ミリ秒(約5分6秒)待たされて失敗0
応答が返らない相手・上限2秒2009ミリ秒で中断(AbortError)0
応答が失われる・鍵なしで再送失敗表示が2回2
応答が失われる・上限2秒つき、鍵なしで再送失敗表示が2回2
応答が失われる・再送のたびに鍵を採番失敗表示が2回2
応答が失われる・送信前に採番した鍵を使い回す失敗表示のあと成功表示1

効いているのは最後の1行だけです。上限を付けても件数は減っていません。逆に、上限が無いままだと利用者は延々と待たされるので、両方いります。片方だけ入れて安心するのがいちばん危ない、というのが今回いちばん腹に落ちたところでした。

実装するうえで詰めきれていない点も残っています。保存しておく期間をどのくらいにするか。処理中に同じ鍵で来たリクエストをどう扱うか(上の擬似コードでは409を返す形にしていますが、待たせるという選択肢もあります)。同じ鍵で本文だけが違うリクエストが来たときに、拒否するのか上書きするのか。このあたりは決めの問題で、決めていないと運用に入ってから困るやつです。

それと、これはクライアント側だけでは完結しません。ヘッダを足しても、サーバが無視すれば何も起きない。むしろ「鍵を送っているから安全」という誤解だけが残るので、順番としてはサーバ側の突き合わせが先です。

文言のほうも、いずれ直したい

コードの話ばかりになりましたが、最初に引っかかったのは文言でした。

不確定な失敗のときに「送信に失敗しました」と言い切るのは、厳密には嘘を含んでいます。届いているかもしれない。それを伏せたまま再送をお願いしているわけです。重複排除が入れば実害は消えるので、文言を先に直す必要はないと思っています。ただ、入るまでの間は嘘のままなんですよね。ここは好みが分かれるところですが、私は「送信結果を確認できませんでした」くらいの言い方に寄せたいと考えています。

振り返って

やることの順番はこうなりました。サーバ側に鍵の突き合わせを入れて、次にクライアント4か所の採番タイミングを直して、最後に文言を合わせる。逆からやると、効かない仕掛けと嘘の文言が同時に増えます。

4か所という数も、地味に効いてきます。今回の変更は4か所すべてに同じ形で入れる必要があって、また複製することになる。次にLPが増えたときにまた複製するのかという話は残るので、送信処理そのものを共通化する方向も、あわせて検討していきたいところです。