Conversation
… tool-level errors The circuit breaker was counting any tool error toward the threshold, including business-level errors like DNS failures, HTTP 4xx/5xx, and page load failures. This caused healthy MCP servers to be marked as unreachable after just 3 bad URLs. Added _is_infrastructure_error() to distinguish: - Infrastructure errors (connection lost, timeout, process crash) → count - Tool-level errors (DNS failure, HTTP errors, page crashes) → don't count Fixes NousResearch#11113
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the distinction between a responsive MCP server returning a domain error and a broken server.
Problems
- The proposed
passattools/mcp_tool.py:1467avoids an increment but does not reset an existing streak. On current main, the breaker is explicitly consecutive-state machinery (tools/mcp_tool.py:2994-3015), so a responsive{"error": ...}response should close it with_reset_server_error(). - The marker-only exception predicate would not preserve the current half-open contract:
tests/tools/test_mcp_circuit_breaker.py:184-218usesRuntimeError("still broken")and requires that failed probe to re-arm the breaker. This predicate would not count that exception. - The diff has no tests for error envelopes or exception classification. Current
tools/mcp_tool.py:4000-4004still bumps every error envelope, so this needs a regression test on the current state machine.
Suggested changes
- Salvage onto current main using
_reset_server_error()after every completed tool response, including error envelopes. - Add tests for repeated
result.isErrorresponses and for the half-open failed-probe path. - Classify exception paths with a contract that preserves the existing breaker safety behavior.
Automated hermes-sweeper review.
| if "error" in parsed: | ||
| _server_error_counts[server_name] = _server_error_counts.get(server_name, 0) + 1 | ||
| # Tool-level error — server is responsive, don't count | ||
| pass |
There was a problem hiding this comment.
A responsive error envelope should reset, not merely avoid incrementing, the consecutive-failure streak. Otherwise two earlier transport failures remain live and the next transport failure trips a breaker whose failures were not consecutive.
| except Exception as exc: | ||
| _server_error_counts[server_name] = _server_error_counts.get(server_name, 0) + 1 | ||
| # Only count infrastructure errors toward circuit breaker | ||
| if _is_infrastructure_error(exc): |
There was a problem hiding this comment.
Please add a regression for this classifier. Current main’s half-open breaker test treats RuntimeError("still broken") as a failed probe that must re-arm the breaker; this marker-only predicate returns false for it and would leave the probe path unprotected after salvage.
Additional reproduction case: HTTP MCP with header-based authenticationEnvironment
Scenario
Observed behavior after clean restart
Expected behavior
Why this matters for this PRThis demonstrates the circuit breaker can create a permanent lockout for HTTP MCP servers — worse than the ~60s temporary lockout reported for stdio transports. For production setups where MCP config may be temporarily misconfigured (e.g., rotated API keys, config migration), the current behavior requires manual intervention beyond a simple restart. The fix in this PR (distinguishing infrastructure errors from business errors, resetting on Related |
Local test results: patch applied on HTTP MCP with header authI cherry-picked this PR onto my local Hermes instance to test against an AgentMail HTTP MCP server (header-based Test setup
Results
Why the counter still incrementsThe I suspect the SuggestionThe # HTTP 4xx/5xx from a responsive server are NOT infrastructure errors
if isinstance(exc, httpx.HTTPStatusError):
return FalseOr the unconditional OverallThe PR fixes the JSON business error case — confirmed. For HTTP auth errors (403), additional paths need coverage. This is still the right direction. |
Port of upstream issue NousResearch#11113 / PR NousResearch#11128, re-based onto current main's consecutive-failure breaker state machine and revised per the PR review. Root cause: a tool-level error comes back from _call as a returned {"error": ...} result (isError content: bad path, HTTP 4xx upstream, validation rejection, "old_text not found"). The success path bumped the breaker on any such result, so a few innocent errors from a *healthy* server (reproduced on victoria as mcp_vault_edit "old_text not found") tripped it for 60s and every vault tool then short-circuited as 'unreachable'. Fix: the success path now resets the consecutive-failure streak on ANY returned result — a response means the server is responsive, and resetting (not merely skipping the increment) is what the breaker's consecutive-state machinery needs so earlier transport blips don't linger and sum with a later one. The exception path is left as upstream's unconditional _bump_server_error: those exceptions are genuine transport/protocol failures (call_tool raised, not isError), and the half-open probe relies on a failed probe re-arming the breaker (tests/tools/test_mcp_circuit_breaker.py:: test_circuit_breaker_reopens_on_probe_failure). This drops the earlier hand-port's _is_infrastructure_error exception classifier, which would have left that probe path unprotected. Tests: repeated isError results keep the breaker fully closed; a responsive error result resets a partial transport streak. Local port pending upstream NousResearch#11128 merging.
Port of upstream issue NousResearch#11113 / PR NousResearch#11128, re-based onto current main's consecutive-failure breaker state machine and revised per the PR review. Root cause: a tool-level error comes back from _call as a returned {"error": ...} result (isError content: bad path, HTTP 4xx upstream, validation rejection, "old_text not found"). The success path bumped the breaker on any such result, so a few innocent errors from a *healthy* server (reproduced on victoria as mcp_vault_edit "old_text not found") tripped it for 60s and every vault tool then short-circuited as 'unreachable'. Fix: the success path now resets the consecutive-failure streak on ANY returned result — a response means the server is responsive, and resetting (not merely skipping the increment) is what the breaker's consecutive-state machinery needs so earlier transport blips don't linger and sum with a later one. The exception path is left as upstream's unconditional _bump_server_error: those exceptions are genuine transport/protocol failures (call_tool raised, not isError), and the half-open probe relies on a failed probe re-arming the breaker (tests/tools/test_mcp_circuit_breaker.py:: test_circuit_breaker_reopens_on_probe_failure). This drops the earlier hand-port's _is_infrastructure_error exception classifier, which would have left that probe path unprotected. Tests: repeated isError results keep the breaker fully closed; a responsive error result resets a partial transport streak. Local port pending upstream NousResearch#11128 merging.
Port of upstream issue NousResearch#11113 / PR NousResearch#11128, re-based onto current main's consecutive-failure breaker state machine and revised per the PR review. Root cause: a tool-level error comes back from _call as a returned {"error": ...} result (isError content: bad path, HTTP 4xx upstream, validation rejection, "old_text not found"). The success path bumped the breaker on any such result, so a few innocent errors from a *healthy* server (reproduced on victoria as mcp_vault_edit "old_text not found") tripped it for 60s and every vault tool then short-circuited as 'unreachable'. Fix: the success path now resets the consecutive-failure streak on ANY returned result — a response means the server is responsive, and resetting (not merely skipping the increment) is what the breaker's consecutive-state machinery needs so earlier transport blips don't linger and sum with a later one. The exception path is left as upstream's unconditional _bump_server_error: those exceptions are genuine transport/protocol failures (call_tool raised, not isError), and the half-open probe relies on a failed probe re-arming the breaker (tests/tools/test_mcp_circuit_breaker.py:: test_circuit_breaker_reopens_on_probe_failure). This drops the earlier hand-port's _is_infrastructure_error exception classifier, which would have left that probe path unprotected. Tests: repeated isError results keep the breaker fully closed; a responsive error result resets a partial transport streak. Local port pending upstream NousResearch#11128 merging.
Port of upstream issue NousResearch#11113 / PR NousResearch#11128, re-based onto current main's consecutive-failure breaker state machine and revised per the PR review. Root cause: a tool-level error comes back from the call as a returned {"error": ...} result (isError content: bad path, HTTP 4xx upstream, validation rejection, "old_text not found"). _record_call_outcome bumped the breaker on any such result, so a few innocent errors from a *healthy* server (reproduced on victoria as mcp_vault_edit "old_text not found") tripped it for 60s and every vault tool then short-circuited as 'unreachable'. Fix: _record_call_outcome now resets the consecutive-failure streak on ANY returned result — a response means the server is responsive, and resetting (not merely skipping the increment) is what the breaker's consecutive-state machinery needs so earlier transport blips don't linger and sum with a later one. The transport paths (_strike and the _bump_server_error call sites around acquire/reconnect) are left as upstream's: those are genuine transport failures, and the half-open probe relies on a failed probe re-arming the breaker (test_circuit_breaker_reopens_on_probe_failure). Tests: repeated isError results keep the breaker fully closed; a responsive error result resets a partial transport streak. Both fail on unpatched upstream and pass with the fix. Re-ported this sync for two upstream moves: the breaker bookkeeping now lives in tools/mcp_tool_handlers.py::_record_call_outcome rather than inline in _make_tool_handler, and the tests' loop helper moved to tools.mcp_tool_loop. Local port pending upstream NousResearch#11128 merging.
|
Closing with a maintainer ruling rather than a quality verdict. Tool |
Local divergence from upstream, re-ported onto this sync's shape.
Root cause: a tool-level error comes back from the call as a returned
{"error": ...} result (isError content: bad path, HTTP 4xx upstream,
validation rejection, "old_text not found"). _record_call_outcome counts any
such result as a breaker strike, so a few innocent errors from a *healthy*
server (reproduced on victoria as mcp_vault_edit "old_text not found") trip it
for 60s and every tool on that server then short-circuits — including the
search tool needed to find the right old_text.
Fix: _record_call_outcome resets the consecutive-failure streak on ANY
returned result — a response means the server is responsive, and resetting
(not merely skipping the increment) is what the breaker's consecutive-state
machinery needs so earlier transport blips don't linger and sum with a later
one. The transport paths (_strike and the _bump_server_error call sites around
acquire/reconnect) are left as upstream's: those are genuine transport
failures, and the half-open probe relies on a failed probe re-arming the
breaker (test_circuit_breaker_reopens_on_probe_failure).
Upstream status changed this sync: PR NousResearch#11128 was CLOSED UNMERGED and issue
NousResearch#11113 closed as COMPLETED by a different change. Upstream's resolution keeps
counting returned error payloads as strikes (citing NousResearch#10447) and only adds an
`application=True` flag so the open-breaker wording says "rejected the last N
calls (it is reachable)" instead of "unreachable". That fixes the message, not
the 60s lockout we were bitten by, so this patch stays.
Because of that, upstream's new
test_breaker_opened_by_tool_errors_says_rejected_not_unreachable asserts the
behaviour we override. Adapted: it now stages the application strikes via
direct _bump_server_error(application=True) calls — exactly what its own second
half already does — so it still pins the wording behaviour without asserting
that a returned error payload is a strike.
Tests: repeated isError results keep the breaker fully closed; a responsive
error result resets a partial transport streak. Both fail on unpatched
upstream and pass with the fix.
Local divergence from upstream, re-ported onto this sync's shape.
Root cause: a tool-level error comes back from the call as a returned
{"error": ...} result (isError content: bad path, HTTP 4xx upstream,
validation rejection, "old_text not found"). _record_call_outcome counts any
such result as a breaker strike, so a few innocent errors from a *healthy*
server (reproduced on victoria as mcp_vault_edit "old_text not found") trip it
for 60s and every tool on that server then short-circuits — including the
search tool needed to find the right old_text.
Fix: _record_call_outcome resets the consecutive-failure streak on ANY
returned result — a response means the server is responsive, and resetting
(not merely skipping the increment) is what the breaker's consecutive-state
machinery needs so earlier transport blips don't linger and sum with a later
one. The transport paths (_strike and the _bump_server_error call sites around
acquire/reconnect) are left as upstream's: those are genuine transport
failures, and the half-open probe relies on a failed probe re-arming the
breaker (test_circuit_breaker_reopens_on_probe_failure).
Upstream status changed this sync: PR NousResearch#11128 was CLOSED UNMERGED and issue
NousResearch#11113 closed as COMPLETED by a different change. Upstream's resolution keeps
counting returned error payloads as strikes (citing NousResearch#10447) and only adds an
`application=True` flag so the open-breaker wording says "rejected the last N
calls (it is reachable)" instead of "unreachable". That fixes the message, not
the 60s lockout we were bitten by, so this patch stays.
Because of that, upstream's new
test_breaker_opened_by_tool_errors_says_rejected_not_unreachable asserts the
behaviour we override. Adapted: it now stages the application strikes via
direct _bump_server_error(application=True) calls — exactly what its own second
half already does — so it still pins the wording behaviour without asserting
that a returned error payload is a strike.
Tests: repeated isError results keep the breaker fully closed; a responsive
error result resets a partial transport streak. Both fail on unpatched
upstream and pass with the fix.
Summary
The MCP circuit breaker was counting any tool error toward the threshold, including business-level errors like DNS failures, HTTP 4xx/5xx, and page load failures. This caused healthy MCP servers to be marked as unreachable after just 3 bad URLs.
Root Cause
The circuit breaker incremented the error count for:
Fix
Added _is_infrastructure_error() helper function to distinguish:
Also changed the JSON response handling to not count tool-level errors (when the server returns a valid response with an error field).
Test Plan
Closes #11113