0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

外部API連携MCPサーバーのエラーハンドリング設計 — リトライ・タイムアウト・レート制限を実装で押さえる

0
Posted at

はじめに:MCPサーバーの「失敗」はAIの判断を狂わせる

僕は合同会社ジョインクラスで、Claude Codeを中心に会社の業務を自動化しています。朝のダイジェスト生成、Zennへのデプロイ、外部LLM APIへの問い合わせなど、17本以上のlaunchdジョブが毎日動いています。その多くが外部APIを叩きます。

外部APIと話す層をMCPサーバーにまとめると、Claude Codeから「ツール」として自然に呼び出せて便利です。ただし、ここのエラーハンドリングが雑だと、問題はAPIの失敗だけでは終わりません。

MCPサーバーがタイムアウトやレート制限を「ただのエラー文字列」でAIに返すと、AIはそれを材料に次の判断をします。「失敗した。じゃあもう一度」を5回繰り返して429を悪化させたり、「空の結果だった」と誤解して存在しないデータで報告を書いたりします。実際に当社では、Zennのレートリミット(1日1コンテンツ)に何度も引っかかり、記事と書籍を同じ日に公開しようとしてデプロイを失敗させました。原因はAPIではなく、「制限を知らない呼び出し側」でした。

この記事では、自社で実際に動いている実装をベースに、外部API連携MCPサーバーで最低限押さえるべき3点を書きます。

  1. タイムアウト:処理をハングさせない
  2. リトライ:失敗を分類し、再試行して良いものだけ再試行する
  3. レート制限:サーバー側で呼び出し間隔とクォータを守る

本文はコード中心です。経営の話は結びに回します。

前提:失敗を3種類に分ける

設計の起点は「エラーをどう扱うか」より「エラーをどう分類するか」です。当社では外部APIの失敗を3種類に分けています。

分類 例 対応
一時的(transient) 429, 502, 503, 504, ネットワーク切断, タイムアウト 待ってリトライ
永続的(permanent) 400, 401, 403, 404, 422 即座に失敗を返す。リトライしない
不明 500, パース失敗 1回だけリトライして諦める

このテーブルをコード上の判定関数にして、リトライロジックから分離するのがポイントです。「とりあえず3回」は分類をサボった結果で、401を3回叩いて認証失敗をログに3行書くだけになります。

実装1:まず動いているシェル版(反面教師)

当社のスクリプト群が共通で読み込む common.sh には、こんなリトライ関数があります。

# .company/scripts/common.sh(実物)
retry() {
  local max_attempts=3
  local attempt=1
  local cmd="$@"

  while [ $attempt -le $max_attempts ]; do
    if eval "$cmd"; then
      return 0
    fi
    log_info "リトライ $attempt/$max_attempts: $cmd"
    attempt=$((attempt + 1))
    sleep 2
  done

  log_error "3回失敗: $cmd"
  return 1
}

api_post() {
  local endpoint="$1"
  local data="${2:-{}}"
  curl -sf -X POST \
    -H "Content-Type: application/json" \
    -d "$data" \
    "$DASHBOARD_URL/api/$endpoint" 2>/dev/null
}

社内ダッシュボードへのPOSTなら、これで十分です。相手が自分のサーバーだからです。

しかし外部APIに同じものを使うと問題が出ます。

  • 固定2秒待機なので、429の原因(過剰な連打)を解消できない
  • 終了コードだけ見ているので、401も503も同じ扱いになる
  • curl -sf にタイムアウトがないので、相手が無応答だと永久に待つ

MCPサーバーに移植するとき、この3点を直しました。

実装2:TypeScriptで書くMCPツールのエラーハンドリング層

当社のMCPサーバーは @modelcontextprotocol/sdk を使ったTypeScript実装です。外部API呼び出しは必ず以下の callExternal を通します。

// src/lib/external-call.ts
type Classification = "transient" | "permanent" | "unknown";

export class ExternalApiError extends Error {
  constructor(
    message: string,
    public readonly status: number | null,
    public readonly classification: Classification,
    public readonly retryAfterMs?: number,
  ) {
    super(message);
  }
}

function classify(status: number | null, err?: unknown): Classification {
  if (status === null) {
    // ネットワーク断、AbortError(タイムアウト)はここ
    return "transient";
  }
  if ([429, 502, 503, 504].includes(status)) return "transient";
  if (status >= 400 && status < 500) return "permanent";
  if (status === 500) return "unknown";
  return "permanent";
}

function parseRetryAfter(header: string | null): number | undefined {
  if (!header) return undefined;
  const seconds = Number(header);
  if (!Number.isNaN(seconds)) return seconds * 1000;
  const date = Date.parse(header);
  return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
}

export async function fetchWithTimeout(
  url: string,
  init: RequestInit,
  timeoutMs: number,
): Promise<Response> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...init, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

interface CallOptions {
  timeoutMs?: number;
  maxAttempts?: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
}

export async function callExternal<T>(
  url: string,
  init: RequestInit,
  parse: (res: Response) => Promise<T>,
  opts: CallOptions = {},
): Promise<T> {
  const {
    timeoutMs = 15_000,
    maxAttempts = 4,
    baseDelayMs = 500,
    maxDelayMs = 20_000,
  } = opts;

  let lastError: ExternalApiError | null = null;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    let res: Response;
    try {
      res = await fetchWithTimeout(url, init, timeoutMs);
    } catch (err) {
      lastError = new ExternalApiError(
        err instanceof Error ? err.message : String(err),
        null,
        "transient",
      );
      await sleep(backoff(attempt, baseDelayMs, maxDelayMs));
      continue;
    }

    if (res.ok) return parse(res);

    const classification = classify(res.status);
    const retryAfterMs = parseRetryAfter(res.headers.get("retry-after"));
    lastError = new ExternalApiError(
      `${res.status} ${res.statusText} from ${url}`,
      res.status,
      classification,
      retryAfterMs,
    );

    if (classification === "permanent") throw lastError;
    if (classification === "unknown" && attempt >= 2) throw lastError;

    // Retry-After があれば優先。無ければ指数バックオフ + ジッター
    await sleep(retryAfterMs ?? backoff(attempt, baseDelayMs, maxDelayMs));
  }

  throw lastError ?? new ExternalApiError("unreachable", null, "unknown");
}

function backoff(attempt: number, base: number, max: number): number {
  const exp = Math.min(max, base * 2 ** (attempt - 1));
  const jitter = Math.random() * exp * 0.3;
  return exp + jitter;
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

ポイントを3つだけ補足します。

タイムアウトは AbortController で必ず付ける。 fetch はデフォルトでは無限に待ちます。MCPサーバーがハングすると、Claude Code側のツール呼び出しもハングし、ユーザーには「考え中」が延々と表示されます。当社では外部LLMへの問い合わせスクリプトで timeout: 300_000 を明示しています。相手がLLMなら5分、普通のREST APIなら15秒が目安です。

Retry-Afterを読む。 429を返すAPIの多くは Retry-After ヘッダーで待機秒数を教えてくれます。これを無視して独自バックオフをするのは、相手の指示を無視して連打しているのと同じです。

ジッターを入れる。 launchdで複数ジョブが同時刻に起動すると、同じAPIに同じタイミングで再試行が集中します。30%のランダム幅を足すだけで衝突がほぼ消えます。

実装3:MCPツールとしての公開とAIへのエラー返却

callExternal をMCPツールから使う側です。ここで重要なのは、エラーをどうAIに返すかです。

// src/tools/fetch-stats.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { callExternal, ExternalApiError } from "../lib/external-call.js";

export function registerFetchStats(server: McpServer) {
  server.tool(
    "fetch_stats",
    "外部サービスの統計を取得する",
    { resourceId: z.string() },
    async ({ resourceId }) => {
      try {
        const data = await callExternal(
          `https://api.example.com/stats/${resourceId}`,
          { headers: { Authorization: `Bearer ${process.env.API_TOKEN}` } },
          (res) => res.json(),
          { timeoutMs: 10_000, maxAttempts: 4 },
        );
        return { content: [{ type: "text", text: JSON.stringify(data) }] };
      } catch (err) {
        if (err instanceof ExternalApiError) {
          return {
            isError: true,
            content: [{
              type: "text",
              text: JSON.stringify({
                error: err.message,
                classification: err.classification,
                retryable: err.classification === "transient",
                retryAfterMs: err.retryAfterMs ?? null,
                hint: err.classification === "permanent"
                  ? "再試行しないでください。パラメータか認証情報を確認してください。"
                  : "一時的な障害です。このツールは既にリトライ済みです。再試行は不要です。",
              }),
            }],
          };
        }
        throw err;
      }
    },
  );
}

isError: true を返しつつ、本文に 構造化したJSON と AIへの指示(hint) を含めます。これをやらないと、Claudeは「失敗した」という文字列を見て自己判断でツールを再度呼びます。サーバー側で既に4回リトライしているのに、AI側がさらに3回繰り返すと、合計12回のリクエストが飛びます。「このツールは既にリトライ済みです」と明示するだけで、この無駄が消えます。

実装4:サーバー側のレート制限(トークンバケット + 日次クォータ)

リトライで守れるのは「相手が怒ったとき」です。もう一段手前で、そもそも怒らせない仕組みが要ります。

Zennの1日1コンテンツ制限に何度も引っかかった当社の対策は、ロックファイルによる日次クォータでした。それをMCPサーバーに汎用化したのがこれです。

// src/lib/rate-limiter.ts
import { readFileSync, writeFileSync, existsSync } from "node:fs";

/** 短期:トークンバケット(秒間・分間の呼び出し間隔を守る) */
export class TokenBucket {
  private tokens: number;
  private lastRefill = Date.now();

  constructor(
    private readonly capacity: number,
    private readonly refillPerSec: number,
  ) {
    this.tokens = capacity;
  }

  async take(): Promise<void> {
    for (;;) {
      this.refill();
      if (this.tokens >= 1) {
        this.tokens -= 1;
        return;
      }
      const waitMs = ((1 - this.tokens) / this.refillPerSec) * 1000;
      await new Promise((r) => setTimeout(r, waitMs));
    }
  }

  private refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec);
    this.lastRefill = now;
  }
}

/** 長期:日次クォータ(プロセスを跨いでファイルで永続化) */
export class DailyQuota {
  constructor(
    private readonly file: string,
    private readonly limitPerDay: number,
  ) {}

  private today() {
    return new Date().toISOString().slice(0, 10);
  }

  private load(): { date: string; count: number } {
    if (!existsSync(this.file)) return { date: this.today(), count: 0 };
    const data = JSON.parse(readFileSync(this.file, "utf8"));
    return data.date === this.today() ? data : { date: this.today(), count: 0 };
  }

  remaining(): number {
    return Math.max(0, this.limitPerDay - this.load().count);
  }

  consume(): void {
    const state = this.load();
    if (state.count >= this.limitPerDay) {
      throw new Error(
        `日次クォータ超過 (${this.limitPerDay}/日)。明日 00:00 UTC にリセットされます。`,
      );
    }
    writeFileSync(this.file, JSON.stringify({ date: state.date, count: state.count + 1 }));
  }
}

使い方はツール内で callExternal の直前に挟むだけです。

const bucket = new TokenBucket(5, 1);          // バースト5、秒間1回まで
const quota = new DailyQuota("/tmp/zenn-publish-quota.json", 1); // 1日1回

server.tool("publish_content", "...", { slug: z.string() }, async ({ slug }) => {
  if (quota.remaining() === 0) {
    return {
      isError: true,
      content: [{ type: "text", text: "本日の公開枠(1件)は消費済みです。明日に回してください。" }],
    };
  }
  await bucket.take();
  const result = await callExternal(/* ... */);
  quota.consume();   // 成功後にカウント。失敗分で枠を消費しない
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
});

クォータをファイルで持つ理由は、MCPサーバーのプロセスが使い捨てだからです。Claude Codeはセッションごとにサーバーを起動するので、メモリ上のカウンタではセッションを跨いだ制限を守れません。当社ではlaunchdの別ジョブからも同じAPIを叩くため、ファイルで共有しています。

成功後にカウントするのも重要です。失敗したリクエストで1日1回の枠を消費すると、その日は何もできなくなります。

検証:手元で壊して確かめる

書いたら必ず壊して試します。3つの検証をローカルで回せます。

# 1. タイムアウト検証:10秒遅延するエンドポイントに5秒タイムアウトで当てる
#    → AbortError が transient に分類され、4回試行後に失敗することを確認
API_URL=https://httpbin.org/delay/10 TIMEOUT_MS=5000 npm run dev:probe

# 2. レート制限検証:429 を返させて Retry-After が尊重されることを確認
API_URL=https://httpbin.org/status/429 npm run dev:probe
# ログに "waiting 1000ms (Retry-After)" のような行が出ればOK

# 3. 永続エラー検証:401 で即座に失敗し、リトライが走らないことを確認
API_URL=https://httpbin.org/status/401 npm run dev:probe
# 試行回数が 1 であることを確認

MCPサーバーとして動かした状態で検証するなら、MCP Inspectorが便利です。

npx @modelcontextprotocol/inspector node dist/index.js

ブラウザでツールを直接呼び出し、isError: true のレスポンスにhintが含まれているかを目で確認します。Claude Codeに繋ぐ前に、ここで構造化エラーの形を固めておくと手戻りが減ります。

落とし穴まとめ

当社で実際に踏んだものを中心に挙げます。

  • 固定スリープのリトライ:429を悪化させる。指数バックオフ + ジッター + Retry-After尊重に変える
  • 全エラーをリトライ:401や404を3回叩くだけ。分類関数を先に書く
  • タイムアウト未設定:MCPサーバーがハングし、Claude Codeごと止まる。AbortController を必ず付ける
  • AIに生のエラー文字列を返す:AIが独自判断で再試行する。retryable と hint を構造化して返す
  • メモリ上のクォータ:プロセス再起動で消える。ファイルかKVに永続化する
  • 失敗分でクォータ消費:1日1回の枠が失敗で潰れる。成功後にカウントする
  • 同時起動ジョブの衝突:launchd/cronで同時刻に動く複数ジョブが同じAPIを叩く。ジッターと共有クォータで解消する

結び:エラーハンドリングは「AIに任せる範囲」の設計

MCPサーバーのエラーハンドリングは、技術的にはありふれた話です。ただ、呼び出し側がAIである点が従来と違います。人間のプログラマーなら「429が返ったから少し待とう」と考えますが、AIは返ってきた文字列をそのまま次の推論の材料にします。サーバー側で「何が起きて、再試行すべきかどうか」を構造化して返すことは、AIの判断範囲を設計することと同じです。

当社では、この層を固めてから外部API連携の自動化ジョブの失敗通知が目に見えて減りました。Zennのレートリミット事故も、クォータをサーバー側に持たせてからは起きていません。「AIに何でもやらせる」のではなく、「AIが間違えようのない形で情報を返す」のが、自動化を安定させる近道だと考えています。

書籍紹介

MCPサーバー開発の全体像、ツール設計、Claude Codeとの接続手順を体系的にまとめた書籍を出しています。本記事で触れたエラーハンドリング層の設計も、より詳しく扱っています。

『Claude Code × MCP サーバー開発入門』
https://zenn.dev/joinclass?tab=books

MCP以外にも、Hooks・cronによる自動化全般を扱った『Claude Code 全自動化バイブル』、設定とコスト管理をまとめた『CLAUDE.md設計パターン』など、Claude Code実務シリーズを公開しています。書籍一覧は上記URLからご覧ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?