mcp: add configurable keepalive failure threshold (#982)

mcp: add configurable keepalive failure threshold

Introduce `KeepAliveFailureThreshold` option in both `ClientOptions` and
`ServerOptions` to control how many consecutive keepalive ping failures
are tolerated before closing the session.

This aligns with the MCP spec's guidance that "multiple failed pings MAY
trigger a connection reset," allowing operators to tune resilience
against transient network hiccups without immediately tearing down
otherwise healthy sessions.

A threshold of 0 or 1 (the default) closes on the first failure,
preserving existing behavior. Higher values let isolated misses pass
while still closing the session once consecutive failures reach the
threshold. A successful ping resets the counter.

Tolerated failures are logged at WARN level; the final failure that
closes the session is logged at ERROR level.

This is rework of #979.
This commit is contained in:
Tomas Dabašinskas 2026-05-31 11:25:49 +03:00 • committed by GitHub
commit 8805aa85f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -751,9 +751,20 @@ type keepaliveSession interface {
// It assigns the cancel function to the provided cancelPtr and starts a goroutine
// that sends ping messages at the specified interval.
//
// logger must be non-nil; ping failures (which terminate the keepalive loop and
// close the session) are reported via logger so they are not silently dropped.
func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc, logger *slog.Logger) {
// failureThreshold is the number of consecutive ping failures tolerated before
// the session is closed; a value below 1 is treated as 1 (close on the first
// failure). A successful ping resets the counter. This mirrors the spec's
// "multiple failed pings MAY trigger a connection reset" language, letting a
// transient miss pass without tearing down an otherwise live session.
//
// logger must be non-nil; ping failures (both the tolerated ones and the final
// one that closes the session) are reported via logger so they are not silently
// dropped.
func startKeepalive(session keepaliveSession, interval time.Duration, failureThreshold int, cancelPtr *context.CancelFunc, logger *slog.Logger) {
if failureThreshold < 1 {
failureThreshold = 1
}
ctx, cancel := context.WithCancel(context.Background())
// Assign cancel function before starting goroutine to avoid race condition.
// We cannot return it because the caller may need to cancel during the
@ -764,6 +775,7 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr
ticker := time.NewTicker(interval)
defer ticker.Stop()
consecutiveFailures := 0
for {
select {
case <-ctx.Done():
@ -772,17 +784,32 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr
pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
err := session.Ping(pingCtx, nil)
pingCancel()
if err != nil {
if errors.Is(err, jsonrpc2.ErrMethodNotFound) {
// Peer doesn't support ping, stop the keepalive process.
return
}
// Ping failed; log it before closing the session so the
// failure is observable to operators. See #218.
logger.Error("keepalive ping failed; closing session", "error", err)
_ = session.Close()
if err == nil {
consecutiveFailures = 0
continue
}
if errors.Is(err, jsonrpc2.ErrMethodNotFound) {
// Peer doesn't support ping, stop the keepalive process.
return
}
consecutiveFailures++
if consecutiveFailures < failureThreshold {
// Tolerate transient failures below the threshold; log so
// the misses are still observable to operators. See #218.
logger.Warn("keepalive ping failed; tolerating below threshold",
"error", err,
"consecutiveFailures", consecutiveFailures,
"failureThreshold", failureThreshold)
continue
}
// Threshold reached; log before closing the session so the
// failure is observable to operators. See #218.
logger.Error("keepalive ping failed; closing session",
"error", err,
"consecutiveFailures", consecutiveFailures,
"failureThreshold", failureThreshold)
_ = session.Close()
return
}
}
}()