定期実行のログに、いつもなら数十秒で終わる工程が「開始」とだけ書かれて、その先が何も無い。再実行すると普通に通る。最初は一過性の不調かと流していたのですが、週に一、二度の頻度で同じ箇所に当たるようになり、無視できなくなりました。原因は、リモートのMCPサーバーへ投げたツール呼び出しが応答を返さず、こちらがそれを待ち続けていたことでした。
対話的に使っているなら、固まったと感じた時点で人が割り込めます。けれど無人の定期実行では割り込む人がいません。応答待ちのまま実行枠の時間を食い潰し、後続の工程に一切たどり着かないまま終わる。失敗としてすら記録されないのが、いちばん厄介でした。
6月25日の更新で、リモートMCPツール呼び出しが無応答のまま延々と待ち続ける挙動が修正され、一定時間で待たずにエラーで中断するようになりました。これは大きな前進です。ただ、この修正が与えてくれるのは「いつかは失敗が返る」という保証であって、「その失敗を無人実行がどう扱うか」までは決めてくれません。今日はそこを、個人開発の運用に引きつけて、私自身が設計し直した記録です。
「固まる」と「失敗が返る」はまったく別の運用になる
無応答でハングする状態と、明示的にエラーが返る状態は、コードの見た目以上に運用の性質が違います。
ハングは、こちらが能動的に何かを判断する機会を奪います。例外も戻り値も来ないので、try/except も後続の分岐も発火しません。プロセスはただ待ち、実行枠のタイムアウトが来て外側から殺されるまで、ログには「開始」しか残りません。事後に見ても、どこで何分待っていたのか痕跡が薄いのです。
一方で失敗が返るなら、こちらは判断できます。再試行するのか、このコネクタを今回は諦めるのか、後続だけ先に進めるのか。プラットフォーム側が無応答を打ち切ってくれるようになったことで、ようやくこの判断の入口に立てます。
ただし、打ち切りまでの猶予は無人実行にとっては長いことがあります。実行枠の全体予算が短い場合、外側の上限より内側で自分から見切る方が、後続工程に時間を残せます。打ち切りを丸ごとプラットフォームに委ねるのではなく、より内側に自前のデッドラインを敷くのが出発点になります。
デッドラインは「どの層が止めるか」を先に決める
タイムアウトの設計でいちばん事故りやすいのは、複数の層がそれぞれ別の上限を持っていて、誰が最初に止めるのかが曖昧なことです。整理として、止め時を握る層を一つに絞ります。
私の場合、内側から順にこう並べます。まず個々のMCPツール呼び出しに呼び出しデッドラインを持たせ、これを実行枠の全体予算よりはっきり短くします。次に、一つの工程が複数の呼び出しを束ねる工程デッドラインを置きます。最後に、定期実行全体を見張る実行枠デッドラインがあり、これはプラットフォームの上限と一致させます。
肝心なのは、内側の上限を外側より必ず短く取ることです。呼び出しデッドラインが工程デッドラインより長ければ、自前の打ち切りは一度も発火せず、結局いちばん外側に飲み込まれます。数字に迷ったら、外側の予算の何割を一回の呼び出しに割けるかから逆算します。残りの工程に時間を残すなら、一回の呼び出しは全体予算のせいぜい三分の一程度に抑えるのが、私の感覚では現実的でした。
自前のデッドラインで包むasyncioラッパ
実装は、MCPツール呼び出しを自前のデッドラインで包み、結果を三つに区別するところから始めます。ハングして自分が打ち切ったのか、サーバーが明示的にエラーを返したのか、正常に応答が返ったのか。この区別が、あとの再試行判断を支えます。
import asyncio
import enum
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
class Outcome(enum.Enum):
OK = "ok"
DEADLINE = "deadline" # 自前デッドラインで打ち切った(ハング相当)
ERROR = "error" # サーバーが明示的に失敗を返した
@dataclass
class CallResult:
outcome: Outcome
value: Any = None
detail: str = ""
elapsed: float = 0.0
async def call_with_deadline(
fn: Callable[[], Awaitable[Any]],
deadline_s: float,
) -> CallResult:
"""MCPツール呼び出しを自前デッドラインで包み、結果を3分類で返す。
deadline_s はプラットフォーム既定の打ち切りより内側に取ること。
"""
loop = asyncio.get_event_loop()
start = loop.time()
try:
value = await asyncio.wait_for(fn(), timeout=deadline_s)
return CallResult(Outcome.OK, value=value, elapsed=loop.time() - start)
except asyncio.TimeoutError:
# 応答が返らないまま自前デッドラインに到達した
return CallResult(
Outcome.DEADLINE,
detail=f"no response within {deadline_s}s",
elapsed=loop.time() - start,
)
except Exception as e: # noqa: BLE001 — 失敗の種類は呼び出し側で判断する
return CallResult(
Outcome.ERROR,
detail=f"{type(e).__name__}: {e}",
elapsed=loop.time() - start,
)
ここで asyncio.wait_for がコルーチンに CancelledError を送って打ち切りますが、相手側の処理が即座に止まる保証はありません。だからこそ、打ち切ったあとに同じコネクタへ続けて投げないことが大事になります。続けて投げれば、止まりきっていない前の呼び出しと競合して、次もまた無応答になりがちです。
ハングと通常エラーは、再試行の前提が違う
DEADLINE と ERROR を区別したのは、再試行の意味づけが違うからです。
サーバーが返した ERROR は、多くの場合こちらに情報が残ります。認証失効なのか、不正な引数なのか、上流の一時障害なのか。種類によっては再試行に意味があり、種類によっては何度投げても同じです。
対して DEADLINE は、相手が今どういう状態か分からないまま打ち切った状態です。ここでの再試行は、相手をさらに混雑させる方向にも働きえます。だから DEADLINE の再試行は、間隔を広めに取り、回数も控えめにします。そして再試行のたびにジッターを混ぜ、複数の定期実行が同じ瞬間に再突入して波を作らないようにします。
import random
async def call_with_retry(
fn: Callable[[], Awaitable[Any]],
deadline_s: float,
max_attempts: int = 3,
base_backoff_s: float = 2.0,
) -> CallResult:
"""3分類の結果に応じて再試行する。冪等な呼び出しにのみ使うこと。"""
last = CallResult(Outcome.ERROR, detail="not attempted")
for attempt in range(1, max_attempts + 1):
last = await call_with_deadline(fn, deadline_s)
if last.outcome is Outcome.OK:
return last
if attempt == max_attempts:
break
# DEADLINE は相手の状態が不明なので、間隔を広めに取る
factor = 2.0 if last.outcome is Outcome.DEADLINE else 1.0
backoff = base_backoff_s * (2 ** (attempt - 1)) * factor
backoff += random.uniform(0, backoff) # フルジッター
await asyncio.sleep(backoff)
return last
再試行が冪等な呼び出しに限られる点は、強調しておきます。DEADLINE で打ち切った呼び出しは、相手側では成立しているかもしれません。書き込み系のツールに無条件で再試行をかけると、二重に効いてしまう危険があります。書き込みには冪等キーを添えるか、そもそも再試行の対象から外すのが安全です。この線引きは、無人エージェントのMCPポリシー強制とallowlist設計で扱った「何を許すか」の設計とも地続きです。
壊れたコネクタは、実行の残り時間ごと巻き込ませない
一回の呼び出しを締められても、同じコネクタが連続してDEADLINEを返すなら、再試行を重ねるほど実行枠の時間を食い潰します。そこで、コネクタ単位のサーキットブレーカを挟みます。一定回数続けて失敗したら、そのコネクタを今回の実行の残りでは開いた状態にし、呼び出す前に即座にスキップします。
import time
class ConnectorBreaker:
"""コネクタ単位の簡易サーキットブレーカ。
連続失敗が閾値に達したら open にし、cooldown を過ぎるまで即スキップする。
"""
def __init__(self, fail_threshold: int = 2, cooldown_s: float = 120.0):
self.fail_threshold = fail_threshold
self.cooldown_s = cooldown_s
self._fails = 0
self._opened_at = 0.0
def is_open(self) -> bool:
if self._fails < self.fail_threshold:
return False
if time.monotonic() - self._opened_at >= self.cooldown_s:
self._fails = 0 # half-open: 一度だけ試させる
return False
return True
def record(self, result: CallResult) -> None:
if result.outcome is Outcome.OK:
self._fails = 0
return
self._fails += 1
if self._fails == self.fail_threshold:
self._opened_at = time.monotonic()
async def guarded_call(
breaker: ConnectorBreaker,
fn: Callable[[], Awaitable[Any]],
deadline_s: float,
) -> CallResult:
if breaker.is_open():
return CallResult(Outcome.ERROR, detail="breaker open: skipped")
result = await call_with_retry(fn, deadline_s)
breaker.record(result)
return result
ブレーカが開いたときに、その工程を全体の失敗にするのか、後続だけ先に進めるのかは、工程の性質で決めます。私の運用では、参照データの取得のように欠けても後続が成立する工程はスキップして続行し、出力の確定に関わる工程はその回を諦めます。どちらにしても、ブレーカが開いた事実そのものはログに残します。無音で飛ばすと、次に同じことが起きたとき原因にたどり着けません。
ログに「待っていた事実」を残す
ハングがやっかいだったのは、痕跡が薄いことでした。だから自前で打ち切る設計に変えるとき、同時に「何秒待って、なぜ打ち切ったか」を必ず書き出すようにしました。
各呼び出しについて、結果の分類・経過秒数・試行回数・ブレーカの状態を一行に落とします。DEADLINE が特定のコネクタに偏っているのか、時間帯に偏っているのかは、この一行が溜まって初めて見えてきます。固まったまま殺されていた頃は、そもそもこの問いを立てる材料がありませんでした。
ログの粒度をどこまで上げるかは、対話的な切り分けの手順とは別物として考えています。突然動かなくなったコネクタを人が追うときの手順はClaude Code でMCPサーバーが突然動かなくなったときの診断ガイドに譲り、ここでは無人実行が後から自分で説明できる最小限を残すことに絞ります。
つまずきやすいところ — 4つの落とし穴
内側のデッドラインを外側より長くしてしまう
自前デッドラインを外側より長く取ってしまうと、打ち切りは一度も発火せず、結局いちばん外側に飲み込まれます。内側を必ず短くすることを強く推奨します。
打ち切った直後に同じコネクタへ即再投入する
DEADLINE で打ち切った直後に同じコネクタへ即再投入するのも事故のもとです。前の呼び出しが相手側でまだ生きていると、競合して次も無応答になりがちです。再試行の前にバックオフを挟み、ジッターで波を崩して回避します。
書き込み系に無条件の再試行をかける
書き込み系の呼び出しに無条件の再試行をかけるのは、本番運用でいちばん怖い罠です。DEADLINE は相手側で成立している可能性を含むため、冪等キーを添えるか、再試行の対象から外します。
cooldown を実行枠より長く取ってしまう
サーキットブレーカの cooldown を実行枠より長く取ると、一度開いたら今回はもう二度と試さない挙動になります。それを意図するならよいのですが、意図せずそうなっていることが多いので、実行枠の長さと突き合わせて決めることをお勧めします。
次の一歩
まずは、いちばん外側で頼っている打ち切り(プラットフォームの上限や実行枠のタイムアウト)の値を一つ確認してください。そのうえで、最も内側のMCP呼び出しに、それよりはっきり短いデッドラインを一つだけ足してみる。call_with_deadline を一箇所に挟んで、結果が三分類でログに出るところまで来れば、固まったまま消えていた工程が、初めて「何秒待って打ち切られたか」を語り始めます。そこからが、再試行とブレーカを足していく地に足のついた出発点になります。
応答が返らない相手をどう諦めるかは、無人で回す仕組みほど効いてきます。私自身まだ調整の途中ですが、止め時をプラットフォーム任せにしないと決めただけで、定期実行のログがずいぶん静かになりました。同じように無人運用を組んでいる方の参考になれば幸いです。