Skip to content

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

Merged
aheritier merged 1 commit into
mainfrom
fix/startable-toolset-backoff-mcp-lsp
Sep 3, 2026
Merged

aheritier merged 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.

@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
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)
dgageot pushed a commit that referenced this pull request Sep 3, 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
@dgageot
dgageot force-pushed the fix/startable-toolset-backoff-mcp-lsp branch from baade77 to 34bd465 Compare September 3, 2026 07:15
aheritier added a commit that referenced this pull request Sep 3, 2026
Refs #4099 (follow-up to #4062/#4074): lifecycle.ErrServerCrashed was only
ever produced inside the Supervisor's internal watch() goroutine and never
surfaced through Start()'s return value, so a persistently-crashing LSP
server relaunched at full speed forever — the watcher's own backoff resets
to its base delay on every successful reconnect, even when the process
dies again moments later.

Crash-loop detection (pkg/tools/lifecycle/supervisor.go):
- New Policy.CrashLoop{Threshold, Window} (default 3 crashes / 1 minute).
  watch() records a timestamp only for an actual crash — not a forced
  RestartAndWait close, not a clean (nil) exit — pruning entries outside
  Window on every record. A single crash, or crashes spread wider than
  Window apart, are still handled entirely by the ordinary Restart/Backoff
  policy and never reach this detector, preserving the existing "one-off
  crash restarts silently" behaviour.
- Once Threshold is reached, watch() gives up (state -> Failed) instead of
  calling tryRestart again, and records a one-shot lifecycle.ErrCrashLooping
  (wraps the triggering ErrServerCrashed) on the supervisor. If a Stop
  lands in the narrow window between that decision and the record (raced
  concurrently), Stop's StateStopped wins instead of being clobbered back
  to Failed.
- Start() consumes that one-shot report before attempting a connect: the
  call right after detection returns ErrCrashLooping without reconnecting,
  and the Start after that attempts a genuine reconnect. This one-shot
  handshake exists because only the caller (StartableToolSet's gate) knows
  when enough time has passed to retry for real — the supervisor itself
  has no notion of the gate's pacing.
- PendingCrashLoopError() exposes the same report as a non-consuming peek,
  for callers that reach the supervisor outside the gate (see below).
- Stop() discards any pending report and crash history as part of
  teardown.

Classification (pkg/tools/startable_backoff.go) — Option A from #4099:
startBackoffRetryable now arms directly on errors.Is(err,
lifecycle.ErrCrashLooping), alongside the existing *modelerrors.StatusError
branch. Chosen over synthesizing a StatusError because the supervisor has
already done the "is this really a loop" judgment; forcing it through an
HTTP-status-shaped signal would be a lossy, indirect encoding of a
non-HTTP condition, and StatusError's Retry-After handling doesn't apply
here anyway. A bare ErrServerCrashed (not escalated to ErrCrashLooping)
deliberately still does NOT arm the gate — that fast, unpaced path
belongs to the supervisor's own restart policy, matching the existing
"deliberately excluded" list for ErrServerUnavailable/ErrTransport/etc.

Wiring (pkg/tools/builtin/lsp/lsp.go):
- The LSP ToolSet did not implement tools.StartReporter, so once
  StartableToolSet.startLocked latched started=true after the first
  successful Start, it never called the underlying toolset again — the
  supervisor's own watcher and ensureInitialized's eager per-request
  reconnects were the only recovery paths, both invisible to the gate.
  Adding IsStarted() = !State.IsTerminal() fixes this exactly for give-up
  cases (crash loop, or the pre-existing max-attempts exhaustion) while
  leaving ordinary transient Restarting alone: unlike the MCP toolset's
  IsStarted (Ready/Degraded only), LSP treats Restarting as still
  "started" so a one-off crash mid-auto-heal does not force
  StartableToolSet into Restart()'s 35s RestartAndWait path on every
  turn's pre-warm — only a full give-up (Failed/Stopped) asks the wrapper
  to get involved, which is exactly when the gate needs to pace. This also
  means a `strict`-profile LSP toolset (RestartNever) is now retried on
  the next turn after any failure rather than staying down until an
  explicit /toolset-restart — consistent with MCP's existing behaviour,
  documented in docs/tools/lsp/index.md.
- ensureInitialized's lazy per-request Start() call bypasses
  StartableToolSet's gate entirely (it isn't the wrapper's paced
  TryStart), so it must not be the one to consume a one-shot crash-loop
  report on the gate's behalf. It now checks PendingCrashLoopError first
  and fails fast without reconnecting, leaving the report for the gate's
  own Start call to consume once its window elapses.
- Known limitation, pre-existing and out of scope here: a raw crash
  (detected only in the background watcher) does not clear the handler's
  atomic `initialized` fast-path flag — only a fresh Connect or an
  explicit Close does — so a tool call arriving immediately after a
  crash (loop or not) can still observe a stale "initialized" session and
  attempt to use it before ensureInitialized's slow path (and the check
  above) ever runs. This affects the ordinary exhausted-restart give-up
  identically and predates this change; fixing it needs its own look at
  the crash-detection/session-teardown interaction.

Tests:
- pkg/tools/lifecycle/supervisor_test.go: TestSupervisor_CrashLoopStops-
  RestartingAndReportsOnStart (3rd crash trips the loop, no further
  Connect until the next Start, then a genuine reconnect),
  TestSupervisor_CrashLoopIgnoresCleanDisconnects, TestSupervisor_Crash-
  LoopIgnoresForcedRestart, TestSupervisor_CrashLoopStopIsClean,
  TestSupervisor_CrashLoopWindowPrunesOldCrashes (crashes spread wider
  than Window never accumulate), TestSupervisor_PendingCrashLoopError-
  IsNonConsuming, TestSupervisor_CrashLoopStopWinningRaceReportsStopped
  (regression test for the Stop/crash-loop race above).
- pkg/tools/startable_backoff_test.go: TestStartBackoffRetryable_Err-
  ServerCrashed (bare crash does not arm), TestStartBackoffRetryable_Err-
  CrashLooping (arms and re-attempts after the window, via TryStart).
- pkg/tools/builtin/lsp/lsp_crashloop_test.go (new): end-to-end
  TestLSPTool_CrashLoopArmsBackoffGate drives a real ToolSet wrapped in
  StartableToolSet (matching production wiring) against a fake LSP server
  subprocess (this test binary re-executed via TestMain, portable across
  the Linux/Windows CI matrix) that completes the handshake and exits 1
  every time; proves the whole chain end to end via the exact spawn
  count. TestLSPTool_CrashLoopPendingReportBlocksDirectToolCalls proves
  the ensureInitialized fast-path check above.

docs/tools/lsp/index.md: replaced the "not currently paced" caveat with a
precise description of the new behaviour, including the strict-profile
retry note above.
@aheritier
aheritier merged commit c72cf18 into main Sep 3, 2026
19 checks passed
@aheritier
aheritier deleted the fix/startable-toolset-backoff-mcp-lsp branch September 3, 2026 07:53
aheritier added a commit that referenced this pull request Sep 3, 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 added a commit that referenced this pull request Sep 3, 2026
Refs #4099 (follow-up to #4062/#4074): lifecycle.ErrServerCrashed was only
ever produced inside the Supervisor's internal watch() goroutine and never
surfaced through Start()'s return value, so a persistently-crashing LSP
server relaunched at full speed forever — the watcher's own backoff resets
to its base delay on every successful reconnect, even when the process
dies again moments later.

Crash-loop detection (pkg/tools/lifecycle/supervisor.go):
- New Policy.CrashLoop{Threshold, Window} (default 3 crashes / 1 minute).
  watch() records a timestamp only for an actual crash — not a forced
  RestartAndWait close, not a clean (nil) exit — pruning entries outside
  Window on every record. A single crash, or crashes spread wider than
  Window apart, are still handled entirely by the ordinary Restart/Backoff
  policy and never reach this detector, preserving the existing "one-off
  crash restarts silently" behaviour.
- Once Threshold is reached, watch() gives up (state -> Failed) instead of
  calling tryRestart again, and records a one-shot lifecycle.ErrCrashLooping
  (wraps the triggering ErrServerCrashed) on the supervisor. If a Stop
  lands in the narrow window between that decision and the record (raced
  concurrently), Stop's StateStopped wins instead of being clobbered back
  to Failed.
- Start() consumes that one-shot report before attempting a connect: the
  call right after detection returns ErrCrashLooping without reconnecting,
  and the Start after that attempts a genuine reconnect. This one-shot
  handshake exists because only the caller (StartableToolSet's gate) knows
  when enough time has passed to retry for real — the supervisor itself
  has no notion of the gate's pacing.
- PendingCrashLoopError() exposes the same report as a non-consuming peek,
  for callers that reach the supervisor outside the gate (see below).
- Stop() discards any pending report and crash history as part of
  teardown.

Classification (pkg/tools/startable_backoff.go) — Option A from #4099:
startBackoffRetryable now arms directly on errors.Is(err,
lifecycle.ErrCrashLooping), alongside the existing *modelerrors.StatusError
branch. Chosen over synthesizing a StatusError because the supervisor has
already done the "is this really a loop" judgment; forcing it through an
HTTP-status-shaped signal would be a lossy, indirect encoding of a
non-HTTP condition, and StatusError's Retry-After handling doesn't apply
here anyway. A bare ErrServerCrashed (not escalated to ErrCrashLooping)
deliberately still does NOT arm the gate — that fast, unpaced path
belongs to the supervisor's own restart policy, matching the existing
"deliberately excluded" list for ErrServerUnavailable/ErrTransport/etc.

Wiring (pkg/tools/builtin/lsp/lsp.go):
- The LSP ToolSet did not implement tools.StartReporter, so once
  StartableToolSet.startLocked latched started=true after the first
  successful Start, it never called the underlying toolset again — the
  supervisor's own watcher and ensureInitialized's eager per-request
  reconnects were the only recovery paths, both invisible to the gate.
  Adding IsStarted() = !State.IsTerminal() fixes this exactly for give-up
  cases (crash loop, or the pre-existing max-attempts exhaustion) while
  leaving ordinary transient Restarting alone: unlike the MCP toolset's
  IsStarted (Ready/Degraded only), LSP treats Restarting as still
  "started" so a one-off crash mid-auto-heal does not force
  StartableToolSet into Restart()'s 35s RestartAndWait path on every
  turn's pre-warm — only a full give-up (Failed/Stopped) asks the wrapper
  to get involved, which is exactly when the gate needs to pace. This also
  means a `strict`-profile LSP toolset (RestartNever) is now retried on
  the next turn after any failure rather than staying down until an
  explicit /toolset-restart — consistent with MCP's existing behaviour,
  documented in docs/tools/lsp/index.md.
- ensureInitialized's lazy per-request Start() call bypasses
  StartableToolSet's gate entirely (it isn't the wrapper's paced
  TryStart), so it must not be the one to consume a one-shot crash-loop
  report on the gate's behalf. It now checks PendingCrashLoopError first
  and fails fast without reconnecting, leaving the report for the gate's
  own Start call to consume once its window elapses.
- Known limitation, pre-existing and out of scope here: a raw crash
  (detected only in the background watcher) does not clear the handler's
  atomic `initialized` fast-path flag — only a fresh Connect or an
  explicit Close does — so a tool call arriving immediately after a
  crash (loop or not) can still observe a stale "initialized" session and
  attempt to use it before ensureInitialized's slow path (and the check
  above) ever runs. This affects the ordinary exhausted-restart give-up
  identically and predates this change; fixing it needs its own look at
  the crash-detection/session-teardown interaction.

Tests:
- pkg/tools/lifecycle/supervisor_test.go: TestSupervisor_CrashLoopStops-
  RestartingAndReportsOnStart (3rd crash trips the loop, no further
  Connect until the next Start, then a genuine reconnect),
  TestSupervisor_CrashLoopIgnoresCleanDisconnects, TestSupervisor_Crash-
  LoopIgnoresForcedRestart, TestSupervisor_CrashLoopStopIsClean,
  TestSupervisor_CrashLoopWindowPrunesOldCrashes (crashes spread wider
  than Window never accumulate), TestSupervisor_PendingCrashLoopError-
  IsNonConsuming, TestSupervisor_CrashLoopStopWinningRaceReportsStopped
  (regression test for the Stop/crash-loop race above).
- pkg/tools/startable_backoff_test.go: TestStartBackoffRetryable_Err-
  ServerCrashed (bare crash does not arm), TestStartBackoffRetryable_Err-
  CrashLooping (arms and re-attempts after the window, via TryStart).
- pkg/tools/builtin/lsp/lsp_crashloop_test.go (new): end-to-end
  TestLSPTool_CrashLoopArmsBackoffGate drives a real ToolSet wrapped in
  StartableToolSet (matching production wiring) against a fake LSP server
  subprocess (this test binary re-executed via TestMain, portable across
  the Linux/Windows CI matrix) that completes the handshake and exits 1
  every time; proves the whole chain end to end via the exact spawn
  count. TestLSPTool_CrashLoopPendingReportBlocksDirectToolCalls proves
  the ensureInitialized fast-path check above.

docs/tools/lsp/index.md: replaced the "not currently paced" caveat with a
precise description of the new behaviour, including the strict-profile
retry note above.
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