zpaper-draft

Type to search...

to open search from anywhere

Cloudflare Workers + Slack Bot開発で踏んだ15個の地雷

概要

Cloudflare Workers上で動くSlack botを開発・運用していたところ、ユーザーから「動かない」「返事がない」「エラーが出る」という報告が相次いだ。1セッションで15個のバグを発見・修正し、最終的にCloudflare Durable Objectへのアーキテクチャ移行まで行った。そのまとめ。

背景

とあるサイトの運用ツールとして、Slack botを作っている。Cloudflare Workers上で動作し、Claude APIを使って会話し、GitHub APIでサイトのコンテンツを編集する仕組み。ユーザーからスクリーンショット付きでバグ報告が来るたびに修正→デプロイを繰り返したが、テストなしでデプロイしたため次々と新しいバグを生んだ。最終的にテストを書いてからアーキテクチャを根本的に見直した。

以下、15個のバグとその修正を順に記録する。

1. tool_use/tool_result履歴破損: Claude API 400エラー

エラーメッセージはこれ。

invalid_request_error: tool_use ids were found without tool_result blocks

processWithClaudeのツールループがMAX_TOOL_ROUNDSに達した時、最後のレスポンス(tool_useブロック含む)がそのまま会話履歴に保存されていた。次のユーザーメッセージ送信時、tool_useに対応するtool_resultがないためAPIが400を返す。

修正としてsanitizeMessages()関数を作成した。最後のassistantメッセージからtool_useブロックを除去してから保存する。ロード時にも同じサニタイズを適用して、既に破損したKVデータも修復できるようにした。

function sanitizeMessages(messages: ClaudeMessage[]): ClaudeMessage[] {
  if (messages.length === 0) return messages;
  const last = messages[messages.length - 1];
  if (last.role === "assistant" && Array.isArray(last.content)) {
    const hasToolUse = last.content.some((b) => b.type === "tool_use");
    if (hasToolUse) {
      const textOnly = last.content.filter((b) => b.type === "text");
      if (textOnly.length > 0) {
        return [
          ...messages.slice(0, -1),
          { role: "assistant", content: textOnly },
        ];
      }
      return messages.slice(0, -1);
    }
  }
  return messages;
}

2. undefinedに対する.replace()呼び出し

Cannot read properties of undefined (reading 'replace')

GitHub Contents APIがファイル内容なしのレスポンスを返す場合がある(大きいファイルなど)。decodeBase64Utf8(data.content)data.contentがundefinedのまま.replace()が呼ばれてクラッシュ。

修正はif (!data.content)ガード追加。

3. レート制限が低すぎ

レート制限を30回/日に設定していた。エラーになったリクエストもカウントされるため、バグで何度もリトライするとすぐ上限に達する。ユーザーには「今日のリクエスト上限に達しました」と表示されるが、そもそもエラーのリトライで消費しているので実質的に使えていない。

30 → 300に引き上げた。

4. 処理中の沈黙問題

Claude APIコール + ツール実行に数十秒かかるが、その間ユーザーに何も表示されない。「壊れてる?」と思われる。

最初の修正として固定メッセージ「確認しました。完了したらお知らせします。」を即座に送信するようにした。ただ、Claudeも自分で確認メッセージを生成するため、メッセージが重複する。

最終的には固定メッセージを削除して、processWithClaudeonIntermediateTextコールバックを追加した。ツールループの各ラウンドでClaudeのテキストをSlackに中間送信する。さらにツール実行状況(>> read_file: pathのような形式)も送信するようにした。

export async function processWithClaude(
  inputMessages: ClaudeMessage[],
  env: Env,
  onIntermediateText?: (text: string) => Promise<void>,
): Promise<{ response: ClaudeResponse; messages: ClaudeMessage[] }> {
  // ...
  while (response.stop_reason === "tool_use" && round < MAX_TOOL_ROUNDS) {
    // Claudeのテキストを中間送信
    if (onIntermediateText) {
      const text = response.content
        .filter((b) => b.type === "text")
        .map((b) => b.text)
        .join("\n")
        .trim();
      if (text) await onIntermediateText(text);
    }
    // ツール実行進捗も送信
    for (const block of response.content) {
      if (block.type === "tool_use") {
        if (onIntermediateText) {
          await onIntermediateText(`>> ${block.name}: ${block.input.path}`);
        }
        const result = await executeTool(block.name, block.input, env);
        // ...
      }
    }
  }
}

5. エラーメッセージが汎用的すぎ

エラー時に「エラーが発生しました。しばらくしてからもう一度お試しください。」としか表示されず、何が起きたかわからない。

修正として、実際のエラーメッセージ + デバッグ情報(エラータイプ、履歴メッセージ数、スレッドID)をSlackに表示するようにした。エラー返信自体もtry-catchで保護。

6. スレッド返信がチャンネルにも表示される

reply_broadcast: falseをSlack API呼び出しに明示的に追加した。

7. Botが自分のメッセージを処理するループ

Botの投稿したメッセージイベントにbot_idフィールドがなく、userもundefined。既存のbot_idチェックをすり抜けていた。Claudeが「メッセージが届いていないようです。何かご用件はありますか?」と困惑した返答をする。

修正として!body.event?.userチェックを追加。userフィールドがないイベントは無視。

8. KVデデュプがSlackレスポンスをブロック

イベント重複チェックのawait env.KV.get(eventKey)がHTTPレスポンスの前に実行されていた。Slackは3秒以内に200を返さないとタイムアウトと判断する。KVアクセスが遅延すると3秒を超えて、連続失敗でSlackがイベント配信自体を停止する。

症状としては、wrangler tailでイベントが一切来なくなる。

修正として、デデュプチェックをバックグラウンド処理のhandleSlackEvent内に移動した。Slackへの200レスポンスは即座に返す。

9. Slackリトライの無条件無視

request.headers.get('x-slack-retry-num')で全リトライを無視していた。デプロイ中にオリジナルイベントが失われた場合、リトライも無視されメッセージが処理されない。

ヘッダーチェックを削除して、KVベースのデデュプに置き換えた(evt:{event.ts}キーで管理)。

10. デデュプが失敗時もイベントを「完了」マーク

KV.putでイベントキーを設定した後に処理開始していた。処理中にWorkerが死ぬと、イベントは「処理済み」扱いのままリトライも通らない。

修正として「processing」(TTL 60秒)→ 処理成功後に「done」(TTL 300秒)の2段階マーキングにした。失敗時はprocessingが自然に期限切れになりリトライ可能。

11. Claudeの挨拶問題

「こんにちは! 何かお手伝いできることはありますか?」 — 挨拶は不要。絵文字も使うなと指示しているのに使う。

システムプロンプトに以下を明示的に追加した。

  • 「Do NOT greet or say hello. Skip pleasantries. Get straight to the task.」
  • 「Do NOT use emojis.」
  • 「ALWAYS include text in your response before calling any tools.」

12. Worker死亡によるサイレント処理中断: 根本原因

ctx.waitUntil()にはCloudflare Workersの実行時間制限がある。Claude APIコール + ツール実行が数十秒から数分かかると、Workerが途中で強制終了される。catchブロックは実行されず、エラーメッセージも送信されない。ユーザーから見ると「中間テキストは来たがその後沈黙」。

これが一番根深い問題だった。修正として、Durable Object(最大15分の実行時間)に処理を移行した。

Slack → Worker.fetch() → 200を即座に返す
                       → stub.fetch() → Durable Object (最大15分)
                                         ├─ Claude API呼び出し
                                         ├─ ツール実行
                                         ├─ Slack返信
                                         └─ エラーハンドリング

13. 循環インポートによるDOクラッシュ

index.tsdurable-object.tsをre-export → durable-object.tsindex.tsからhandleSlackEventをimport。ESMの循環参照でhandleSlackEventがundefinedになり、DO内でサイレントクラッシュ。

handleSlackEventhandler.tsに分離して循環依存を解消した。

:::note 注記

ESMの循環参照はTypeScriptの型チェックでは検出されない。コンパイルは通るが実行時にundefinedになる。

:::

14. max_tokensが小さすぎ

max_tokensを4096に設定していた。update_fileツールコールでファイル全体の内容をcontentパラメータに含める必要がある。.astroファイルは数千トークンになるため、4096トークンでは応答が途中で切れる。ツールコールのJSON構造が不完全になり、ツールが実行されない。

症状としては、Claudeが「変更を適用します。」と言った後、何も起こらない。エラーも出ない。応答がmax_tokensで切れているだけだが、stop_reasonを見なければわからない。

max_tokensを30000に引き上げた。

15. Botが全チャンネルメッセージに反応

メンション無しの通常メッセージにもBotが反応していた。

<@BOT_ID>パターンのメンションチェック、またはthread_tsがあるスレッド返信のみ処理するフィルターを追加した。

教訓

このセッションで得た教訓をまとめておく。

テストなしデプロイの連鎖

1つの修正が別のバグを生む。テストなしでデプロイ→バグ報告→修正→デプロイ→新しいバグ、というループ。最終的に69個のテストを書いてからアーキテクチャ変更を行った。

Slack Events APIの3秒ルール

レスポンスが3秒以内に返らないとSlackはリトライし、最終的にイベント配信を停止する。非同期処理は必ずレスポンス後に行う。8番と9番のバグはどちらもこれに起因している。

ctx.waitUntilの限界

Cloudflare Workersのctx.waitUntilには実行時間制限がある。Claude APIのように応答に数十秒かかるAPIを呼ぶ場合、Workerが途中で死ぬ。長時間処理にはDurable ObjectかQueuesを使う。

max_tokensの見積もり

ツールコールでファイル全体を出力する場合、max_tokensが小さいと応答が途中で切れてサイレント失敗する。出力にどれだけのトークンが必要かを考慮して設定する必要がある。

循環インポート

ESMの循環参照は実行時にundefinedになる。TypeScriptの型チェックでは検出されない。Durable Objectのようにエントリポイントからre-exportする構造ではとくに起きやすい。

wrangler tailの有用性

wrangler tailで本番ログをリアルタイムで見ることで、フィルタリングされているイベントやDOの動作を確認できる。Slackがイベント配信を停止している(8番のバグ)のを発見できたのもwrangler tailがあったから。

余談

1セッションで15個のバグ修正とアーキテクチャ移行を行った。最大の教訓は「テストを書いてからデプロイする」ということ。そしてCloudflare Workersで長時間処理を行う場合はDurable Objectが必須ということ。テストを先に書いていれば、15個のバグのうち半分以上は本番に出る前に潰せていただろう。