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も自分で確認メッセージを生成するため、メッセージが重複する。
最終的には固定メッセージを削除して、processWithClaudeにonIntermediateTextコールバックを追加した。ツールループの各ラウンドで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.tsがdurable-object.tsをre-export → durable-object.tsがindex.tsからhandleSlackEventをimport。ESMの循環参照でhandleSlackEventがundefinedになり、DO内でサイレントクラッシュ。
handleSlackEventをhandler.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個のバグのうち半分以上は本番に出る前に潰せていただろう。