Claude Media
Claude SDKのミドルウェアで共通処理を挟む — 登録順序とHTTPクライアント差し替え

Claude SDKのミドルウェアで共通処理を挟む — 登録順序とHTTPクライアント差し替え

Anthropic公式SDKのミドルウェアで横断処理を挟む方法。登録順序の実行結果、HTTPクライアントの差し替え、組み込みrefusal-fallbackの挙動まで扱います。

Claude SDKのミドルウェアで何ができるか

Anthropic公式SDK(Python、TypeScript、Go、Java、C#、PHP、Ruby)には、ミドルウェア(インターセプター)機構があります。リクエストを送る前とレスポンスを受け取った後に、コードを挟める仕組みです。ログ出力、独自のリトライ、リクエストへの注釈付け、拒否応答時のフォールバック処理といった横断処理を、API呼び出しのたびに書かずに1か所へ集約できます。

各ミドルウェアは、リクエストを受け取り、next() を呼んで次の処理へ渡し、返ってきたレスポンスを加工して返す関数です。next() の前に書いたコードは往路で、後に書いたコードは復路で実行されます。呼び出し元のコード → ミドルウェアA → ミドルウェアB → SDKコア → Claude APIの順に往路が進み、応答は逆順で戻ります。リクエストごとのspanを観測基盤へ送りたい場合は、SDKのOpenTelemetryトレースが別の手段になります。

Pythonでの最小構成は次のとおりです。

def logging_middleware(request, call_next):
    # 往路: リクエストを送る前
    print(f"-> {request.method} {request.url}")
 
    response = call_next(request)
 
    # 復路: レスポンスを受け取った後
    print(f"<- {response.status_code}")
    return response
 
client = Anthropic(middleware=[logging_middleware])

TypeScriptでは next が非同期になる点だけ異なります。

const loggingMiddleware: Middleware = async (request, next, ctx) => {
  ctx.logger.debug("->", request.method, request.url);
  const response = await next(request);
  ctx.logger.debug("<-", response.status, request.url);
  return response;
};
 
const client = new Anthropic({ middleware: [loggingMiddleware] });

Go・Java・C#・PHP・Rubyにも同じ形のフックがあります。next の呼び名は言語ごとに違い、Goは next(req)、Javaは nextClient.execute(request, requestOptions)、PHPは $next($request)、Rubyは call_next.call(request) です。発想は共通で、独自ヘッダーの付与、応答までの時間計測、社内トレーシング基盤へのスパン送出など、呼び出し側のビジネスロジックと切り離したい処理に向いています。

複数のミドルウェアは先頭が最も外側になる

配列で複数渡すと、登録順の先頭が最も外側になります。先頭の往路コードが最初に走り、復路コードが最後に走る入れ子構造です。クライアント生成時に渡したミドルウェアは、リクエストごとのオプションで渡したものより外側で実行されます。

ログ取得とリトライを別々のミドルウェアにした場合、並べ方で観測できる範囲が変わります。モックのHTTP層で試すと違いがはっきり出ます。最初のリクエストに529(overloaded)を返し、2回目に200を返す構成です。

import httpx2 as httpx   # Python SDK 1.11.0 は httpx ではなく httpx2 を使う
from anthropic import Anthropic
 
calls = {"n": 0}
def handler(request):                      # 本物のAPIには出ないモック
    calls["n"] += 1
    print("  http: sent", request.headers.get("x-trace"))
    if calls["n"] == 1:
        return httpx.Response(529, json={"type": "error", "error": {
            "type": "overloaded_error", "message": "Overloaded"}})
    return httpx.Response(200, json={
        "id": "msg_1", "type": "message", "role": "assistant",
        "model": "claude-opus-4-8", "content": [{"type": "text", "text": "hi"}],
        "stop_reason": "end_turn", "stop_sequence": None,
        "usage": {"input_tokens": 1, "output_tokens": 1}})
 
def logging_middleware(request, call_next):
    print("log  -> out")
    request.headers["x-trace"] = "added-by-middleware"
    r = call_next(request)
    print("log  <- back", r.status_code)
    return r
 
def retry_middleware(request, call_next):
    print("retry-> out")
    for i in range(3):
        r = call_next(request)
        print(f"retry   attempt {i + 1}: {r.status_code}")
        if r.status_code < 500:
            break
    print("retry<- back")
    return r
 
client = Anthropic(
    api_key="dummy", max_retries=0,        # SDK組み込みのリトライは切る
    middleware=[logging_middleware, retry_middleware],   # 並べ替えて比べる
    http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
client.messages.create(model="claude-opus-4-8", max_tokens=16,
                       messages=[{"role": "user", "content": "x"}])
くらべる

並べ替えたときの実行結果(Python SDK 1.11.0)

[logging, retry]

logging を外側

log  -> out
retry-> out
  http: sent added-by-middleware
retry   attempt 1: 529
  http: sent added-by-middleware
retry   attempt 2: 200
retry<- back
log  <- back 200

ログは往路1行・復路1行だけです。529は内側のリトライが吸収し、外からは見えません。

[retry, logging]

retry を外側

retry-> out
log  -> out
  http: sent added-by-middleware
log  <- back 529
retry   attempt 1: 529
log  -> out
  http: sent added-by-middleware
log  <- back 200
retry   attempt 2: 200
retry<- back

ログは試行のたびに出るので、529で失敗した1回目も記録されます。

どちらの並びでも、HTTP層に届いたリクエストは2件です。違うのは、ログに何が残るかだけです。再試行の回数まで記録したいならretryを外側に、最終結果だけでよければloggingを外側に置きます。トークンコストまで観測したい場合は、Prompt Cachingの仕組みを踏まえてキャッシュ命中率もログに残します。retryを外側にすると、試行ごとの値が並びます。

ミドルウェアで足したヘッダーはHTTPクライアントまで届く

ミドルウェアとは別に、各SDKはHTTPクライアント自体の差し替えを受け付けます。社内プロキシの経由、独自のTLS証明書、コネクションプーリングの調整は、ミドルウェアではなくこちらの役目です。使えるHTTPクライアントは1つのSDKクライアントにつき1つで、指定すると既定のものを置き換えます。

カスタムHTTPクライアントはすべてのミドルウェアを通過した後のリクエストを受け取ります。実行順は「ミドルウェアチェーン → カスタムHTTPクライアント → ネットワーク送信」です。前節の出力に http: sent added-by-middleware と出ているとおり、ミドルウェアが付けた x-trace ヘッダーはそのままHTTP層まで届きます。

実装上のつまずきも1つあります。Python SDK 1.11.0は内部で httpx ではなく httpx2 を使っていて、httpx.Client を http_client に渡すと次のエラーで止まります。

TypeError: Invalid `http_client` argument; `httpx.Client` is from the `httpx` package,
but this SDK uses `httpx2`. Use `httpx2.Client` instead.

既存コードのプロキシ設定を移すときは、渡すクライアントの型がSDKのバージョンに合っているかを先に見ておくと迷いません。役割の分け方は次のとおりです。アプリケーションの意味に近い処理はミドルウェア、通信の土台はHTTPクライアントです。外部通信がプロキシ経由に限られる環境では、HTTPクライアントの差し替えから考えます。

組み込みミドルウェア: refusal-fallbackで拒否応答を別モデルへ再送する

各SDKには、拒否応答(stop_reason: "refusal")を検知して別モデルへ自動で再送する refusal-fallback ミドルウェアが入っています。Claude Fable 5.1・Claude Opus 5.5・Claude Sonnet 5.5など、安全分類器を持つモデルがリクエストを拒否しても、返ってくるのはHTTP 200の通常のレスポンスです。HTTPエラーでは検知できないので、stop_reason を見て同じリクエストをフォールバックモデルへ送り直す役目をミドルウェアが担います。

from anthropic import Anthropic, BetaFallbackState, BetaRefusalFallbackMiddleware
 
client = Anthropic(
    middleware=[BetaRefusalFallbackMiddleware([{"model": "claude-opus-4-8"}])],
)
 
state = BetaFallbackState()  # 以降の会話をフォールバック先に固定する
 
with state:
    message = client.beta.messages.create(
        max_tokens=1024,
        model="claude-fable-5",
        messages=[{"role": "user", "content": "Hello, Claude"}],
    )
print(f"served by: {message.model}")

状態を共有すると、2ターン目は拒否を経由しない

BetaFallbackState の効果は、HTTP層を見ると分かります。Fable 5へのリクエストには拒否を返し、Opus 4.8へのリクエストには正常に応答するモックで、同じ会話の2ターンを送りました。anthropic-beta ヘッダーも並べています。

# 状態を共有した場合
turn 1: served by claude-opus-4-8, stop_reason=end_turn
turn 2: served by claude-opus-4-8, stop_reason=end_turn
('/v1/messages', 'claude-fable-5', 'fallback-credit-2026-07-01')
('/v1/messages', 'claude-opus-4-8', 'fallback-credit-2026-07-01')
('/v1/messages', 'claude-opus-4-8', 'fallback-credit-2026-07-01')
数字

2ターンの会話で送られたリクエスト数

  • 状態を共有

    3件

    1ターン目は拒否+再送、2ターン目は直接Opus 4.8へ

  • 毎ターン作り直し

    4件

    2ターン目もFable 5へ送って再び拒否される

  • 状態なし

    警告ログ

    再送は動くが、SDKが状態の共有を促す警告を出す

Fable 5が拒否し、Opus 4.8が応答するモックでの実測

状態なしで実行すると、SDKは次の警告を出します。

anthropic-sdk: BetaRefusalFallbackMiddleware fell back without an active BetaFallbackState;
follow-up requests will retry models that already refused. Run them inside a shared
`with BetaFallbackState():` block to pin them to the accepted model.

3件とも anthropic-beta: fallback-credit-2026-07-01 が付いているのは、ミドルウェアが自動で足すヘッダーだからです。

同じモデルを入れても、救われない

フォールバックリストは順に試され、途中のモデルが拒否すれば次へ進みます。全部が拒否したときは例外にならず、最後のモデルの拒否レスポンスが返ります。リストに拒否された本人と同じモデルだけを入れると、2件のリクエストの末に次の結果になりました。

all refuse: ['claude-fable-5', 'claude-fable-5'] refusal

拒否されたリクエストを同じモデルへ送り直しても、多くの場合また拒否されます。リストには別のモデルを入れます。

効く範囲は client.beta.messages だけ

公式ドキュメントでは、ミドルウェアが効くのは client.beta.messages 経由の呼び出しです(C#とGoは client.Beta.Messages、Javaは client.beta().messages()、PHPは $client->beta->messages)。同じクライアントで client.messages.create を呼ぶとどうなるか、同じモックで試しました。

served by claude-fable-5, stop_reason=refusal
[('/v1/messages', 'claude-fable-5', None)]

再送は起きず、anthropic-beta ヘッダーも付かず、拒否がそのまま返ります。既存コードが非ベータのメソッドで呼んでいるなら、ミドルウェアを入れただけでは保護されません。

くらべる

サーバー側fallbackとSDKミドルウェア

リクエストに指定

サーバー側 fallbacks

Claude APIでベータとして提供されています。Message Batches APIでは使えず、Amazon Bedrock・Google Cloud・Microsoft Foundryでも使えません。1回のAPI呼び出しの中で再送され、返ってくる応答は1つです。

クライアントに指定

SDKミドルウェア

Anthropic SDKを使っていれば、どのプラットフォームでも使えます。クライアントに1度設定すれば、再送は自動で動きます。検知と再送はクライアント側で起きるので、通信は2回に分かれます。

ストリーミングと請求

ストリーミングでも使い方は同じです。ミドルウェアが拒否を検知すると、フォールバックモデルのイベントを開いたままのストリームへ継ぎ足します。呼び出し側は、ストリームが最後まで流れるものとして書けます。

次の4点は、サーバー側 fallbacks の説明にある挙動と、API共通の扱いです。

  • ツール呼び出しのブロックが開いたまま拒否されたストリームは再送されず、拒否がそのまま返ります。fallback-credit-2026-07-01 ヘッダーがあれば、部分応答の続きから再開できるクレジットトークンは付きます
  • 一度フォールバックした会話は、約1時間のあいだ組織単位で記憶されます。fallbacks を付けた後続リクエストは、フォールバック先へ直接送られます。ベストエフォートなので、元のモデルへ再び送られることはありえます
  • ストリーミングでない呼び出しの途中で拒否されたときは、拒否側の部分出力は返りません。フォールバックモデルが最初から答えます
  • Fable 5.1のthinkingブロックは、そのモデルか新しいモデルだけが読めます。フォールバック先のモデルには読めないため、APIが捨てます。会話履歴を自分で再送する実装では、この点が効きます

ミドルウェア側で拒否済みのモデルを覚えるのは BetaFallbackState です。サーバー側の記憶とは別の仕組みです。

請求の面では、拒否の扱いがカテゴリで分かれます。出力が出る前の拒否は、stop_details.category が bio・frontier_llm・reasoning_extraction のときだけ課金対象です。ストリームの途中で起きた拒否は、入力トークンと流れた分の出力が通常料金で課金されます。フォールバックで救われた会話では、課金対象の拒否はそのまま課金され、加えて再送側のキャッシュミス分をフォールバッククレジットが補います。

ミドルウェアを使うときによくあるつまずき

  • サブエージェントの呼び出しにフォールバックが伝わらない。サーバー側の fallbacks は、ツール実行の内部から発行するモデル呼び出しへ引き継がれません。ミドルウェアはクライアントに載るので、サブエージェントが別のクライアントを作る構成なら、そちらにも登録します
  • リトライの予算をセッション単位にしてしまう。1ターンの中でエージェントとサブエージェントの両方が拒否されることがあります。リトライ回数はリクエスト単位で管理します
  • 共有フラグでON/OFFを切り替える。グローバル変数やキャッシュ済みの設定値で有効・無効を管理すると、更新漏れで気づかないうちに無効になります。有効かどうか確かめられないなら、ミドルウェアを明示的に構成し直します
  • リトライハンドラにだけフォールバックを入れて満足する。エラー復旧の分岐やバックグラウンドワーカーなど、リクエストを再発行するすべての経路に適用しないと、保護が最も必要な経路だけ素通りします
  • 拒否をエラー監視の指標に含めない。拒否はHTTP 200で返るので、エラー率や5xxを見張る監視には映りません。拒否そのものと、フォールバックで救えた件数を別々に数え、差を監視します。判定には content や stop_details の中身ではなく、stop_reason が "refusal" かどうかを使います。category や explanation は null のことがあるためです

サーバー側 fallbacks とミドルウェアは同じ役目を持ちます。同じリクエストでは、どちらか一方だけを設定します。両方を使いたい場合は、ミドルウェアを入れていない別のクライアントから fallbacks を呼び出します。なお、Claude CodeのfallbackModel設定はこのSDKミドルウェアとは別の仕組みです(Claude CodeのfallbackModelを参照)。

まとめ

ミドルウェアは、ログ・リトライ・注釈・拒否対応のような横断処理を1か所に集める仕組みです。拒否への備えは client.beta.messages を使っているか、BetaFallbackState を会話で共有しているかの2点を先に見ます。Anthropic APIの実装を広く見渡したい場合はAnthropic API完全ガイドを参照してください。

この記事を共有:XはてブLinkedIn