fix(#4060): extend backoff gate to remote MCP HTTP errors - #4074
fix(#4060): extend backoff gate to remote MCP HTTP errors#4074aheritier wants to merge 1 commit into
Conversation
Three review findings on PR #4074, all addressed in this commit: 1. (must-fix) enrichConnectError previously gated the *modelerrors.StatusError wrap on the extracted server message being non-empty. Many load-balancer and rate-limit responses carry an empty body, so a bare 429/503 with no payload silently skipped the wrap and the backoff gate never armed — defeating the whole point of this PR for exactly the responses it exists to pace. Now wraps on status code alone; the enrichment text degrades gracefully to '(server responded %d)' when no message is available. 2. (should-fix) Retry-After was discarded: WrapHTTPError was always called with resp=nil. oauthTransport now also captures the raw Retry-After header value alongside the status/body it already tracks, and enrichConnectError builds a minimal *http.Response carrying that header so WrapHTTPError parses it onto the StatusError — matching the handling already in place for model-provider adapters. Status, message and Retry-After are read together as a single lastServerErrorSnapshot() under one lock (not three separately-locked accessors), so a caller can never pair a status from one response with a Retry-After header captured from a different concurrent response on the same transport (this transport's RoundTrip can run concurrently for a single logical connect attempt, e.g. a standalone SSE probe alongside the initialize call). 3. (should-fix) Added an end-to-end regression test that drives a real *mcp.Toolset (built via NewRemoteToolset, exactly as production wiring does) through tools.StartableToolSet.TryStart against a mock 503/403 server, proving the whole chain (enrichConnectError -> Toolset.Start -> supervisor.Start -> the backoff gate) stays intact end to end, not just the enrichConnectError unit boundary.
6267a9e to
d99ac16
Compare
d99ac16 to
608a099
Compare
608a099 to
21920e7
Compare
21920e7 to
0341872
Compare
A2A toolset startup fetches the remote agent's card via
agentcard.Resolver, which returns *agentcard.ErrStatusNotOK{StatusCode,
Status} on any non-200 response. #4074's deferral rationale ("the
agent-card resolver does not expose HTTP status cleanly") was wrong on
this point — the resolver already exposes the status; enrichCardError
(pkg/tools/a2a/carderror.go) only needs to translate it into
*modelerrors.StatusError via modelerrors.WrapHTTPError, mirroring
enrichConnectError's handling of remote MCP HTTP errors
(pkg/tools/mcp/remote.go). Toolset.Start now returns enrichCardError's
result instead of a bare fmt.Errorf, so retryable responses arm the
StartableToolSet backoff gate exactly as remote MCP and RAG embedding
429s do.
The one thing the resolver genuinely doesn't expose is the
Retry-After header: Resolver.Resolve discards the *http.Response after
producing ErrStatusNotOK. retryAfterRecorder is a minimal
http.RoundTripper installed only in front of the card-resolution GET
(never the JSON-RPC transport chain built afterwards) that records the
status and Retry-After of the most recent >=400 response; unlike
oauthTransport's lastServerErrorSnapshot it carries no mutex, since
Start issues exactly one synchronous card GET per attempt. Its header
is only forwarded when its recorded status matches ErrStatusNotOK's
own StatusCode, so a header from an unrelated response can never be
paired with the wrong status.
Classifier policy is unchanged from #4062/#4074: the gate arms only on
a fixed enumeration (429, 408, 500, 502, 503, 504, 529), not a full 5xx
range — 501, 505, and the Cloudflare 520-527 family do not arm it.
Deliberately excluded from arming: DNS failures, connection refused,
SSRF-blocked private-IP targets, malformed/unparsable agent cards, and
other 4xx statuses (bad auth, bad config) — all fail promptly with no
pacing. Only the agent-card fetch during startup is paced; per-call
SendStreamingMessage failures on an already-started toolset are not.
Adds carderror_test.go (retryable/non-retryable status tables incl.
501 to prove the enumeration isn't a full 5xx range, no-status cases
for connection-refused/SSRF-block/malformed-card, and Retry-After
present/absent) plus backoff_test.go end-to-end tests that drive a
real *Toolset through tools.StartableToolSet.TryStart against a mock
503/403 agent-card server, and a recovery test that flips the mock
from 503 to a real, working agent card + JSON-RPC handshake after the
backoff window elapses.
docs/tools/a2a/index.md documents the new "Startup failure behaviour"
section with the same precision as MCP's; docs/tools/rag/index.md's
cross-reference note now names A2A alongside remote MCP.
Refs #4060, #4074, #4098
A2A toolset startup fetches the remote agent's card via
agentcard.Resolver, which returns *agentcard.ErrStatusNotOK{StatusCode,
Status} on any non-200 response. #4074's deferral rationale ("the
agent-card resolver does not expose HTTP status cleanly") was wrong on
this point — the resolver already exposes the status; enrichCardError
(pkg/tools/a2a/carderror.go) only needs to translate it into
*modelerrors.StatusError via modelerrors.WrapHTTPError, mirroring
enrichConnectError's handling of remote MCP HTTP errors
(pkg/tools/mcp/remote.go). Toolset.Start now returns enrichCardError's
result instead of a bare fmt.Errorf, so retryable responses arm the
StartableToolSet backoff gate exactly as remote MCP and RAG embedding
429s do.
The one thing the resolver genuinely doesn't expose is the
Retry-After header: Resolver.Resolve discards the *http.Response after
producing ErrStatusNotOK. retryAfterRecorder is a minimal
http.RoundTripper installed only in front of the card-resolution GET
(never the JSON-RPC transport chain built afterwards) that records the
status and Retry-After of the most recent >=400 response; unlike
oauthTransport's lastServerErrorSnapshot it carries no mutex, since
Start issues exactly one synchronous card GET per attempt. Its header
is only forwarded when its recorded status matches ErrStatusNotOK's
own StatusCode, so a header from an unrelated response can never be
paired with the wrong status.
Classifier policy is unchanged from #4062/#4074: the gate arms only on
a fixed enumeration (429, 408, 500, 502, 503, 504, 529), not a full 5xx
range — 501, 505, and the Cloudflare 520-527 family do not arm it.
Deliberately excluded from arming: DNS failures, connection refused,
SSRF-blocked private-IP targets, malformed/unparsable agent cards, and
other 4xx statuses (bad auth, bad config) — all fail promptly with no
pacing. Only the agent-card fetch during startup is paced; per-call
SendStreamingMessage failures on an already-started toolset are not.
Adds carderror_test.go (retryable/non-retryable status tables incl.
501 to prove the enumeration isn't a full 5xx range, no-status cases
for connection-refused/SSRF-block/malformed-card, and Retry-After
present/absent) plus backoff_test.go end-to-end tests that drive a
real *Toolset through tools.StartableToolSet.TryStart against a mock
503/403 agent-card server, and a recovery test that flips the mock
from 503 to a real, working agent card + JSON-RPC handshake after the
backoff window elapses.
docs/tools/a2a/index.md documents the new "Startup failure behaviour"
section with the same precision as MCP's; docs/tools/rag/index.md's
cross-reference note now names A2A alongside remote MCP.
Refs #4060, #4074, #4098
0341872 to
01f4bf5
Compare
aheritier
left a comment
There was a problem hiding this comment.
Reviewed at head 01f4bf5c (base main, MERGEABLE/CLEAN). CI for this SHA is fully green: build-and-test, lint, windows-tests, CodeQL/Analyze, build-image ×2, docs checks — 19 check-runs, 0 failed, 0 pending.
Re-validated locally from a git archive of the head SHA: go build clean; go test -race -count=1 ./pkg/tools/ ./pkg/tools/mcp/ ./pkg/modelerrors/ ./pkg/tools/codemode/ green; golangci-lint run 0 issues.
Tests are load-bearing (mutation-checked): re-gating the wrap on msg != "" fails TestEnrichConnectError_EmptyBodyStatusStillArms + TestBackoffGate_RemoteMCPRecoversAfterBackoffWindow; dropping the Retry-After forward fails TestEnrichConnectError_RetryAfterHonoured; dropping the *StatusError guard in startBackoffRetryable is caught by the pre-existing TestStartableToolSet_PlainTextStatusShapeDoesNotArmGate.
Classification is structural, not textual: startBackoffRetryable (pkg/tools/startable_backoff.go:40-46) hands the extracted *StatusError itself to RetryableHTTPStatus, so extractHTTPStatusCode always takes the errors.As branch and the \b[45]\d{2}\b regex fallback is unreachable from the gate.
Stdio path unchanged: enrichConnectError has a single caller (remote.go:181, remote client only).
Findings
[should-fix] Code-mode caveat missing from docs/tools/mcp/index.md:261. The paragraph says remote MCP retryable statuses "are paced by the same bounded exponential backoff gate" without qualification. I probed a codemode.Wrap composite (one Startable inner returning a 503 *StatusError + one non-Startable builtin) through tools.NewStartable(...).TryStart: the failing inner's Start ran 3/3 times — not paced — because pkg/tools/startable.go:576-586 calls resetStartBackoff() on PartialStartError. That's pre-existing behaviour from #4062 and already tracked in #4067, so it's not a regression here, but code-mode users reading this doc will expect pacing they won't get. Suggest a one-liner: "Not yet applied when toolsets are wrapped in code mode — see #4067."
For the record, the total-failure code-mode case (every inner Startable and failing, one with 503) does arm the gate for the whole composite, including a plain-error sibling — new since the 503 now carries a *StatusError, but reasonable given nothing is up.
[optional] pkg/tools/mcp/oauth.go:912-921 — the single-lock snapshot rationale (SSE probe racing initialize) isn't pinned by a test: moving the lastErrRetryAfter read outside t.mu still passes the suite under -race. A small concurrent logErrorResponse / lastServerErrorSnapshot test would guard the claim.
[optional] PR description's "Changed files" table omits docs/tools/rag/index.md (+11/-4, added in round 2).
#4060 checklist
Remote MCP 429/408/5xx-set → paced (remote.go:224-239, e2e TestBackoffGate_*); 4xx + lifecycle sentinels → prompt fail (startable_backoff_test.go:763-816); empty-body statuses → still arm; Retry-After → honoured; LSP crash-loop → deferred to #4099; A2A → deferred to #4098 / stacked #4104 (overlaps this PR only on docs/tools/rag/index.md). All referenced issues are open.
No blocking findings. Filing as a comment because GitHub blocks self-approval by the author.
Remote MCP servers returning 503/429/5xx during the initialize handshake previously triggered a new connect attempt on every agent turn. enrichConnectError now wraps the HTTP status captured by the oauthTransport in modelerrors.WrapHTTPError, so retryable responses surface as *StatusError and arm the StartableToolSet backoff gate exactly as RAG embedding 429s do. The wrap happens on status code alone (not conditional on a non-empty response body), since many load-balancer and rate-limit responses carry an empty body and would otherwise silently skip the gate for exactly the responses it exists to pace. 4xx client-error responses (400/401/403) are also wrapped in *StatusError for structured access but are classified non-retryable, so bad-config and auth failures still fail promptly without pacing. A server-supplied Retry-After header, when present, is threaded through to the backoff gate: oauthTransport captures the raw header value alongside the status/body it already tracks, and enrichConnectError builds a minimal *http.Response carrying it so WrapHTTPError parses it onto the StatusError, matching the handling already in place for model-provider adapters. Status, message and Retry-After are read together as a single lastServerErrorSnapshot() under one lock (not separate accessors), so a caller can never pair a status from one response with a Retry-After header captured from a different concurrent response on the same transport (this transport's RoundTrip can run concurrently for a single logical connect attempt, e.g. a standalone SSE probe alongside the initialize call). The empty-body error message no longer repeats the status code redundantly alongside StatusError's own "HTTP %d:" prefix. Local stdio MCP failures (missing binary, connection refused) never reach enrichConnectError and are unaffected by this change. Classifier policy: startBackoffRetryable arms only on *StatusError with a retryable HTTP status — a fixed enumeration (429, 408, 500, 502, 503, 504, 529), not a full 5xx range: codes such as 501, 505, or the Cloudflare 520-527 family do not arm the gate. Doc comments and docs/tools/mcp/index.md now name this enumeration precisely instead of saying "5xx" generically. docs/tools/rag/index.md's "What triggers backoff" section is scoped explicitly to the RAG/embedding path (its 429-only trigger set does not generalize to other toolset types) with a cross-reference to MCP's broader trigger set, resolving the prior contradiction between the two pages. Deliberately excluded from arming: lifecycle.ErrServerUnavailable (missing binary), lifecycle.ErrTransport (connection refused / no such host), lifecycle.ErrAuthRequired / ErrCapabilityMissing, lifecycle.ErrInitTimeout, lifecycle.ErrSessionMissing. Note: ErrServerCrashed is NOT currently surfaced by supervisor.Start(); LSP crash-loop pacing is deferred until that propagation path is wired. Adds unit coverage for enrichConnectError (status-only gating, Retry-After present/absent) plus end-to-end tests that drive a real *mcp.Toolset (via NewRemoteToolset, matching production wiring) through tools.StartableToolSet.TryStart against a mock 503/403 server, proving the whole chain stays intact end to end. A further end-to-end test flips the mock server from 503 to a real, working MCP handshake after the backoff window elapses, proving the toolset actually recovers and starts rather than merely ceasing to error. Refs #4060 (partial — A2A pacing deferred: agent-card resolver does not expose HTTP status cleanly; LSP crash-loop pacing also deferred)
01f4bf5 to
baade77
Compare
A2A toolset startup fetches the remote agent's card via
agentcard.Resolver, which returns *agentcard.ErrStatusNotOK{StatusCode,
Status} on any non-200 response. #4074's deferral rationale ("the
agent-card resolver does not expose HTTP status cleanly") was wrong on
this point — the resolver already exposes the status; enrichCardError
(pkg/tools/a2a/carderror.go) only needs to translate it into
*modelerrors.StatusError via modelerrors.WrapHTTPError, mirroring
enrichConnectError's handling of remote MCP HTTP errors
(pkg/tools/mcp/remote.go). Toolset.Start now returns enrichCardError's
result instead of a bare fmt.Errorf, so retryable responses arm the
StartableToolSet backoff gate exactly as remote MCP and RAG embedding
429s do.
The one thing the resolver genuinely doesn't expose is the
Retry-After header: Resolver.Resolve discards the *http.Response after
producing ErrStatusNotOK. retryAfterRecorder is a minimal
http.RoundTripper installed only in front of the card-resolution GET
(never the JSON-RPC transport chain built afterwards) that records the
status and Retry-After of the most recent >=400 response; unlike
oauthTransport's lastServerErrorSnapshot it carries no mutex, since
Start issues exactly one synchronous card GET per attempt. Its header
is only forwarded when its recorded status matches ErrStatusNotOK's
own StatusCode, so a header from an unrelated response can never be
paired with the wrong status.
Classifier policy is unchanged from #4062/#4074: the gate arms only on
a fixed enumeration (429, 408, 500, 502, 503, 504, 529), not a full 5xx
range — 501, 505, and the Cloudflare 520-527 family do not arm it.
Deliberately excluded from arming: DNS failures, connection refused,
SSRF-blocked private-IP targets, malformed/unparsable agent cards, and
other 4xx statuses (bad auth, bad config) — all fail promptly with no
pacing. Only the agent-card fetch during startup is paced; per-call
SendStreamingMessage failures on an already-started toolset are not.
Adds carderror_test.go (retryable/non-retryable status tables incl.
501 to prove the enumeration isn't a full 5xx range, no-status cases
for connection-refused/SSRF-block/malformed-card, and Retry-After
present/absent) plus backoff_test.go end-to-end tests that drive a
real *Toolset through tools.StartableToolSet.TryStart against a mock
503/403 agent-card server, and a recovery test that flips the mock
from 503 to a real, working agent card + JSON-RPC handshake after the
backoff window elapses.
docs/tools/a2a/index.md documents the new "Startup failure behaviour"
section with the same precision as MCP's; docs/tools/rag/index.md's
cross-reference note now names A2A alongside remote MCP.
Refs #4060, #4074, #4098
Stacked on PR #4062. Base retargets to
mainonce that merges.Refs #4060 (partial — A2A pacing and LSP crash-loop pacing deferred; see below)
What
Remote MCP servers responding with 503/429/5xx during the initialize handshake previously triggered a fresh connect attempt on every agent turn (the #4060 burst pattern). This PR fixes that by wrapping the HTTP status from the remote server in a
*modelerrors.StatusErrorso theStartableToolSetbackoff gate arming logic can pace retries — including a server-suppliedRetry-Afterhint, and correctly for responses with no body.Design
Wrap point —
enrichConnectErrorinpkg/tools/mcp/remote.go. TheoauthTransportalready records the last HTTP error status vialogErrorResponse(for any>= 400response).enrichConnectErrorwraps that status (regardless of whether the response carried a body — many rate-limit/load-balancer responses don't) viamodelerrors.WrapHTTPError, surfacing it as a*StatusErrorin the chain.Retry-After is honored.
pkg/tools/mcp/oauth.go'soauthTransportnow also captures the rawRetry-Afterheader value alongside the status/body it already tracked.lastServerErrorSnapshot()reads status, message, and Retry-After together under a single lock (not three separate accessor calls) so a caller can never pair a status from one response with a Retry-After header captured from a different concurrent response on the same transport — this transport'sRoundTripcan run concurrently for one logical connect attempt (e.g. a standalone SSE probe racing the initialize call).enrichConnectErrorbuilds a minimal*http.Responsecarrying that header and passes it toWrapHTTPError, matching the handling already in place for model-provider adapters (PR #4062).What arms the gate.
startBackoffRetryablechecks for a*modelerrors.StatusErrorwith a retryable HTTP status (429/408/5xx) viaerrors.As— exactly as it already does for RAG embedding failures. No regex heuristics; no new classification logic in the gate itself.What does NOT arm (unchanged policy):
enrichConnectError.*StatusErrorfor structured access butRetryableHTTPStatusreturns false → fail promptly.oauthDeclined,authorizationRequired) — handled by their own early-return paths before the status branch; unaffected.lifecycle.ErrServerUnavailable,ErrTransport,ErrAuthRequired,ErrInitTimeout,ErrSessionMissing— the gate classifier explicitly excludes all of these.Deferred:
lifecycle.ErrServerCrashedis produced only insidelspSession.Wait()which flows to the supervisor's internal watcher, not tosupervisor.Start(). The gate never sees it via the current error propagation path; deferred.Changed files
pkg/tools/mcp/remote.goenrichConnectError: wrap on status alone (not gated on a non-empty body); forwardsRetry-Aftervia a minimal synthetic*http.Responsepkg/tools/mcp/oauth.gooauthTransportcaptures the rawRetry-Afterheader; newlastServerErrorSnapshot()reads status/message/Retry-After together under one lockpkg/tools/startable_backoff.goErrInitTimeout,ErrSessionMissing), notes the deferred LSP crash-loop pathpkg/tools/mcp/remote_test.go*StatusError; 403 → non-retryable; empty-body 503/429 still arm;Retry-Afterpresent/absent; end-to-endTestBackoffGate_*driving a realNewRemoteToolsetthroughtools.StartableToolSet.TryStartpkg/tools/startable_backoff_test.goErrServerUnavailable,ErrTransport,ErrAuthRequired) do NOT arm the gate; 4xxStatusErrordoes NOT arm the gatedocs/tools/mcp/index.mddocs/tools/lsp/index.md