-
Notifications
You must be signed in to change notification settings - Fork 277
Skip known-bad backends when opening vMCP sessions #6162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
73108de
Skip known-bad backends when opening sessions
jerm-dro e8f50bd
Fail open when backend health is not yet known
jerm-dro edf59da
Pin the two health predicates against every status
jerm-dro 8138c4d
Merge remote-tracking branch 'origin/main' into jerm-dro/gate-session…
jerm-dro cc74536
Attempt degraded backends when opening sessions
jerm-dro 7cfae83
Merge remote-tracking branch 'origin/main' into jerm-dro/gate-session…
jerm-dro dc1cde5
Honour cancellation in the slow test backend
jerm-dro 16adacf
Merge branch 'main' into jerm-dro/gate-session-init-on-backend-health
jerm-dro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package health | ||
|
|
||
| import "github.com/stacklok/toolhive/pkg/vmcp" | ||
|
|
||
| // ShouldAdvertise reports whether a backend in this status may contribute | ||
| // capabilities to the advertised view (tools/list and friends). | ||
| // | ||
| // Degraded backends are included: they are slow but working, and hiding their | ||
| // tools would be a worse outcome for the caller than serving them. An empty | ||
| // status means health monitoring is disabled, which is treated as healthy so a | ||
| // deployment without a monitor behaves as it did before monitoring existed. | ||
| // | ||
| // Excluded: unhealthy (not responding), unknown (not yet probed), and | ||
| // unauthenticated (operator misconfiguration). | ||
| func ShouldAdvertise(status vmcp.BackendHealthStatus) bool { | ||
| return status == "" || | ||
| status == vmcp.BackendHealthy || | ||
| status == vmcp.BackendDegraded | ||
| } | ||
|
|
||
| // ShouldOpenSession reports whether a session should attempt to open a | ||
| // connection to a backend in this status. | ||
| // | ||
| // It skips only statuses that positively establish the backend is a bad bet and | ||
| // admits everything else, including not-yet-classified. This is the fix for | ||
| // #5861: session establishment waits for every backend it attempts | ||
| // (session.makeBaseSession's wg.Wait), so a backend the monitor already knows is | ||
| // bad sets the floor for the whole tenant's session-establishment latency. Worse, | ||
| // the handshake makes several sequential round trips, so the cost is a multiple | ||
| // of the backend's per-request latency, not one unit of it. The reported | ||
| // backend's 10-25s latency exceeds the 10s probe timeout, so its checks fail and | ||
| // it reaches Unhealthy after UnhealthyThreshold consecutive failures — which is | ||
| // the status this predicate skips. | ||
| // | ||
| // Degraded is deliberately ADMITTED. The tempting reading — "degraded means slow, | ||
| // so don't block on it" — does not survive the status's three producers | ||
| // (see vmcp.BackendDegraded): | ||
| // | ||
| // - Slow probe (healthChecker.CheckHealth): a successful check slower than | ||
| // DegradedThreshold. Genuinely slow, and the only producer where skipping | ||
| // would buy latency. | ||
| // - Recovering (statusTracker.RecordSuccess): ANY success recorded while | ||
| // consecutiveFailures > 0 is forced to degraded, overriding the check's own | ||
| // verdict. The backend just answered, possibly in microseconds, and stays | ||
| // labelled degraded for up to one CheckInterval (30s default). | ||
| // - Auth retrying (workloads.mapWorkloadStatusToVMCPHealth): a transient | ||
| // OAuth-refresh failure, with no latency component at all. | ||
| // | ||
| // Skipping degraded would exclude a fast, working, just-recovered backend from | ||
| // every session created in the ~30s after it recovers, making recovery slower to | ||
| // take effect — a worse and more surprising failure than the one #5861 reports. | ||
| // Telling the producers apart needs a degradation reason the enum does not carry; | ||
| // until it does, the safe reading of degraded at session-open time is "still | ||
| // worth attempting". | ||
| // | ||
| // The residual cost is accepted knowingly: a backend slow enough to be degraded | ||
| // but faster than the probe timeout (DegradedThreshold..Timeout) stays on the | ||
| // session-establishment path. That window is bounded by the probe timeout, where | ||
| // #5861's backend was not. | ||
| // | ||
| // Unknown is admitted for a separate reason: serving is not gated on the first | ||
| // health check completing (only the status reporter calls | ||
| // WaitForInitialHealthChecks), so sessions are routinely created while backends | ||
| // are still Unknown — during pod startup, and for a backend whose first check | ||
| // failed below the unhealthy threshold, which the monitor records as Unknown with | ||
| // a non-zero failure count (statusTracker.RecordFailure). Skipping those would | ||
| // connect a session to zero backends during the startup window: both a | ||
| // regression against the pre-#5861 behaviour and a worse failure than the one | ||
| // being fixed. "Not yet known to be bad" must fail open. | ||
| func ShouldOpenSession(status vmcp.BackendHealthStatus) bool { | ||
| // Skip only confirmed-bad statuses; everything else — including Degraded, | ||
| // Unknown, and the empty zero value — is attempted. | ||
| return status != vmcp.BackendUnhealthy && | ||
| status != vmcp.BackendUnauthenticated | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package health | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
|
|
||
| "github.com/stacklok/toolhive/pkg/vmcp" | ||
| ) | ||
|
|
||
| // TestShouldAdvertiseAndShouldOpenSession pins both health predicates against | ||
| // every BackendHealthStatus constant, plus the empty zero value and an | ||
| // unrecognized value. | ||
| // | ||
| // The two are asserted together because their relationship is the contract that | ||
| // matters, and it is not a simple ordering. ShouldOpenSession is LOOSER for | ||
| // unknown (session establishment must not fail closed before the first health | ||
| // check completes), and the two now COINCIDE on degraded — the deliberate outcome | ||
| // of #5861's review: that status conflates "slow", "recovering" and "auth | ||
| // retrying", so it cannot be read as "slow" at session-open time. Testing them | ||
| // side by side makes an accidental change to either one visible as a change in | ||
| // the pairing. | ||
| // | ||
| // The unrecognized-value row pins a subtlety worth stating explicitly: the two | ||
| // predicates have opposite defaults for a status neither knows about. | ||
| // ShouldAdvertise is an allow-list, so an unrecognized status is NOT advertised | ||
| // (fails closed — a capability that may not be servable is withheld). | ||
| // ShouldOpenSession is a deny-list, so it IS attempted (fails open — better to | ||
| // connect to a backend of uncertain health than to strand a session with none). | ||
| // Each default is the conservative choice for its own question, but they point | ||
| // in opposite directions, so anyone adding a status must consider both. | ||
| func TestShouldAdvertiseAndShouldOpenSession(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| status vmcp.BackendHealthStatus | ||
| wantAdvertise bool | ||
| wantOpenSession bool | ||
| }{ | ||
| { | ||
| name: "empty means health monitoring disabled: assume usable", | ||
| status: "", | ||
| wantAdvertise: true, | ||
| wantOpenSession: true, | ||
| }, | ||
| { | ||
| name: "healthy", | ||
| status: vmcp.BackendHealthy, | ||
| wantAdvertise: true, | ||
| wantOpenSession: true, | ||
| }, | ||
| { | ||
| // Degraded is attempted, not skipped. Only one of its three producers | ||
| // is latency; the "recovering" producer forces degraded onto a backend | ||
| // that just answered successfully, so skipping it would sideline a | ||
| // fast, working backend for up to one check interval. | ||
| name: "degraded is advertised AND attempted", | ||
| status: vmcp.BackendDegraded, | ||
| wantAdvertise: true, | ||
| wantOpenSession: true, | ||
| }, | ||
| { | ||
| name: "unhealthy", | ||
| status: vmcp.BackendUnhealthy, | ||
| wantAdvertise: false, | ||
| wantOpenSession: false, | ||
| }, | ||
| { | ||
| // The asymmetry that remains: aggregation waits for confirmation, | ||
| // session establishment must not, or a cold monitor connects sessions | ||
| // to zero backends during pod startup. | ||
| name: "unknown is not advertised but is still attempted", | ||
| status: vmcp.BackendUnknown, | ||
| wantAdvertise: false, | ||
| wantOpenSession: true, | ||
| }, | ||
| { | ||
| name: "unauthenticated (operator misconfiguration)", | ||
| status: vmcp.BackendUnauthenticated, | ||
| wantAdvertise: false, | ||
| wantOpenSession: false, | ||
| }, | ||
| { | ||
| // ShouldAdvertise is an allow-list (fails closed); ShouldOpenSession is | ||
| // a deny-list (fails open). Opposite defaults, deliberately — see the | ||
| // doc comment above. | ||
| name: "unrecognized status: not advertised, but still attempted", | ||
| status: vmcp.BackendHealthStatus("some-future-status"), | ||
| wantAdvertise: false, | ||
| wantOpenSession: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| assert.Equal(t, tt.wantAdvertise, ShouldAdvertise(tt.status), | ||
| "ShouldAdvertise(%q)", tt.status) | ||
| assert.Equal(t, tt.wantOpenSession, ShouldOpenSession(tt.status), | ||
| "ShouldOpenSession(%q)", tt.status) | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.