APIを使ったシステムを本番運用していると、ある日突然リクエストが弾かれる――そんな経験はありませんか?原因のほとんどはレートリミット(呼び出し回数・トークン数の上限)への到達です。

しかし実は、APIはレスポンスを返すたびに「残り使用枠」を教えてくれています。その情報は HTTPレスポンスヘッダー に含まれており、PHP の cURL でも簡単に取得できます。この記事では、まずヘッダーを生で確認する方法から始めて、最終的に「使用率90%超えを自動検知するコード」まで順を追って解説します。


1. まず「生のレスポンスヘッダー」を確認してみよう

cURL にはレスポンスヘッダーをボディと一緒に取得するオプションがあります。CURLOPT_HEADER => true を追加するだけです。

まずは Anthropic API を例に、ヘッダーを丸ごと出力してみましょう。

<?php
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode([
        'model'      => 'claude-opus-4-6',
        'max_tokens' => 100,
        'messages'   => [['role' => 'user', 'content' => 'hi']],
    ]),
    CURLOPT_HEADER         => true,   // ← これを追加するだけ
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'x-api-key: ' . $ANTHROPIC_API_KEY,
        'anthropic-version: 2023-06-01',
    ],
]);

$rawResponse = curl_exec($ch);
$headerSize  = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);

// ヘッダー部分とボディ部分を分離
$rawHeaders = substr($rawResponse, 0, $headerSize);
$body       = substr($rawResponse, $headerSize);

// ★ まずヘッダーを丸ごと出力して中身を確認する
echo $rawHeaders;

実行すると、以下のようなヘッダーが出力されます(一部抜粋):

HTTP/2 200
content-type: application/json
anthropic-ratelimit-requests-limit: 500
anthropic-ratelimit-requests-remaining: 487
anthropic-ratelimit-requests-reset: 2025-04-13T10:00:00Z
anthropic-ratelimit-tokens-limit: 40000
anthropic-ratelimit-tokens-remaining: 36200
anthropic-ratelimit-tokens-reset: 2025-04-13T10:00:00Z

ここにレートリミット情報がすべて入っています。同様に DeepSeek の場合は:

HTTP/1.1 200 OK
x-ratelimit-limit-requests: 1000
x-ratelimit-remaining-requests: 950
x-ratelimit-limit-tokens: 100000
x-ratelimit-remaining-tokens: 91000

2. 各APIのレートリミットヘッダー一覧

2つのAPIで使われているヘッダー名を整理します。

Anthropic(Claude)

ヘッダー名 意味
anthropic-ratelimit-requests-limit 期間内のリクエスト最大数
anthropic-ratelimit-requests-remaining 残りリクエスト数
anthropic-ratelimit-requests-reset リセット日時(ISO8601)
anthropic-ratelimit-tokens-limit 期間内のトークン最大数
anthropic-ratelimit-tokens-remaining 残りトークン数
anthropic-ratelimit-tokens-reset リセット日時(ISO8601)

DeepSeek(OpenAI互換形式)

ヘッダー名 意味
x-ratelimit-limit-requests 期間内のリクエスト最大数
x-ratelimit-remaining-requests 残りリクエスト数
x-ratelimit-limit-tokens 期間内のトークン最大数
x-ratelimit-remaining-tokens 残りトークン数

使用率の計算式はどちらも同じです:

使用率 = (limit - remaining) ÷ limit × 100

つまり remaininglimit の10%を切ったら「90%超え」と判断します。


3. ヘッダー文字列をパースする

生のヘッダー文字列は改行区切りのテキストなので、まず「キー => 値」の配列に変換するユーティリティ関数を用意します。

/**
 * 生のHTTPレスポンスヘッダー文字列を
 * ['ヘッダー名(小文字)' => '値'] の配列に変換する
 */
function parseHeaders(string $rawHeaders): array
{
    $headers = [];
    foreach (explode("\r\n", $rawHeaders) as $line) {
        if (strpos($line, ':') !== false) {
            [$key, $value] = explode(':', $line, 2);
            $headers[strtolower(trim($key))] = trim($value);
        }
    }
    return $headers;
}

使い方はシンプルです:

$h = parseHeaders($rawHeaders);

// Anthropic の例
$tokLimit     = (int)($h['anthropic-ratelimit-tokens-limit']     ?? 0);
$tokRemaining = (int)($h['anthropic-ratelimit-tokens-remaining'] ?? 0);

echo "トークン使用率: " . round(($tokLimit - $tokRemaining) / $tokLimit * 100, 1) . "%";
// → トークン使用率: 9.5%

4. 使用率90%超えを検知する関数

「limit」と「remaining」のペアを受け取り、使用率が90%を超えていたら警告メッセージを返す汎用関数です。

/**
 * 使用率が90%以上の項目を警告配列として返す
 *
 * @param string $apiName  API識別名(ログ表示用)
 * @param array  $limits   ['項目名' => [使用済み数, 上限数]] の配列
 * @return array           警告メッセージの配列(90%未満なら空)
 */
function checkUsageRate(string $apiName, array $limits): array
{
    $warnings = [];
    foreach ($limits as $label => [$used, $limit]) {
        if ($limit > 0) {
            $rate = $used / $limit;
            if ($rate >= 0.9) {
                $warnings[] = sprintf(
                    '[%s] %s の使用率が %.1f%% に達しています(%d / %d)',
                    $apiName, $label, $rate * 100, $used, $limit
                );
            }
        }
    }
    return $warnings;
}

呼び出し例:

$warnings = checkUsageRate('Claude', [
    'リクエスト数' => [480, 500],   // 96% → 警告あり
    'トークン数'   => [3000, 40000], // 7.5% → 警告なし
]);

// $warnings = ['[Claude] リクエスト数 の使用率が 96.0% に達しています(480 / 500)']

5. 完成コード(Claude / DeepSeek 両対応)

ここまでの要素を組み合わせた完成版です。既存の API 呼び出し処理に対して、最小限の変更(CURLOPT_HEADER => true の追加とヘッダー処理の追記)で使用率監視を組み込んでいます。

<?php

// ============================================================
// ユーティリティ関数
// ============================================================

/**
 * 生のHTTPレスポンスヘッダー文字列を配列に変換
 */
function parseHeaders(string $rawHeaders): array
{
    $headers = [];
    foreach (explode("\r\n", $rawHeaders) as $line) {
        if (strpos($line, ':') !== false) {
            [$key, $value] = explode(':', $line, 2);
            $headers[strtolower(trim($key))] = trim($value);
        }
    }
    return $headers;
}

/**
 * 使用率90%以上の項目を警告配列として返す
 */
function checkUsageRate(string $apiName, array $limits): array
{
    $warnings = [];
    foreach ($limits as $label => [$used, $limit]) {
        if ($limit > 0) {
            $rate = $used / $limit;
            if ($rate >= 0.9) {
                $warnings[] = sprintf(
                    '[%s] %s の使用率が %.1f%% に達しています(%d / %d)',
                    $apiName, $label, $rate * 100, $used, $limit
                );
            }
        }
    }
    return $warnings;
}

// ============================================================
// API 呼び出し本体
// ============================================================

$usageWarnings = [];  // 90%超え警告を収集する配列

if (!$deepseek) {

    // ----------------------------------------------------------
    // Anthropic(Claude)API 呼び出し
    // ----------------------------------------------------------
    $payload = json_encode([
        'model'      => $modelname,
        'max_tokens' => $maxTokens,
        'system'     => $system,
        'messages'   => $messages,
    ]);

    $ch = curl_init('https://api.anthropic.com/v1/messages');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_HEADER         => true,   // ← ヘッダーも取得
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'x-api-key: ' . $ANTHROPIC_API_KEY,
            'anthropic-version: 2023-06-01',
        ],
    ]);

    $rawResponse = curl_exec($ch);
    $headerSize  = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    curl_close($ch);

    // ヘッダーとボディを分離
    $rawHeaders = substr($rawResponse, 0, $headerSize);
    $response   = substr($rawResponse, $headerSize);

    // レートリミットヘッダーをパースして使用率チェック
    $h = parseHeaders($rawHeaders);

    $reqLimit     = (int)($h['anthropic-ratelimit-requests-limit']     ?? 0);
    $reqRemaining = (int)($h['anthropic-ratelimit-requests-remaining']  ?? 0);
    $tokLimit     = (int)($h['anthropic-ratelimit-tokens-limit']        ?? 0);
    $tokRemaining = (int)($h['anthropic-ratelimit-tokens-remaining']    ?? 0);

    $usageWarnings = checkUsageRate('Claude', [
        'リクエスト数' => [$reqLimit - $reqRemaining, $reqLimit],
        'トークン数'   => [$tokLimit - $tokRemaining, $tokLimit],
    ]);

} else {

    // ----------------------------------------------------------
    // DeepSeek API 呼び出し
    // ----------------------------------------------------------
    $deepseekMessages   = [];
    $deepseekMessages[] = ['role' => 'system', 'content' => $system];

    foreach ($messages as $msg) {
        $deepseekMessages[] = [
            'role'    => $msg['role'] === 'assistant' ? 'assistant' : 'user',
            'content' => $msg['content'],
        ];
    }

    $payload = json_encode([
        'model'           => $modelname,
        'max_tokens'      => $maxTokens,
        'messages'        => $deepseekMessages,
        'response_format' => ['type' => 'json_object'],
        'temperature'     => 0.7,
    ]);

    $ch = curl_init('https://api.deepseek.com/v1/chat/completions');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_HEADER         => true,   // ← ヘッダーも取得
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $DEEPSEEK_API_KEY,
        ],
    ]);

    $rawResponse = curl_exec($ch);
    $headerSize  = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $httpCode    = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    // ヘッダーとボディを分離
    $rawHeaders = substr($rawResponse, 0, $headerSize);
    $response   = substr($rawResponse, $headerSize);

    // レートリミットヘッダーをパースして使用率チェック
    $h = parseHeaders($rawHeaders);

    $reqLimit     = (int)($h['x-ratelimit-limit-requests']     ?? 0);
    $reqRemaining = (int)($h['x-ratelimit-remaining-requests'] ?? 0);
    $tokLimit     = (int)($h['x-ratelimit-limit-tokens']       ?? 0);
    $tokRemaining = (int)($h['x-ratelimit-remaining-tokens']   ?? 0);

    $usageWarnings = checkUsageRate('DeepSeek', [
        'リクエスト数' => [$reqLimit - $reqRemaining, $reqLimit],
        'トークン数'   => [$tokLimit - $tokRemaining, $tokLimit],
    ]);

    // DeepSeek レスポンスを Anthropic 互換形式に変換
    if ($httpCode === 200) {
        $deepseekResponse  = json_decode($response, true);
        $convertedResponse = [
            'id'      => $deepseekResponse['id'],
            'type'    => 'message',
            'role'    => 'assistant',
            'content' => [[
                'type' => 'text',
                'text' => $deepseekResponse['choices'][0]['message']['content'],
            ]],
            'model'   => $deepseekResponse['model'],
        ];
        $response = json_encode($convertedResponse);
    } else {
        http_response_code($httpCode);
        $response = json_encode([
            'error'    => 'Unexpected DeepSeek response format',
            'response' => $response,
        ]);
    }
}

// ============================================================
// 使用率90%超えの処理
// ============================================================

if (!empty($usageWarnings)) {

    // ① サーバーのエラーログに記録
    foreach ($usageWarnings as $warn) {
        error_log('[API_RATE_WARNING] ' . $warn);
    }

    // ② レスポンスJSONに警告フィールドを追加(呼び出し元で参照可能)
    $decoded = json_decode($response, true);
    if (is_array($decoded)) {
        $decoded['_rate_limit_warnings'] = $usageWarnings;
        $response = json_encode($decoded);
    }
}

6. 警告を受け取った後の活用例

警告はレスポンスの _rate_limit_warnings キーに配列として入っています。フロント側や呼び出し元でこれを拾って活用できます。

// API レスポンスを受け取った後の処理例
$result = json_decode($response, true);

if (!empty($result['_rate_limit_warnings'])) {
    foreach ($result['_rate_limit_warnings'] as $warn) {
        // 管理者にメール通知する
        mail('admin@example.com', '[警告] API使用率90%超え', $warn);

        // Slack Webhook に投げる
        // notifySlack($warn);
    }
}

また、サーバーログ(error_log)にも出力されるため、ログ監視ツールと組み合わせてアラートを飛ばすことも簡単です。


まとめ

今回のポイントを整理します。

  • cURL の CURLOPT_HEADER => true を追加するだけで、レスポンスヘッダーを丸ごと取得できる
  • Anthropicanthropic-ratelimit-* 系のヘッダーで使用枠を通知してくれる
  • DeepSeek は OpenAI 互換の x-ratelimit-* 形式で通知してくれる
  • 「使用済み = limit − remaining」で計算し、使用済み ÷ limit ≧ 0.9 なら90%超えと判断できる
  • 警告はログ出力・レスポンスへの付加・通知送信など、用途に合わせて自由に拡張できる

APIの突然の遮断を防ぐために、ぜひ本番環境でも使用率の監視を取り入れてみてください。