ブログ一覧

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

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-numx-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 の遷移そのものをロックにしました。エピソードは draftinggeneratingreviewingpostingposted と進みますが、この更新に 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 で塞いでいます。1 回で全部は塞げませんでした。

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

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

  • 確実に失敗(再試行してよい): DNS 解決の失敗や接続拒否のように、リクエストが相手に届く前に落ちたもの。応答があるものでは 400、401、権限の 403、429。リクエストが拒否されたので、未投稿が確実
  • 結果不明(自動では再試行しない): タイムアウト、ECONNRESETEPIPE のように送信後に起こり得るソケットの失敗、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 の側にはありません。入口の冪等化と出口の失敗分類で、その保証を外側から与えています。

参考リンク

関連

この記事をシェア