Skip to content

fix(#4098): extend backoff gate to A2A agent-card HTTP errors - #4104

Open
aheritier wants to merge 1 commit into
fix/startable-toolset-backoff-mcp-lspfrom
fix/startable-toolset-backoff-a2a
Open

fix(#4098): extend backoff gate to A2A agent-card HTTP errors#4104
aheritier wants to merge 1 commit into
fix/startable-toolset-backoff-mcp-lspfrom
fix/startable-toolset-backoff-a2a

Conversation

@aheritier

Copy link
Copy Markdown
Collaborator

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

Extends the StartableToolSet backoff gate (#4062, #4074) to A2A agent-card resolution failures — the piece #4074 explicitly deferred because the agent-card resolver "didn't cleanly expose HTTP status." That deferral rationale turned out to be wrong: agentcard.Resolver.Resolve already returns *agentcard.ErrStatusNotOK{StatusCode, Status} on any non-200 response. enrichCardError (new pkg/tools/a2a/carderror.go) translates that into *modelerrors.StatusError, mirroring enrichConnectError's handling of remote MCP HTTP errors.

The one thing the resolver genuinely doesn't expose is the Retry-After header (it discards the *http.Response). retryAfterRecorder, a minimal http.RoundTripper installed only in front of the card-resolution GET, recovers it — it never reaches the JSON-RPC transport chain built afterwards.

Arming set (unchanged fixed enumeration from #4062/#4074): 429, 408, 500, 502, 503, 504, 529 — not a full 5xx range (501, 505, Cloudflare 520–527 excluded). Fails promptly, no pacing: DNS failures, connection refused, SSRF-blocked private-IP targets, malformed/unparsable agent cards, and other 4xx (bad auth/config). Only the startup agent-card fetch is paced — per-call SendStreamingMessage failures on an already-started toolset are not.

Changed files

File Change
pkg/tools/a2a/carderror.go New: retryAfterRecorder + enrichCardError
pkg/tools/a2a/a2a.go Wire recorder + enrichCardError into Toolset.Start
pkg/tools/a2a/carderror_test.go New: retryable/non-retryable status tables, no-status cases, Retry-After tests
pkg/tools/a2a/backoff_test.go New: end-to-end StartableToolSet.TryStart gate tests (arm/block/reopen/recover)
docs/tools/a2a/index.md New "Startup failure behaviour" section
docs/tools/rag/index.md Cross-reference note now names A2A alongside remote MCP

Testing

task build && task test && task lint all pass (this sandbox lacked task/golangci-lint/gcc; installed task v3.53.1, golangci-lint v2.13.1 via go install, and gcc/build-essential via apt so pkg/rag/treesitter's CGO-gated tests run — CGO_ENABLED=1 was set for task test). Independent review (self-review pattern per #4062/#4074, aheritier identity) posted below.

Refs #4060, #4074, #4098

aheritier

This comment was marked as resolved.

@aheritier aheritier changed the title fix: extend backoff gate to A2A agent-card HTTP errors (#4098) fix(#4098): extend backoff gate to A2A agent-card HTTP errors Sep 1, 2026
@aheritier
aheritier force-pushed the fix/startable-toolset-backoff-a2a branch from 86d4c09 to 976f7b1 Compare September 1, 2026 21:02
@aheritier
aheritier marked this pull request as ready for review September 1, 2026 21:02
@aheritier
aheritier requested a review from a team as a code owner September 1, 2026 21:02
@aheritier

This comment was marked as resolved.

@aheritier aheritier added area/docs Documentation changes area/testing Test infrastructure, CI/CD, test runners, evaluation 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. labels Sep 1, 2026
@aheritier
aheritier force-pushed the fix/startable-toolset-backoff-a2a branch from 976f7b1 to 8c64d79 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.

Independent review — no blocking issues (CI verified green)

Reviewed head 8c64d794 against base fix/startable-toolset-backoff-mcp-lsp (three-dot diff, 6 files, +434/−6).

CI: all 20 live check-runs for this SHA succeeded (ci: build-and-test, lint, windows-tests, build-image ×2, license-check; CodeQL: Analyze go/actions/js; docs-lint/a11y/upstream). Note for anyone reading the PR body's "CI is green" with the stacked base: ci.yml/codeql.yml use an unfiltered pull_request: trigger, so Go checks run for feature-branch bases too — verified.

Local validation (throwaway checkout): go build ./..., go vet, go test -count=1 -race ./pkg/tools/a2a/ ./pkg/tools/ ./pkg/modelerrors/, golangci-lint run — all clean; -race -count=5 stable. Mutation check via go test -overlay: 4/4 mutants killed (no StatusError wrap; Retry-After dropped; recorder threshold >=500; wrong status forwarded) — the new tests are load-bearing.

#4098 acceptance criteria:

  • Card-error origin identified: agentcard.Resolver.Resolve returns *ErrStatusNotOK{StatusCode} unwrapped; consumed structurally via errors.As (carderror.go:39-41) — no reliance on err.Error() text or the modelerrors regex fallback.
  • Retryable statuses (429/408/500/502/503/504/529) → *modelerrors.StatusError (carderror.go:44-53, wired at a2a.go:198); fixed enumeration, 501 proven excluded.
  • Non-HTTP failures (ctx cancel, DNS, conn-refused, SSRF-blocked, malformed 200 body) fall through unwrapped → fail fast; 4xx wrapped but non-retryable → fail fast (carderror_test.go:48-134, backoff_test.go:74-95).
  • End-to-end gate tests through real tools.NewStartable(...).TryStart: arm → block → reopen (backoff_test.go:29-70) and recover to a working a2asrv handshake (:104-177).
  • Retry-After honoured with a status-matched recorder (carderror.go:23-29, 48-51; tests carderror_test.go:141-184).

Parity with #4074: recorder is a local around a fresh *http.Client; JSON-RPC transport is rebuilt from the saved base (a2a.go:212), so the per-call SendStreamingMessage path is unchanged. docs/tools/rag/index.md overlap with #4074 is additive on top of #4074's own lines — rebase onto main after #4074 merges will be clean.

[optional] carderror.go:49 — the status == statusErr.StatusCode guard is defensive only (the resolver issues a single GET; redirect hops are <400 and never recorded), so that branch is unreachable/untested. Fine to keep.

Verdict: approve-equivalent. GitHub blocks self-approval for the PR author, so this is posted as a comment. Merge gating: retarget to main once #4074 lands rather than merging into the feature branch.

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/testing Test infrastructure, CI/CD, test runners, evaluation 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.

1 participant