April 22, 2026

[Anthropic 529リトライ後にメッセージが間違ったトピックに送信される問題] - Telegram Forum: Message Sent to Wrong Topic After Anthropic 529 Retry

Anthropic APIが529(過負荷)を返し、OpenClawがリクエストを再試行すると、返信メッセージが正しいmessage_thread_idなしで送信され、フォーラムトピックからメッセージが消える原因となります。

🔍 症状

主な症状

Anthropic APIの529リトライサイクル後、Telegram返信メッセージは正常に送信され(Telegram APIがokと有効なmessage_idを返す)、但是消息未出现在预期的论坛主题中。

ログの証拠

2026-03-03T11:19:05.208Z [agent/embedded] embedded run agent end: runId=561c9fa1 isError=true error=The AI service is temporarily overloaded.
2026-03-03T11:19:05.685Z [agent/embedded] embedded run agent end: runId=81dab484 isError=true error=The AI service is temporarily overloaded.
2026-03-03T11:24:34.955Z [telegram] sendMessage ok chat=-1003885638534 message=13832

診断上の症状

  • thread not foundエラーなし — TelegramはスレッドIDを拒否しなかった
  • ログにmessage_thread_idなし — デバッグ出力がスレッドパラメータを省略し、診断を困難にしている
  • 5分のギャップ — 最後の529エラーとsendMessageの間に(バックオフを伴うリトライを示唆)
  • セッションエントリなし — インシデント当日にtopic:562のセッションレコードがない
  • ステイルソケットの再起動 — メッセージがすでに消失した7分後に発生

ユーザー向けの動作

  • トピック562の元のメッセージに返信がない
  • レスポンスメッセージIDはTelegramのデータベースに存在する(APIレスポンスで確認済み)
  • メッセージはターゲットトピックにも Geral トピックにも表示されない
  • メッセージは「送信済み」のように見えるが、実質的に孤立している

🧠 原因

主な障害:リトライ時のスレッドコンテキスト消失

根本原因はリトライパイプラインにおけるコンテキスト伝播の失敗です。AnthropicがHTTP 529を返すと、以下のシーケンスが発生します:

  1. メッセージ受信 — OpenClawがmessage.chat.idmessage.message_thread_id: 562、会話コンテキストを含むTelegramアップデートを受信
  2. API呼び出し開始 — OpenClawが会話コンテキストでAnthropicのmessages APIを呼び出す
  3. 529エラー受信 — AnthropicがHTTP 529: The AI service is temporarily overloadedを返す
  4. リトライトリガー — OpenClawのリトライメカニズム(バックオフ付き)がAPI呼び出しを再試行
  5. コンテキスト破損 — リトライサイクル中、元のTelegramアップデートのmessage_thread_idがsendMessage呼び出しに継承されない

アーキテクチャの問題:セッション状態とインラインコンテキスト

OpenClawは会話コンテキストをセッションストアに保存するセッションベースのアーキテクチャを使用しています。致命的なバグは以下の状況で発生します:

// Simplified flow showing the failure point
async function handleUpdate(update) {
  const threadId = update.message.message_thread_id; // 562 - captured here
  
  // On first attempt, session is created/loaded
  const session = await sessionStore.get(update.chat.id);
  session.threadId = threadId;
  await sessionStore.set(update.chat.id, session);
  
  // ... API call made, 529 received ...
  
  // On retry, session state may be stale or overwritten
  const retrySession = await sessionStore.get(update.chat.id);
  // retrySession.threadId could be undefined, null, or wrong value
  
  // sendMessage called without correct thread_id
  await telegram.sendMessage({
    chat_id: update.chat.id,
    text: response,
    message_thread_id: retrySession.threadId // BUG: undefined!
  });
}

寄与因子

  • リトライ遅延による競合状態 — 529とリトライの間の5分バックオフにより、セッション状態がクリア、破損、または上書きされる可能性がある
  • sendMessageログにthread_idなし — デバッグステートメントがmessage_thread_idを省略し、早期検出を妨げる:
    // Current (broken) log format
    console.log(`sendMessage ok chat=${chatId} message=${messageId}`);
    

    // Missing: message_thread_id=${threadId || ‘undefined’}

  • セッションストアのTTL/有効期限 — リトライウィンドウ中にセッションが期限切れになると、スレッドコンテキストが失われる
  • 並行メッセージ処理 — リトライ中に別のトピックの別のメッセージが到着すると、セッション状態が上書きされる可能性がある

エラーが発生しない理由

Telegramはmessage_thread_idなしでメッセージを受け入れ、"Main Topic"(thread_id: 0)に送信するデフォルト動作になります。しかし、フォーラムグループのMain Topicの動作はクライアントやTelegramのバージョンによって異なり、元メッセージのコンテキストが別のスレッドからのものであった場合、一部のクライアントはこれらのメッセージを完全に非表示にする可能性があります。

🛠️ 解決手順

手順1:sendMessageにスレッドIDが渡されていることを確認

受信メッセージから利用可能な場合はセッション状態からの値をデフォルトとし、sendMessageペイロード常にmessage_thread_idを含めるようにTelegramアダプターを変更します:

// BEFORE (broken implementation)
async sendMessage(chatId, text, options = {}) {
  const payload = {
    chat_id: chatId,
    text: text,
    // message_thread_id not included - defaults to 0/undefined
    ...options
  };
  
  const result = await this.telegram.sendMessage(payload);
  console.log(`sendMessage ok chat=${chatId} message=${result.message_id}`);
  return result;
}

// AFTER (fixed implementation)
async sendMessage(chatId, text, options = {}) {
  const payload = {
    chat_id: chatId,
    text: text,
    parse_mode: 'Markdown',
    ...options
    // message_thread_id MUST be passed explicitly in options
    // No defaulting to undefined - caller is responsible
  };
  
  // Enhanced logging with thread_id
  console.log(`sendMessage ok chat=${chatId} thread=${payload.message_thread_id ?? 'main'} message=${result.message_id}`);
  return result;
}

手順2:リトライサイクルを通じてスレッドIDを保持

受信メッセージからのthread_idがセッション状態に関係なくsendMessage呼び出しに渡されるようにする:

// BEFORE (session-dependent)
async handleMessage(ctx, messageText) {
  const session = await this.getSession(ctx.chat.id);
  const response = await this.callAIWithRetry(messageText, session.context);
  
  // Thread ID from session - may be stale after retry
  await this.telegram.sendMessage(ctx.chat.id, response, {
    message_thread_id: session.threadId
  });
}

// AFTER (incoming message context preserved)
async handleMessage(ctx, messageText) {
  // Capture thread_id from the ACTUAL incoming message, not session
  const originalThreadId = ctx.message.message_thread_id;
  
  const session = await this.getSession(ctx.chat.id);
  const response = await this.callAIWithRetry(messageText, session.context);
  
  // Always use the original message's thread_id
  await this.telegram.sendMessage(ctx.chat.id, response, {
    message_thread_id: originalThreadId
  });
}

手順3:すべての送信操作にスレッドIDを追加

フォーラムコンテキストで動作する場合、すべてのTelegram送信メソッドにthread_idを含めるようにする:

// Helper to build send options with thread context
function buildSendOptions(originalMessage, overrides = {}) {
  const options = { ...overrides };
  
  // Always include thread_id if original message had one
  if (originalMessage.message_thread_id) {
    options.message_thread_id = originalMessage.message_thread_id;
  }
  
  return options;
}

// Usage
const sendOptions = buildSendOptions(ctx.message);
await this.telegram.sendMessage(ctx.chat.id, text, sendOptions);
await this.telegram.editMessageReplyMarkup(ctx.chat.id, messageId, sendOptions);

手順4:リトライログの改善

デバッグ支援のため、各リトライ試行でthread_idをログに記録する:

async callAIWithRetry(message, context, threadId) {
  const maxRetries = 3;
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    console.log(`[retry] attempt=${attempt} thread=${threadId} maxRetries=${maxRetries}`);
    
    try {
      return await this.anthropic.messages.create({
        model: 'claude-3-5-sonnet-20241022',
        max_tokens: 1024,
        messages: [{ role: 'user', content: message }],
        extra_headers: { 'anthropic-dangerous-direct-browser-access': 'true' }
      });
    } catch (error) {
      lastError = error;
      
      if (error.status === 529) {
        console.log(`[retry] received 529 (overloaded) thread=${threadId}`);
        const backoffMs = Math.min(1000 * Math.pow(2, attempt), 30000);
        console.log(`[retry] backing off for ${backoffMs}ms thread=${threadId}`);
        await sleep(backoffMs);
      } else if (error.status === 529) {
        throw error; // Non-retryable error
      }
    }
  }
  
  throw lastError;
}

手順5:セッション状態のロック(上級者向け)

長いリトライサイクル中のセッション状態破損を防ぐ:

// Use optimistic locking for session updates
async updateSession(chatId, updater, threadId) {
  const maxAttempts = 3;
  
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const session = await this.sessionStore.get(chatId);
    const updated = updater(session);
    
    // Preserve thread_id across session updates
    updated.threadId = session.threadId || threadId;
    
    try {
      await this.sessionStore.set(chatId, updated);
      return updated;
    } catch (conflictError) {
      if (attempt === maxAttempts) throw conflictError;
      await sleep(50 * attempt); // Brief backoff
    }
  }
}

🧪 検証

手順1:529シナリオの再現

Anthropic 529エラーをシミュレートしてリトライパスをトリガーする:

# Using curl to simulate the Telegram update webhook
curl -X POST http://localhost:3000/webhook/telegram \
  -H "Content-Type: application/json" \
  -d '{
    "update_id": 123456789,
    "message": {
      "message_id": 100,
      "chat": { "id": -1003885638534, "type": "supergroup" },
      "message_thread_id": 562,
      "text": "Test message for 529 retry scenario"
    }
  }'

手順2:sendMessageログ出力の確認

修正適用後、ログにthreadが含まれていることを確認する:

# Expected log output AFTER fix
2026-03-03T11:24:34.955Z [telegram] sendMessage ok chat=-1003885638534 thread=562 message=13832

# Should NOT see (before fix):
2026-03-03T11:24:34.955Z [telegram] sendMessage ok chat=-1003885638534 message=13832

手順3:メッセージが正しいトピックに表示されることを確認

# Use Telegram's getMessage to verify thread placement
curl "https://api.telegram.org/bot${BOT_TOKEN}/getMessage?chat_id=-1003885638534&message_id=13832"

# Expected response includes:
{
  "ok": true,
  "result": {
    "message_id": 13832,
    "chat": { "id": -1003885638534, "type": "supergroup" },
    "message_thread_id": 562,  // <-- Must match original
    "text": "..."
  }
}

手順4:セッションにスレッドIDが含まれていることを確認

# Check session store for correct thread_id
# (depends on session store implementation)

# If using Redis:
redis-cli GET "session:-1003885638534"
# Should contain: {"threadId": 562, "..."}

# If using file-based:
cat sessions/-1003885638534.json
# Should contain: {"threadId": 562, "..."}

手順5:スレッドコンテキスト保持のユニットテスト

describe('Telegram forum thread context', () => {
  it('should preserve message_thread_id through 529 retry', async () => {
    const ctx = createMockContext({
      chatId: -1003885638534,
      messageId: 100,
      threadId: 562,
      text: 'Test message'
    });
    
    // Mock Anthropic to return 529 twice, then success
    aiClient.messages.create
      .mockRejectedValueOnce({ status: 529, message: 'overloaded' })
      .mockRejectedValueOnce({ status: 529, message: 'overloaded' })
      .mockResolvedValueOnce({ content: [{ type: 'text', text: 'Response' }] });
    
    await handler.handleUpdate(ctx);
    
    // Verify sendMessage was called with correct thread_id
    expect(telegramAdapter.sendMessage).toHaveBeenCalledWith(
      -1003885638534,
      expect.any(String),
      expect.objectContaining({ message_thread_id: 562 })
    );
  });
});

手順6:Telegramテスト環境での統合テスト

# Use Telegram's test environment or a private bot
# Send message in a forum topic, trigger 529 error, verify reply location

# 1. Set BOT_TOKEN to test bot
export BOT_TOKEN="test_bot_token"

# 2. Run openclaw with logging
OPENCLAW_LOG_LEVEL=debug npm start

# 3. Monitor for:
# - sendMessage logs with thread=562
# - Message appears in correct topic
# - No "lost" messages

⚠️ よくある落とし穴

環境固有のトラップ

  • Dockerコンテナの再起動でセッション状態がクリアされる

    OpenClawがDockerで実行され、長いリトライサイクル中にコンテナが再起動すると、セッション状態(thread_idを含む)が失われます。セッションストアをインメモリではなく外部化(Redis)してください。

    # Docker Compose configuration - externalize session storage
    services:
      openclaw:
        image: openclaw:latest
        environment:
          - SESSION_STORE=redis
          - REDIS_URL=redis://redis:6379
      redis:
        image: redis:7-alpine
        volumes:
          - redis-data:/data
    volumes:
      redis-data:
    
  • macOSのファイルディスクリプタ制限

    macOSでファイルベースのセッションを使用する場合、デフォルトのulimitが高負荷時にセッションの書き込み失敗を引き起こす可能性があります:

    # Check current limit
    ulimit -n
    # Increase if below 1024
    ulimit -n 65535
    
  • Windowsでのセッションキーパス区切り文字

    特殊文字を含むチャットID(先頭のハイフン)でのWindowsでのセッションストアファイルパスに問題がある可能性があります:

    # Use encodeURIComponent for chat IDs in file paths
    const sessionPath = path.join(
      sessionDir,
      `${encodeURIComponent(String(chatId))}.json`
    );
    

設定の落とし穴

  • BotFatherでフォーラムサポートを有効にするのを忘れる

    Telegramボ트는フォーラムトピックに対して明示的なgroup_membership権限が必要です:

    # Required BotFather commands:
    # /setprivacy -> Disable (for forum access)
    # /setjoingroup -> Yes
    # /setforums -> Enable (if available)
    
  • セッションTTLとリトライバックオフの不一致

    セッションTTLがリトライバックオフ期間より短い場合、スレッドコンテキストが期限切れになります:

    # Example: 5-minute TTL but 5-minute backoff = guaranteed context loss
    SESSION_TTL=300000  # 5 minutes in ms
    MAX_RETRY_BACKOFF=300000  # Should be less than TTL
    
  • reply_to_message_idmessage_thread_idなしで使用する

    reply_to_message_idを正しく設定しても、message_thread_idを省略するとフォーラムメッセージが失われます:

    # BROKEN: reply without thread context
    {
      chat_id: -1003885638534,
      text: "Reply text",
      reply_to_message_id: 100
      // Missing: message_thread_id: 562
    }
    

    CORRECT: include both

    { chat_id: -1003885638534, text: “Reply text”, reply_to_message_id: 100, message_thread_id: 562 }

コードレベルの落とし穴

  • thread_idを文字列と数値で混在させる

    Telegram APIは両方の型を受け入れますが、型の混合が問題を引き起こします:

    # Telegram API is flexible but some clients expect integer
    const threadId = parseInt(message.message_thread_id, 10);
    # Or ensure consistent type
    const threadId = String(message.message_thread_id);
    
  • 並行ハンドラーでのセッション上書き

    同じチャットに対して複数のメッセージが同時に到着すると、セッションの書き込みが競合する可能性があります:

    // PROBLEMATIC: Read-modify-write without atomicity
    const session = await getSession(chatId);
    session.threadId = threadId;  // Read
    await saveSession(chatId, session);  // Write - another request may overwrite
    

    // FIXED: Use atomic operations or locking await updateSessionAtomic(chatId, (s) => { s.threadId = threadId; return s; });

  • リトライハンドラーでのasync/await競合状態

    コールバック/プロミスチェーンがコンテキストを失う可能性があります:

    // PROBLEMATIC
    function handleMessage(ctx) {
      let threadId = ctx.message.message_thread_id;
    

    retry(3, () => ai.call()).then(response => { // ’this’ and ’threadId’ may be out of scope or stale sendMessage(ctx.chat.id, response, { threadId }); });

    // New message arrives, ’threadId’ is overwritten threadId = newMessage.message_thread_id; }

    // FIXED: Capture context in closure function handleMessage(ctx) { const threadId = ctx.message.message_thread_id; // Capture immediately

    retry(3, () => ai.call()).then(response => { sendMessage(ctx.chat.id, response, { message_thread_id: threadId }); }); }

🔗 関連するエラー

  • HTTP 529: The AI service is temporarily overloaded

    Anthropicのレート制限エラーで、リトライシーケンスをトリガーします。529エラーはこのバグの開始イベントです。

  • stale-socket

    失われたメッセージの7分後に発生したゲートウェイヘルスモニタの再起動です。直接的には関連ありませんが、根底にある接続の不安定さを示しており、リトライの問題を悪化させる可能性があります。

  • thread not found(このケースでは発生せず)
    message_thread_idが実在しないトピックを参照している場合のTelegram APIエラーです。このエラーがないことは、Telegramが有効なスレッドID(またはスレッドIDなし)を受け取ったことを確認しています。

  • 長時間実行中の操作中のセッション期限切れ

    セッションTTLが操作時間より短い場合にコンテキストが失われるGitHubイシューrelated to similar issue where session TTL is shorter than operation duration, causing context loss. Similar root cause to the thread_id loss.

  • スレッドコンテキスト欠落によるWebhook配信失敗

    フォーラムメッセージに対してTelegramウェブホックのアップデートがmessage_thread_idなしで到着し、ルーティング失敗を引き起こす関連するイシューです。

  • context.lengthExceeded Anthropicエラー

    リトライ中に会話コンテキストが大きくなりすぎた場合にAnthropicが返すエラーです。エラー処理が状態を失うと、スレッドコンテキストの問題を複雑にする可能性があります。

  • 並行Telegramアップデートでの競合状態

    同じチャットに対して複数のアップデートが同時に到着すると、セッション状態が上書きされ、thread_idが失われる可能性があります。このバグと同じアーキテクチャの脆弱性です。

  • ボットトークン更新後の間違ったチャットへのメッセージ送信

    ボット設定がmid-operation中に変更されてメッセージがincorrectly routeされる関連するセッション/コンテキスト消失シナリオです。

エビデンスとソース

このトラブルシューティングガイドは、FixClaw Intelligence パイプラインによってコミュニティの議論から自動的に合成されました。