ブログ一覧

X API への自動投稿が二重になる経路を入口と出口で塞ぐ

更新: Web開発者向け

Slack にエピソードを書き込むと、LLM と対話しながら 4 コマ漫画のシナリオを練り、画像を生成して X に自動投稿する bot を、AWS Lambda 1 本で動かしています(私が @SaitouWeb で流していた 4 コマは、この bot の出力です)。この bot には、同じ 4 コマが X に 2 回出る経路が 2 つありました。入口は Slack が同じイベントを 2 回届けることとボタンの連打で、出口は X API に投稿した結果が分からないまま再試行することです。二重投稿は実際には起きておらず、コードを読み直して「起こり得る」と分かったので先に塞ぎました。この記事は、2 つの経路をどこで塞いだかと、塞ぎきれずに残った部分の記録です。

構成のうち関係する部分

src/
  app.ts                  # Slack イベント / cron / 自己 invoke を 1 つの Lambda で分岐
  handlers/
    episode.ts            # 受付: ガード → イベントの冪等化 → status 遷移 → 自己 invoke
    actions.ts            # ボタン: ack → 条件付きの status 遷移 → 自己 invoke
  services/
    store.ts              # DynamoDB。claimEvent / transitionStatus / beginRegeneration
    publisher.ts          # X API。失敗を 3 種類に分類する PublishError
    processor.ts          # 処理: 生成と投稿。「重複」は投稿済みに畳む
    scheduler.ts          # 予約投稿。「結果不明」は自動で再試行しない
  __tests__/e2e/
    idempotency.test.ts   # 再送・連打・投稿失敗からの復帰
terraform/                # DynamoDB は 1 テーブル。デプロイ用ロールの権限はこの系のリソースに限定

入口: Slack は同じイベントを最大 4 回届ける

Slack の Events API は、こちらが 3 秒以内に 2xx を返さないと配信失敗とみなし、最大 3 回まで再送します。1 回目はほぼ即時、2 回目は 1 分後、3 回目は 5 分後です。再送には x-slack-retry-num と x-slack-retry-reason のヘッダが付き、理由が http_timeout なら「前回は 3 秒を超えた」という意味になります。つまり、同じイベントが最大 4 回届く前提で受け取る側を作る必要があります。

最初の実装は、http_timeout 理由の再送だけを早期 return で無視し、それ以外の再送はそのまま処理していました。DynamoDB への保存は無条件の PUT で、同じイベントが 2 回処理されればエピソードが 2 つできる構造でした。冪等化については、当時のコードに私自身がこう書いています。

完全な冪等化(event_id による重複排除)が必要になった場合は DynamoDB 等で処理済み管理を検討する。

「必要になったら」と先送りしていたわけです。

塞ぎ方: イベント ID を条件付きで書く

イベント ID(無ければ channel:ts)をキーにしたマーカーを、attribute_not_exists(id) の条件付き PUT で書きます。書けた側だけが処理を続け、条件で弾かれた側は何もせず 200 を返します。マーカーには 24 時間の TTL を付けて、自動で消えるようにしています。

await client.send(new PutCommand({
  TableName: TABLE,
  Item: { id: `event#${eventKey}`, ttl: Math.floor(Date.now() / 1000) + 24 * 60 * 60 },
  ConditionExpression: "attribute_not_exists(id)",
}));
// ConditionalCheckFailedException なら「もう誰かが取った」= false を返して終わる

マーカー用のテーブルを新しく作らず、エピソードと同じテーブルに event# の接頭辞で同居させています。デプロイに使う IAM ロールはこの系のテーブル 1 つに操作を限定してあり、テーブルを増やすには権限を定義している層(bootstrap)を先に変更する必要がありましたが、同居させれば既存の権限のまま済みます。捨てたのは、テーブルを分けたときの見通しの良さです。マーカーは status 属性を持たないので、投稿一覧の Scan や GSI には出ませんが、テーブルをそのまま眺めると混ざって見えます。

マーカーだけでは足りない: ボタンの連打は別のイベント

イベント ID の冪等化で防げるのは「同じイベントの再送」だけです。「生成する」ボタンを 2 回押すと、それは別々のイベントとして届くので、マーカーは両方通ります。連打は status の遷移そのものをロックにして防ぎました。エピソードは drafting → generating → reviewing → posting → posted と進みますが、この更新に ConditionExpression を付けて「今の status がこれのときだけ次へ進む」にします。2 回目の押下は条件で弾かれ、Slack のスレッドに「すでに処理中です」を返して終わります。

順序が肝心で、投稿は `posting` に遷移してから X API を呼びます。 呼んでから遷移すると、その隙間にもう 1 回呼べるからです。成功したら posted に、失敗したら reviewing に戻して再試行できるようにしました。

もう 1 つ、画像の再生成回数の上限判定が「読んで、足して、書く」になっていて、同時に走ると回数が重複する形でした。これも条件付きの原子的な加算にしています。

UpdateExpression: "SET #status = :generating, regenerationCount = regenerationCount + :one",
ConditionExpression: "#status = :reviewing AND regenerationCount < :max",

この競合が実際に起きたかどうかは、記録が無いので分かりません。コードを読み直して分かったので、同じ PR で直しました。

出口: 投稿した結果が分からないときに再試行してはいけない

入口を塞いでも、出口の再試行で二重になる経路が残ります。X API を呼んだあと、タイムアウトや送信後の切断で応答が返ってこなかった場合、投稿は成功しているかもしれません。ここで「失敗した」と判断して reviewing に戻し、もう一度投稿すると二重になります。手動の再投稿なら人が X を見てから押せますが、予約投稿は cron が最大 3 回自動で再試行するので、人の目を挟まずに二重になり得る形でした。入口を塞いだ PR の時点でこの経路には気づいていて、コードに NOTE として残し、翌日の PR で塞いでいます。

塞ぎ方: 失敗を 3 種類に分ける

X API のクライアントには twitter-api-v2 を使っていて、失敗は 3 つの型で返ってきます。応答が無かった ApiRequestError、応答はあった ApiResponseError、応答が途中で切れた ApiPartialResponseError。失敗を送信前か送信後かで分けました。

  • 確実に失敗(再試行してよい): DNS 解決の失敗や接続拒否のように、リクエストが相手に届く前に落ちたもの。応答があるものでは 400、401、権限の 403、429。リクエストが拒否されたので、未投稿が確実
  • 結果不明(自動では再試行しない): タイムアウト、ECONNRESET や EPIPE のように送信後に起こり得るソケットの失敗、5xx、途中で切れた応答。投稿されている可能性が残る
  • 重複(投稿済みとして扱う): 403 のうち、X が「同じ内容の投稿は作れない」と拒否したもの。v2 の応答では detail に duplicate content の文言、v1 では code: 187。これは「1 回目が実は成功していた」証拠として使う
export type PublishErrorClassification = "retryable" | "unknown_outcome" | "duplicate";

export function classifyPublishError(error: unknown): PublishErrorClassification {
  if (isApiRequestError(error)) {
    const code = error.requestError?.code;
    if (code && UNKNOWN_OUTCOME_SOCKET_CODES.has(code)) return "unknown_outcome";
    if (/timeout|timed out/i.test(error.requestError?.message ?? "")) return "unknown_outcome";
    return "retryable";
  }
  if (isApiPartialResponseError(error)) return "unknown_outcome";
  if (isApiResponseError(error)) {
    if (isDuplicateContentError(error)) return "duplicate";
    if (error.code >= 500) return "unknown_outcome";
    return "retryable";
  }
  return "retryable";
}

自動で再試行するのは「確実に失敗」だけです。「結果不明」は、手動投稿なら reviewing に戻して「X を確認し、投稿されていない場合だけ再度押してください」と人に知らせ、予約投稿なら自動再試行せず即 reviewing に戻します(再試行回数も消費しません)。「重複」は posted にし、投稿 ID は取れないので指標の収集だけ無効にします。

捨てたのは、投稿後に X 側を照会して本当に投稿されたかを確かめる厳密な方法です。照会の API を呼ぶ処理とそのレート制限への対応を持つより、結果不明を人に知らせる方が手間が少ないと判断しました。

検証

入口と出口それぞれに、e2e テストで挙動を固定しました。同じイベントの再送で 2 つ目のエピソードができないこと、ボタンの二重押下で生成と投稿が 1 回しか走らないこと、投稿失敗から reviewing に戻れること。出口側は分類のユニットテストが 22 件、手動と予約それぞれの「結果不明」「重複」「確実に失敗」の経路を通す e2e が 9 件です。

スレッドの残りは失われ、重複を成功の証拠にできるのは直後だけ

  • スレッド形式(2〜4 件を返信で繋ぐ投稿)の途中で「重複」が返ると、先頭を投稿済みにした時点で残りの投稿は失われます。巻き戻すと先頭が二重になるので、先頭の成功を優先しています
  • X は時間が空くと同じ内容を重複として拒否しないことがあります。開発者フォーラムには、同じ内容が 20 分後や 1 時間後には通ったという報告があります。「重複」を「1 回目が成功していた証拠」として使えるのは、直後の再試行に限ります
  • 「結果不明」を人に知らせる設計は、人が X を見に行く手間を前提にしています。個人で運用している bot なので、この手間は許容しました。投稿量が多い運用では、照会の API で確かめる方を選ぶと思います

再送は来る前提で受け、送信後の失敗は自動で再試行しない

  • 再送は「来る前提」で受ける。Slack は 3 秒で切り、最大 3 回再送する。無視する分岐を書くと、初回の処理が失敗したときにイベントが消える
  • 冪等化は 2 段にする。同じイベントの再送はイベント ID のマーカーで、別イベントになる連打は status 遷移の条件で弾く
  • 外部 API の失敗は「送信前か送信後か」で分ける。送信後の失敗は成功している可能性があり、自動で再試行してはいけない
  • 「重複」の拒否は、直後の再試行に限って成功の証拠として使える

この bot は、確率的な作業者(LLM)を Slack から呼び出して、その結果を X という外部に書き込む系です。書き込みが 1 回しか起きないことを保証する仕組みは LLM の側に無いので、入口の冪等化と出口の失敗分類で LLM の外側から保証しています。

参考リンク

関連

この記事をシェア