feat(target-health): track MCP and agent target health

Add a per-replica, in-memory health tracker for MCP servers and A2A
agents (target_health.go). The tracker records observations from real
proxied traffic (handleTargetProxy, mcp_a2a.go) and federated calls
(mcpFederatedToolsList/mcpFederatedToolsCall, mcp_federation.go), and
derives a three-state readout (unknown, healthy, unhealthy) from a
configurable consecutive-failure threshold. Add TargetHealthConfig to
Config, validated at construction. The tracker never gates routing:
federation and the target proxy keep contacting every configured
server regardless of its recorded health.

Extend proxyResult (routes_passthrough.go) with status and
clientCanceled so handleTargetProxy can classify a proxied response
without proxyUpstream knowing anything about target health.

Add maybeSweepTargetHealth, an opt-in, lazy, single-flight active
probe sweep with no timer and no long-lived goroutine, matching this
plugin's lack of a Close hook under Traefik hot reload. A probe uses
the initialize/close handshake for a Streamable HTTP MCP server, a
plain unread GET for a legacy /sse server, and a GET of the agent card
path for an A2A agent. handleMCPServers and handleAgents now trigger a
sweep at the top of each call.

Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w
This commit is contained in:
2026-09-03 01:42:21 +01:00
parent 4bb49b4fe9
commit d6eebd5a84
8 changed files with 1413 additions and 11 deletions
+24 -5
View File
@@ -910,8 +910,27 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u
// nil unless accountJSON was true and the response's Content-Type was
// application/json.
type proxyResult struct {
tee *cappedAccountingBuffer
tee *cappedAccountingBuffer
// status is the upstream response's own status code, once headers
// were actually written to w — 0 when proxyUpstream returned before
// ever writing them (a build/connection failure, or a client cancel
// before the request was even sent). Additive field for
// feat/target-health's passive recording (recordTargetProxyHealth,
// target_health.go): ok=false alone conflates "the upstream never
// answered at all" with "it answered with a 5xx", which that
// feature's failure rule needs to distinguish. Field order (pointer,
// then int, then the two bools) is fieldalignment-sensitive, the same
// convention this package's other structs already follow.
status int
isJSON bool
// clientCanceled is true exactly when either of proxyUpstream's own
// errors.Is(err, context.Canceled) branches fired — the CLIENT, not
// the upstream, ended the request, whether before the upstream call
// was even sent or mid-copy of an already-started response.
// feat/target-health's passive recording must record NOTHING for
// this case (handleTargetProxy's own doc comment, mcp_a2a.go): a
// client hanging up says nothing about the target's own health.
clientCanceled bool
}
// proxyUpstream is the shared reverse-proxy core behind both native
@@ -1054,7 +1073,7 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
cancel()
if errors.Is(err, context.Canceled) {
g.logf("%s: client canceled request: %v", logPrefix, err)
return proxyResult{}, false
return proxyResult{clientCanceled: true}, false
}
g.errorf("%s: upstream connection error: %v", logPrefix, err)
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
@@ -1099,10 +1118,10 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
// — the latter falls through to the errorf below, unchanged.
if errors.Is(err, context.Canceled) {
g.logf("%s: client canceled request: %v", logPrefix, err)
return proxyResult{isJSON: isJSON, tee: tee}, false
return proxyResult{isJSON: isJSON, tee: tee, status: resp.StatusCode, clientCanceled: true}, false
}
g.errorf("%s: stream response body: %v", logPrefix, err)
return proxyResult{isJSON: isJSON, tee: tee}, false
return proxyResult{isJSON: isJSON, tee: tee, status: resp.StatusCode}, false
}
return proxyResult{isJSON: isJSON, tee: tee}, true
return proxyResult{isJSON: isJSON, tee: tee, status: resp.StatusCode}, true
}