MCP 错误处理与重试机制怎么做?从错误码到断路器的实战方案
MCP 服务上线第一天就翻了车——tool 调用超时、server 不响应、客户端疯狂重试把整个链路打崩。这是很多团队上生产时的真实经历,问题不在 MCP 本身,而在于没做好错误处理和重试。
MCP 基于 JSON-RPC 2.0 协议通信,错误处理的核心在于:区分哪些错能重试、哪些不能,以及重试时怎么避免把服务打崩。下面从 MCP 协议本身的错误体系讲起,再到重试策略、断路器和降级方案的实战配置。
MCP 协议的错误码体系
MCP 定义了两层错误码:标准 JSON-RPC 错误和协议扩展错误。搞不清这两层的区别,重试逻辑就是瞎写。
标准 JSON-RPC 错误(客户端和服务端都可能返回):
| 错误码 | 含义 | 能否重试 |
|---|---|---|
| -32700 | Parse error,请求体格式错误 | 不能 |
| -32600 | Invalid Request,请求不合法 | 不能 |
| -32601 | Method not found | 不能 |
| -32602 | Invalid params | 不能 |
| -32603 | Internal error | 视情况 |
MCP 扩展错误(code < -32000):
| 错误码 | 含义 | 能否重试 |
|---|---|---|
| -32000 | Server error,服务端通用错误 | 可能可以 |
| -32001 | 请求超时 | 可以 |
| -32002 | 速率限制(Rate limit) | 可以(需等 retryAfter) |
关键点:-32xxx 范围的错误才是"可能重试"的候选,-32xxx 以下的参数类错误重试也没用——你传错了参数,重试 100 次还是错。
MCP 的错误响应里可以带 data 字段,里面放 retryable 和 retryAfter,就是告诉客户端"这个错能重试,等多久再试":
json{ "jsonrpc": "2.0", "id": "req-123", "error": { "code": -32001, "message": "Request timeout", "data": { "retryable": true, "retryAfter": 3 } } }
所以第一步是:在错误处理逻辑里,根据错误码判断是否可重试,而不是一刀切全部重试。
Stdio 和 HTTP/SSE Transport 的错误处理差异
MCP 支持两种 transport,错误处理方式完全不同,搞混了会踩坑。
Stdio transport:没有重试机制。进程挂了就是挂了,错误写 stderr,进程退出码非零表示失败。客户端能做的是重启进程然后重放请求——但要注意,重启后的 server 是全新状态,之前的 session 已经丢了。我见过有人重启后拿旧 session_id 去请求,服务端根本不认,排查了半天才发现是 session 丢失的问题。
HTTP/SSE transport:有 HTTP 状态码可以判断。400 系列是客户端问题(不用重试),500 系列是服务端问题(可以重试),503 通常带 Retry-After header。SSE 流断开时,用最后一个 event ID 重连可以续上,不需要从头开始——这是 SSE transport 相比 Stdio 的一个明显优势。
pythonimport httpx import asyncio class MCPRetryableError(Exception): pass class MCPClientError(Exception): pass async def call_mcp_with_http(method: str, params: dict): async with httpx.AsyncClient() as client: resp = await client.post( "http://mcp-server:8080/mcp", json={"jsonrpc": "2.0", "id": "1", "method": method, "params": params} ) if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", 5)) await asyncio.sleep(retry_after) return await call_mcp_with_http(method, params) elif resp.status_code >= 500: raise MCPRetryableError(resp.json()) elif resp.status_code >= 400: raise MCPClientError(resp.json()) return resp.json()
指数退避重试:别让重试变成雪崩
"超时了再试一次"是最直觉的做法,但问题在于:如果 100 个客户端同时超时、同时重试,服务端刚缓过来又被压垮。这就是 thundering herd 问题,在 MCP 多客户端场景下特别常见。
指数退避的思路是每次重试等更久:第 1 次等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒。再加上随机抖动(jitter),让各客户端的重试时间错开:
pythonimport random import asyncio async def retry_with_backoff(func, max_retries=3, base_delay=1.0, max_delay=32.0): last_error = None for attempt in range(max_retries): try: return await func() except MCPRetryableError as e: last_error = e if attempt == max_retries - 1: break delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay) await asyncio.sleep(delay) raise last_error
参数选择的经验值(MCP 场景下):
- base_delay:0.5-1 秒。MCP 服务如果只是临时过载,1 秒内通常能恢复
- max_delay:16-32 秒。超过 30 秒还没恢复,大概率不是临时问题,再重试没意义
- max_retries:3 次。对 MCP 来说够了,更多重试不如触发断路器
- jitter:必须加。不加 jitter 的退避在多客户端场景下等于没退避,所有客户端会在同一时刻重试
断路器模式:连续失败时及时止损
重试解决的是"临时故障",但如果是持续故障(比如 MCP server 的下游数据库挂了),越重试越雪上加霜。断路器的作用是:失败次数超过阈值后直接拒绝请求,不再调用服务端,给对方恢复的时间。
断路器有三个状态:
- CLOSED(正常):请求正常通过,同时统计失败次数
- OPEN(熔断):直接拒绝,不调用服务端。等超时后进入半开
- HALF_OPEN(试探):放一个请求过去试探,成功则恢复 CLOSED,失败则继续 OPEN
pythonimport time from enum import Enum class CircuitState(Enum): CLOSED = "closed" OPEN = "open" HALF_OPEN = "half_open" class MCPCircuitBreaker: def __init__(self, failure_threshold=5, recovery_timeout=30.0): self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout self.state = CircuitState.CLOSED self.failure_count = 0 self.last_failure_time = 0 async def call(self, func): if self.state == CircuitState.OPEN: if time.time() - self.last_failure_time >= self.recovery_timeout: self.state = CircuitState.HALF_OPEN else: raise Exception("Circuit breaker OPEN, fast fail") try: result = await func() self.failure_count = 0 if self.state == CircuitState.HALF_OPEN: self.state = CircuitState.CLOSED return result except Exception: self.failure_count += 1 self.last_failure_time = time.time() if self.failure_count >= self.failure_threshold: self.state = CircuitState.OPEN raise
MCP 场景下断路器参数建议:
- failure_threshold:5 次。MCP server 连续 5 次都失败,基本可以判定服务异常
- recovery_timeout:30 秒。比普通 HTTP 服务短,因为 MCP 调用通常是轻量级的,恢复较快
重试 + 断路器:生产环境必须组合使用
实际生产中两者必须配合:重试处理临时抖动,断路器处理持续故障。请求先过断路器,断路器放行后才进入重试逻辑:
pythonbreaker = MCPCircuitBreaker(failure_threshold=5, recovery_timeout=30.0) async def resilient_mcp_call(method: str, params: dict): async def _call(): return await mcp_client.call(method, params) async def _guarded_call(): return await breaker.call(_call) return await retry_with_backoff(_guarded_call, max_retries=3)
注意顺序:先断路器后重试。反过来会出问题——断路器 OPEN 时,重试逻辑还会等 3 次超时才放弃,白白浪费时间。断路器在前可以快速失败,客户端立刻知道"别等了"。
优雅降级:服务彻底不可用时的兜底
重试和断路器都救不了的场面——MCP server 完全挂了——系统不能直接崩溃,需要有降级方案:
pythonasync def mcp_call_with_fallback(method: str, params: dict, fallback=None): try: return await resilient_mcp_call(method, params) except Exception: if fallback is not None: return fallback raise
常见的降级策略:
- 缓存回退:MCP tool 返回的数据如果短期内不变,缓存上次成功结果直接返回
- 默认值兜底:非核心功能(如推荐、个性化)返回默认值,只有核心功能才报错
- 备用服务:同一个 tool 如果有备用 server,切换过去
核心思路:把 MCP 调用分为"必须成功"和"可以降级"两类,前者才需要严格的重试和断路器,后者用 fallback 兜底。
2026 MCP Roadmap:重试语义即将标准化
MCP 官方在 2026 路线图中明确将 retry semantics 列为重点完善领域,目前的方向:
- 标准化的重试语义:定义哪些错误应该重试、重试策略如何声明,而不是让每个客户端自己实现
- Streamable HTTP transport:新 transport 原生支持负载均衡和水平扩展,server 重启对客户端无感,从架构层面减少需要重试的场景
- 无状态化 session:session 创建、恢复、迁移标准化后,server 扩缩容时客户端不需要重连重试
当前的"手动实现重试+断路器"是过渡方案,后续 MCP SDK 很可能会内置这些能力。但在标准化落地之前,上面这套方案是生产环境必备的防护网。