チャットボットWebhook再送対策|二重返信を防ぐ冪等設計
チャットボットWebhookの再送で二重返信や順序逆転を起こさないために、二段冪等、状態管理、部分成功、DLQ、手動再処理、試験方法を解説します。
利用者が一度しか質問していないのに、チャットボットから同じ返信が二度届く。担当者への引き継ぎチケットも二件作成される。このような事故は、Webhookの再送を「同じ処理をもう一度実行する合図」として扱うと起こります。送信元は応答を確認できなければ、受信側の処理が終わっていてもイベントを再送することがあります。ネットワークの遅延により、古いイベントが新しいイベントより後に届く場合もあります。
チャットボットWebhookの再送対策では、配送を一度きりにするのではなく、同じイベントが何度届いても、返信などの副作用を一度に抑える設計が必要です。そのために、Webhookの受信と利用者への返信を分離し、イベント単位と業務単位の二段階で冪等性を確保します。冪等性とは、同じ要求を繰り返しても、業務上の結果が重複しない性質です。
この記事では、受領記録、キュー、状態遷移、部分成功、順序逆転、デッドレターキュー(DLQ)、手動復旧を一連の処理として整理します。署名検証やリプレイ対策の詳細は、先にチャットボットのWebhook受信設計を確認してください。
チャットボットWebhook再送は「受信」と「返信」を分けて考える
最初に区別すべきなのは、Webhookを受け取ったことと、利用者への返信が完了したことです。受信エンドポイントが2xxを返すのは、通常、イベントを安全に受領したことを送信元へ伝えるためです。チャットの回答生成、返信APIの呼び出し、CRM更新まで同期処理に含めると応答が遅れ、送信元による再送が発生しやすくなります。
GitHubはWebhookのベストプラクティスで、イベントをキューへ渡し、10秒以内に2xxを返すよう案内しています。Slack Events APIも、イベント受信後3秒以内の2xx応答を求め、失敗時の再試行情報を仕様として示しています。ただし、応答期限、成功とみなすステータス、再送回数はサービスごとに異なります。実装時は対象サービスの最新の公式仕様を個別に確認してください。
受信側では、署名と必須項目を検証し、イベントを耐久性のある保存先へ記録してから受領応答を返します。その後、ワーカーが回答生成や返信を進めます。2xxを返した後の障害は送信元の再送に頼らず、内部の状態管理とキューによって復旧します。
| 境界 | 主な処理 | 完了の意味 |
|---|---|---|
| 受信 | 署名検証、形式確認、一意な受領記録、キュー投入 | 後から安全に処理できる状態になった |
| 業務処理 | 回答生成、ルール判定、有人引き継ぎ | 送信内容または次の担当が確定した |
| 副作用 | 返信API、CRM更新、通知、チケット作成 | 外部の状態が一度だけ変更された |
先に配送仕様と失敗地点を洗い出す
実装前に、送信元のイベントID、再送条件、応答期限、順序保証、再配信機能を確認します。正規の再送でイベントIDが維持されるか、管理画面などからの再配信で新しいIDが付くかも重要です。ヘッダー名や再送回数を別サービスの知識から推測せず、公式文書のURL、確認日、対象バージョンを設計記録に残します。
次に処理を分解し、各地点で停止した場合に何が失われ、何が重複し得るかを確認します。「受信DBへの保存は成功したがキュー投入前に停止」「回答生成は成功したが返信前に停止」「返信APIは成功したが完了記録前に停止」では、それぞれ安全な復旧方法が異なります。特に最後のケースを単純に再実行すると、利用者への二重返信につながります。
| 失敗地点 | 見える状態 | 安全な復旧方針 |
|---|---|---|
| 受領記録の前 | イベントが残っていない | 成功応答を返さず、送信元の仕様に沿った再送を受ける |
| 受領記録の後、キュー投入の前 | 受信済み・未処理 | 台帳を走査し、未投入分を再度キューへ渡す |
| 回答生成の途中 | 処理中または再試行待ち | 一時障害に限り、上限付きで内部再試行する |
| 返信成功後、完了記録の前 | 外部では成功している可能性がある | 自動再返信せず、結果照会または保留へ移す |
受信口は検証・記録・キュー投入までに絞る
受信エンドポイントには、短時間で完了できる処理だけを置きます。具体的には、署名検証、イベント種別と必須値の確認、受領台帳への登録、処理キューへの引き渡しです。回答生成や外部APIの呼び出しを受信処理から外すことで、Webhookへの応答時間を安定させます。
ただし、受領記録とキュー投入を別々に実行すると、その間に取りこぼしが生じます。DBへイベントを保存した直後にプロセスが停止すれば、受信済みであるにもかかわらずワーカーへ届きません。対策として、受領台帳と送信予定を同じトランザクションで保存し、別のワーカーが未送信分をキューへ渡すOutboxパターンなどを検討します。採用する基盤に合わせて、少なくとも「受信済み・未投入」のレコードを定期的に検出し、再投入できる仕組みを用意します。
概念上の受信処理は次のとおりです。具体的な関数名やトランザクションAPIは、採用しているフレームワークとデータベースに合わせてください。
async function receiveWebhook(rawBody: Buffer, headers: Headers) {
verifyProviderSignature(rawBody, headers);
const event = parseAndValidate(rawBody);
await db.transaction(async (tx) => {
const inserted = await tx.receivedEvent.insertIfAbsent({
provider: event.provider,
eventId: event.id,
status: "received",
payloadRef: await storeEncryptedPayload(event)
});
if (inserted) {
await tx.outbox.insert({ topic: "chat-event", eventId: event.id });
}
});
return new Response(null, { status: 204 });
}
同じイベントがほぼ同時に到着しても一件だけ登録されるよう、アプリケーション内の事前検索だけでなく、データベースの一意制約を使います。「検索してから追加する」という二段階の処理だけでは、検索と追加の間に別の処理が同じ行を追加できるためです。一意制約を最終的な判定境界にし、競合が起きた場合は既存レコードの状態を読み取って次の処理を決めます。
イベントIDと会話・メッセージIDで二段冪等にする
第一段階は、配送単位の冪等性です。提供元とイベントIDの組み合わせを一意にし、同じ配送イベントからワーカーが複数起動しないようにします。複数のサービスを連携する場合、イベントIDだけを一意にすると、偶然同じ文字列を使う別サービスのイベントまで重複と判定するおそれがあります。
第二段階は、業務上の副作用に対する冪等性です。イベントIDが異なっていても、同じ利用者メッセージに対する返信要求が作られる場合があります。たとえば、管理画面からイベントを再配信した場合や、関連する二種類のイベントが同じ返信処理へ到達した場合です。そこで、チャネル、会話ID、入力メッセージID、返信種別を組み合わせた業務キーを用意し、返信レコードを一意にします。
CREATE UNIQUE INDEX uq_received_event
ON webhook_events(provider, event_id);
CREATE UNIQUE INDEX uq_chat_reply
ON chat_replies(channel, conversation_id, source_message_id, reply_kind);
一意制約に衝突しても、直ちに「処理済み」とは判断しません。既存レコードが成功、処理中、保留のどの状態にあるかを確認します。成功なら返信を再実行せず終了します。処理中なら新しいワーカーを起動せず、処理権の期限を確認します。保留なら自動処理を重ねず、結果照会または担当者の判断へ進めます。
イベントIDが提供されない場合、本文ハッシュだけを安易に代用しないでください。別の利用者が同じ文面を送ることもあれば、再送時に配送情報が変わることもあります。正規の再送でも安定する項目を公式仕様で確認し、チャネル、会話、メッセージ、イベント種別などの組み合わせを定義します。識別子が欠損した場合に受信を拒否するのか、保留へ移すのかも決め、衝突時と欠損時の双方を試験します。
処理状態を遷移として管理する
単一の「処理済み」フラグだけでは、障害時に再実行してよいか判断できません。少なくとも、受信、処理中、成功、再試行待ち、保留、失敗を区別します。各レコードには更新日時、試行回数、次回試行日時、最終エラー分類、相関IDを持たせます。会話本文や認証情報をエラー欄へそのまま残さないよう、記録対象とマスキング方法も事前に決めます。
| 状態 | 意味 | 次の操作 |
|---|---|---|
| 受信 | 安全に記録したが処理は未開始 | ワーカーが処理権を取得する |
| 処理中 | 一つのワーカーが実行中 | 期限内は重複起動せず待つ |
| 成功 | 必要な副作用と結果記録が完了 | 再送は成功済みとして終了する |
| 再試行待ち | 一時障害で再実行可能 | 次回時刻に上限付きで実行する |
| 保留 | 外部処理の成否が不明、または判断が必要 | 結果照会または権限を持つ担当者による確認へ進む |
| 失敗 | 上限到達または再実行不能 | DLQへ移し、原因修正後に復旧可否を判断する |
「受信」から「処理中」への遷移は、条件付き更新によって一つのワーカーだけが成功するようにします。ワーカーが停止して「処理中」のまま残る場合に備え、処理権にはリース期限を設けます。ただし、期限が切れたという理由だけで返信を再実行してはいけません。停止したワーカーが外部送信まで終えている可能性があるためです。返信前後の状態を分け、外部結果を確認できないレコードは「再試行待ち」ではなく「保留」へ移します。
返信成功後にDB更新が失敗した場合を設計する
特に注意が必要なのは、返信APIが成功した後、受信側が成功状態をDBへ保存できなかった場合です。再試行するワーカーから見ると処理は未完了ですが、利用者にはすでに返信が届いています。この状態を通常の一時エラーと同じキューへ戻すと、二重返信が発生します。
返信先APIが冪等キーを受け付ける場合は、同じ業務キーを毎回送り、同じ要求が重複適用されない仕様であることを公式文書で確認します。送信結果を照会できる場合は、外部リクエストIDやメッセージIDを保存し、再送前に結果を確認します。冪等キーも結果照会も利用できず、送信の成否が不明な場合は、自動再実行せず保留へ移します。
外部返信の直前に「送信予定」を保存しても、それだけでは一度だけの実行を保証できません。記録後・送信前に停止すれば未送信ですが、送信後・完了記録前に停止すれば送信済みです。どちらもDBには「送信予定」としか残らず、DB内の情報だけでは区別できません。外部サービスとの完全な分散トランザクションを前提にせず、冪等キー、結果照会、保留、手動確認を組み合わせて判断します。
手動確認では、利用者へ再返信する前に、会話画面または送信履歴で同じ内容がすでに届いていないかを確認します。確認した外部ID、判断結果、判断理由を対象レコードへ関連付けます。担当者が会話本文の機密情報を必要以上に閲覧しないよう、参照権限と記録範囲も設計してください。
順序逆転は到着時刻ではなく業務状態で判定する
Webhookは、送信された順序どおりに到着するとは限りません。古い「未対応」イベントが、新しい「有人対応中」イベントより後に届くことがあります。受信時刻だけで新旧を判断すると、遅れて届いた古いイベントを最新と誤認し、現在の状態を巻き戻してしまいます。
提供元が連番、イベント発生時刻、オブジェクトの版を提供する場合は、その意味と再送時の挙動を公式仕様で確認します。比較に使える値があっても、時計のずれや同時刻のイベントを考慮し、現在の業務状態から許可する遷移を併用します。たとえば「未対応→自動対応中→有人対応中→完了」は許可し、「完了→未対応」は管理者による再開操作を除いて拒否する、といった判断基準です。
会話内の複数メッセージを直列化するかどうかは、処理内容に応じて判断します。回答生成をすべて並行させると、処理時間の差によって返信順が入れ替わりやすくなります。一方、別の会話まで同じキューで直列化すると、一件の遅延がほかの利用者にも波及します。会話IDをパーティションキーにして同じ会話だけを直列化し、異なる会話は並行処理する構成が候補です。
古いイベントを適用しない場合も、記録自体は残します。提供元、イベントID、比較に使った版または時刻、現在状態、拒否理由、判断時刻を記録します。これにより、利用者から返信が届かなかったと連絡を受けた場合でも、どの判定によって処理を止めたか追跡できます。
再試行上限・DLQ・手動再処理をつなぐ
再試行の対象は、時間を置けば成功する可能性があるエラーに限定します。タイムアウト、一時的な接続障害、連携先が再試行可能と明示する429や一時的な5xxなどが候補です。一方、入力形式の不備、権限不足、存在しない宛先、外部送信の成否不明は、同じ要求を繰り返しても解消しません。HTTPステータスだけで一律に決めず、連携先の公式仕様を確認し、エラー本文から機密情報を除いたうえで分類します。
再試行間隔には指数バックオフとジッターを使い、障害中の連携先へ要求が集中することを避けます。具体的な回数と最大待機時間は、会話で許容できる遅延、送信先の制限、有人対応へ切り替える目標時間を基に決めます。429への対応、同時実行数、流量制御を具体化する場合は、チャットボットのレート制限設計も参照してください。
上限に達したイベントは、DLQへ移すだけで完了としません。アラートの通知先、確認期限、原因修正後の再投入方法、利用者への代替案内まで決めます。DLQ内のイベントを無条件でまとめて戻すと、二重返信や同じ障害の再発につながります。現在の業務状態と副作用の履歴を確認し、一件ずつ、または安全性を判断できる条件で絞って再投入します。
手動再処理では、閲覧、再試行、成功扱い、破棄の権限を分けます。操作者、承認者、対象イベント、変更前後の状態、理由、実行結果を監査ログへ残します。手動操作のログ項目と保存方針を具体化する際は、チャットボットの監査ログ設計も確認してください。
タイムアウト・重複・逆転・部分成功を試験する
正常なイベントを一度送り、返信を確認するだけでは再送対策を検証できません。公開前に加え、キュー、DB、返信先API、状態遷移を変更した後にも、障害を意図的に再現します。検証対象はHTTP応答だけではありません。受領台帳の件数、返信レコードの件数、実際の送信件数、最終状態、アラート、監査ログを突き合わせます。
| 試験 | 操作 | 期待結果 |
|---|---|---|
| 同時重複 | 同じイベントIDを並行送信する | 受領、返信レコード、実返信が各一件になる |
| 別ID・同一業務 | 同じメッセージを指す異なるイベントを送る | 業務キーによって重複返信が防止される |
| 受信タイムアウト | 保存先を遅延または停止する | 受領前に成功応答を返さず、回復後の再送を安全に処理する |
| キュー投入前停止 | 受領記録後にプロセスを停止する | 未投入イベントが台帳から回収される |
| 部分成功 | 返信成功後の完了保存を失敗させる | 自動で再返信せず、保留または結果照会へ進む |
| 順序逆転 | 新しい状態の後に古いイベントを送る | 状態が巻き戻らず、拒否理由が記録される |
| 再試行上限 | 一時エラーを継続させる | 上限到達後にDLQへ移り、担当者へ通知される |
| 手動復旧 | 権限を持つ担当者が一件を再投入する | 承認・理由・結果が残り、重複送信が起きない |
試験用の会話と送信先は本番利用者から分離し、テストイベントであることを識別できるようにします。合格条件は「最終的に成功した」だけではありません。二重返信がないこと、取りこぼしたイベントを回収できること、成否不明のイベントを自動で再実行しないことまで確認します。これらの障害試験をチャットボット全体の公開判定へ組み込む場合は、チャットボット公開前テストケースと合わせて計画してください。
実装と運用をつなぐ確認チェックリスト
- 送信元の応答期限、成功ステータス、再送条件、順序保証を公式仕様で確認したか
- 受信処理を署名検証、受領記録、キュー投入までに絞ったか
- 受領記録後・キュー投入前の停止から未処理イベントを回収できるか
- 提供元とイベントIDの組み合わせをデータベースで一意にしたか
- 会話ID、入力メッセージID、返信種別などで副作用も一意にしたか
- 受信、処理中、成功、再試行待ち、保留、失敗の状態遷移を定義したか
- 返信成功後に完了記録が失敗した場合の結果照会または保留手順があるか
- 順序判定に版、イベント発生時刻、現在状態、許可された遷移を使っているか
- 再試行可能なエラー、間隔、上限、DLQへの移行条件を決めたか
- 手動再処理の権限、承認、監査ログ、利用者への代替案内を決めたか
- 重複、別IDの同一業務、部分成功、順序逆転を公開前に試したか
WebとLINEを含むチャネル全体の役割分担から見直す場合は、WebサイトとLINEを連携する設計も参考になります。Socratesで扱える連携範囲、回答の運用方法、有人対応への切替方法を確認したい場合は、現在の送信元、利用できる会話識別子、失敗時の運用手順を整理したうえで導入相談をご利用ください。対象環境の仕様を確認しながら、実現方法を検討します。
よくある質問
イベントIDを一意にすれば二重返信は防げますか
配送イベントの重複防止には有効ですが、それだけでは十分ではありません。異なるイベントIDから同じ返信処理が作られる場合や、返信成功後の記録失敗によってワーカーが再実行される場合があります。イベントIDによる配送単位の制御に加え、会話ID、入力メッセージID、返信種別などの業務キーで返信そのものを一意にします。
Webhookをexactly-onceで処理できますか
外部サービス、ネットワーク、受信DB、キュー、返信APIをまたいで、配送回数そのものを完全に一度へ固定する前提にはできません。少なくとも一度届くことを想定して重複を検出し、返信などの副作用を冪等に保ちます。外部APIの冪等キーや結果照会が使えない範囲には、保留と手動確認を用意します。
冪等性レコードはいつ削除すればよいですか
一律の日数では決められません。送信元が再送・再配信できる期間、問い合わせ記録の保存方針、監査要件、個人情報の保持方針を確認して決めます。削除後に古いイベントが届くと新規イベントとして扱われるため、再送可能期間より短い保持は避けます。本文を残す必要がない場合は、識別子と最小限の処理結果を本文から分離して保持する方法も検討します。
手動再処理なら冪等性チェックを省略できますか
省略できません。手動操作でも、すでに返信済みのイベントを再投入する可能性があります。自動処理と同じ一意制約と状態判定を通し、再送が必要な場合は承認操作を分けて理由を記録します。操作者、承認者、対象、変更前後の状態、実行結果も監査ログへ残してください。
再試行回数は何回に設定すればよいですか
提供元と返信先の仕様、会話で許容できる遅延、有人対応へ切り替える目標時間によって異なります。回数だけを先に決めず、再試行間隔、最大経過時間、対象とするエラー分類、上限到達後の通知先をまとめて設計します。連携先がRetry-Afterなどを返す場合は、そのサービスの公式仕様に従って扱います。