Skip to content

fix(#4060): extend backoff gate to remote MCP HTTP errors - #4074

Open
aheritier wants to merge 1 commit into
mainfrom
fix/startable-toolset-backoff-mcp-lsp
Open

fix(#4060): extend backoff gate to remote MCP HTTP errors#4074
aheritier wants to merge 1 commit into
mainfrom
fix/startable-toolset-backoff-mcp-lsp

Conversation

@aheritier

@aheritier aheritier commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

🤖 Automated implementer agentthis comment was posted by the implementer bot from Docker Agentic Platform, not by a human developer

Stacked on PR #4062. Base retargets to main once 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.StatusError so the StartableToolSet backoff gate arming logic can pace retries — including a server-supplied Retry-After hint, and correctly for responses with no body.

Design

Wrap point — enrichConnectError in pkg/tools/mcp/remote.go. The oauthTransport already records the last HTTP error status via logErrorResponse (for any >= 400 response). enrichConnectError wraps that status (regardless of whether the response carried a body — many rate-limit/load-balancer responses don't) via modelerrors.WrapHTTPError, surfacing it as a *StatusError in the chain.

Retry-After is honored. pkg/tools/mcp/oauth.go's oauthTransport now also captures the raw Retry-After header 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's RoundTrip can run concurrently for one logical connect attempt (e.g. a standalone SSE probe racing the initialize call). enrichConnectError builds a minimal *http.Response carrying that header and passes it to WrapHTTPError, matching the handling already in place for model-provider adapters (PR #4062).

What arms the gate. startBackoffRetryable checks for a *modelerrors.StatusError with a retryable HTTP status (429/408/5xx) via errors.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):

  • Local stdio MCP spawn failures (missing binary, connection refused) — never reach enrichConnectError.
  • 4xx client errors (400/401/403) — wrapped in *StatusError for structured access but RetryableHTTPStatus returns false → fail promptly.
  • Auth paths (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:

  • A2A pacing: the agent-card resolver does not cleanly expose HTTP status in its error chain; deferred to a follow-up.
  • LSP crash-loop pacing: lifecycle.ErrServerCrashed is produced only inside lspSession.Wait() which flows to the supervisor's internal watcher, not to supervisor.Start(). The gate never sees it via the current error propagation path; deferred.

Changed files

File What changed
pkg/tools/mcp/remote.go enrichConnectError: wrap on status alone (not gated on a non-empty body); forwards Retry-After via a minimal synthetic *http.Response
pkg/tools/mcp/oauth.go oauthTransport captures the raw Retry-After header; new lastServerErrorSnapshot() reads status/message/Retry-After together under one lock
pkg/tools/startable_backoff.go Classifier doc comment: complete excluded-sentinel list (adds ErrInitTimeout, ErrSessionMissing), notes the deferred LSP crash-loop path
pkg/tools/mcp/remote_test.go 8 tests: 503/429/500 → retryable *StatusError; 403 → non-retryable; empty-body 503/429 still arm; Retry-After present/absent; end-to-end TestBackoffGate_* driving a real NewRemoteToolset through tools.StartableToolSet.TryStart
pkg/tools/startable_backoff_test.go 4 tests: lifecycle sentinels (ErrServerUnavailable, ErrTransport, ErrAuthRequired) do NOT arm the gate; 4xx StatusError does NOT arm the gate
docs/tools/mcp/index.md Startup-failure note: remote MCP 5xx/429 now paced
docs/tools/lsp/index.md Startup-failure note: crash-loop pacing not yet in place; supervisor policy is the throttle

@aheritier aheritier changed the title fix: extend backoff gate to remote MCP HTTP errors (#4060 follow-up) fix(#4060): extend backoff gate to remote MCP HTTP errors Aug 28, 2026
@aheritier
aheritier requested a review from docker-agent August 28, 2026 21:24
@aheritier
aheritier marked this pull request as ready for review August 28, 2026 21:24
@aheritier
aheritier requested a review from a team as a code owner August 28, 2026 21:24
@aheritier aheritier added area/docs Documentation changes area/tools For features/issues/fixes related to the usage of built-in and MCP tools status/needs-triage For issues that need to be triaged kind/fix PR fixes a bug (maps to fix:). Use on PRs only. and removed status/needs-triage For issues that need to be triaged labels Aug 28, 2026
aheritier added a commit that referenced this pull request Aug 28, 2026
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.
@aheritier
aheritier force-pushed the fix/startable-toolset-backoff-mcp-lsp branch from 6267a9e to d99ac16 Compare August 28, 2026 22:49
@aheritier
aheritier force-pushed the fix/startable-toolset-backoff-mcp-lsp branch from d99ac16 to 608a099 Compare August 30, 2026 17:04
aheritier

This comment was marked as resolved.

aheritier

This comment was marked as resolved.

dgageot
dgageot previously approved these changes Sep 1, 2026
aheritier added a commit that referenced this pull request Sep 1, 2026
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
Base automatically changed from fix/startable-toolset-backoff to main September 1, 2026 21:19
aheritier added a commit that referenced this pull request Sep 1, 2026
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
@aheritier
aheritier force-pushed the fix/startable-toolset-backoff-mcp-lsp branch from 0341872 to 01f4bf5 Compare September 1, 2026 21:19

@aheritier aheritier left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
@aheritier
aheritier force-pushed the fix/startable-toolset-backoff-mcp-lsp branch from 01f4bf5 to baade77 Compare September 1, 2026 21:44
aheritier added a commit that referenced this pull request Sep 1, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation changes area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants