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
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). 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. Deliberately excluded (still fast-retry or fail-promptly): 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. Refs #4060 (partial — A2A pacing deferred: agent-card resolver does not expose HTTP status cleanly; LSP crash-loop pacing also deferred)
d99ac16 to
608a099
Compare
aheritier
left a comment
There was a problem hiding this comment.
🤖 Automated implementer agent — this comment was posted by the implementer bot from Docker Agentic Platform, not by a human developer
Reviewed at head 608a0991 (this branch is stacked on #4062 and was cascade-rebased when that branch rebased onto a fresher main; content is unchanged by that rebase — single commit, same diff). This PR extends the #4060 backoff gate to remote MCP HTTP errors.
Since this stacked PR only runs a subset of CI (6 checks — canonical-check, link-check, llms-txt-check, markdownlint, pa11y, validate-upstream, all passing; the full build-and-test/lint/windows-tests jobs appear scoped to PRs targeting main), I re-validated locally on the current head:
go build ./pkg/tools/... ./pkg/tools/mcp/... # clean
go test ./pkg/tools/... ./pkg/tools/mcp/... -count=1 # all green
golangci-lint run ./pkg/tools/ ./pkg/tools/mcp/ # 0 issues
mergeable=MERGEABLE; mergeStateStatus=BLOCKED solely on the missing required approving review.
What this fixes
Remote MCP servers returning 503/429/5xx during the initialize handshake previously triggered a fresh connect attempt on every agent turn (the #4060 burst pattern, extended to MCP). enrichConnectError (pkg/tools/mcp/remote.go:205) now wraps the HTTP status captured by oauthTransport in modelerrors.WrapHTTPError, so retryable responses surface as *StatusError and arm the existing StartableToolSet backoff gate.
Verified
Wrap happens on status alone, not gated on a non-empty body. Many rate-limit/load-balancer responses carry no body. enrichConnectError (remote.go:215) checks status != 0 only; TestEnrichConnectError_EmptyBodyStatusStillArms (remote_test.go:1218) drives a real httptest.Server returning an empty-body 503/429 and confirms the *StatusError still arms.
4xx does not arm. TestEnrichConnectError_NonRetryableStatusDoesNotArm (403 → *StatusError wrapped but RetryableHTTPStatus false) and TestStartBackoffRetryable_4xxStatusDoesNotArm (classifier-level) both pass.
Retry-After is honored and thread-safe. oauthTransport (pkg/tools/mcp/oauth.go) now captures the raw Retry-After header alongside status/body; lastServerErrorSnapshot() (oauth.go:912, 100% covered) reads all three under a single lock so a concurrent request on the same transport (e.g. a standalone SSE probe racing the initialize call) can't have its Retry-After paired with a different response's status. TestEnrichConnectError_RetryAfterHonoured / _NoRetryAfterHeaderLeavesZero cover both the present and absent cases.
End-to-end coverage, not just the enrichConnectError unit boundary. TestBackoffGate_RemoteMCPRetryableStatusPacesReconnect / _NonRetryableStatusFailsPromptly (remote_test.go:1318, :1371) build a real *mcp.Toolset via NewRemoteToolset (matching production wiring), wrap it in tools.NewStartable, and drive TryStart against a mock server — proving the whole chain (enrichConnectError → Toolset.Start → supervisor.Start → the gate) stays intact, and that a 403 reaches the server every turn with no pacing.
Classifier scope is precise and honestly documented. startBackoffRetryable (pkg/tools/startable_backoff.go:35) arms only on *modelerrors.StatusError. The doc comment explicitly excludes lifecycle.ErrServerUnavailable, ErrTransport, ErrAuthRequired/ErrCapabilityMissing, ErrInitTimeout, ErrSessionMissing — each has a dedicated non-arming test (startable_backoff_test.go:763,778,792). Notably, the doc comment and docs/tools/lsp/index.md are both explicit that lifecycle.ErrServerCrashed-based LSP crash-loop pacing is not yet wired (the sentinel is produced only in the supervisor's internal watcher goroutine, not surfaced by supervisor.Start()) — this PR does not overclaim behaviour it doesn't deliver, and defers it honestly rather than shipping dead-code coverage for it (an earlier draft of this PR did include that dead arm; it was caught in review and removed).
Coverage: go tool cover -func on ./pkg/tools/mcp/ shows both enrichConnectError and lastServerErrorSnapshot at 100%.
Deferred (documented, not silently dropped)
- A2A pacing — the agent-card resolver doesn't cleanly expose HTTP status.
- LSP crash-loop pacing — needs
ErrServerCrashedpropagated throughsupervisor.Start()first.
PR body/description updated to reflect the full accumulated diff (oauth.go changes, Retry-After handling, and all 8 new MCP tests were missing from the original description; the "Changed files" table and Design section now match the current commit).
No blocking findings. Filing as a comment rather than a formal approval only because I authored the PR and GitHub blocks self-approval for the author, matching the pattern on #4062.
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