Skip to content

fix(selection): make recovery probes breaker-ineligible (probe/selection wedge defence-in-depth)#4

Open
snowkide wants to merge 1 commit into
feat/websocket-supportfrom
fix/selection-probe-breaker-ineligible
Open

fix(selection): make recovery probes breaker-ineligible (probe/selection wedge defence-in-depth)#4
snowkide wants to merge 1 commit into
feat/websocket-supportfrom
fix/selection-probe-breaker-ineligible

Conversation

@snowkide

Copy link
Copy Markdown

Context

Split out from #3 (the half-open permit-leak fix) so the two changes can land and be reviewed independently. #3 is the root-cause fix for the probe/selection wedge; this PR is defence-in-depth on top of it — they touch disjoint files and do not depend on each other.

Problem

Selection-recovery probes (prober.mirror) send a non-internal request, so upstreamBreakerEligible was true for them — i.e. a probe went through the circuit breaker. The probe's documented purpose is to "reach the upstream so it can prove itself" and feed a fresh health signal to the selection policy. But if the upstream's breaker is open, the probe is denied a permit, the prober records that breaker-open denial as a probe failure, the upstream's error rate stays high, and it stays excluded forever — a self-reinforcing wedge that no real traffic can break.

Fix

Mark selection-recovery probes as breaker-ineligible: they neither acquire a permit nor record an outcome into the breaker. The breaker still recovers on its own via HalfOpen trials on real traffic; the probe's signal flows through the selection tracker instead.

  • New common.WithSelectionProbe(ctx) / IsSelectionProbe(ctx) marker. We tag the ctx, not the request — prober.mirror shares the req object with live client traffic.
  • prober.mirror tags its probe ctx.
  • upstreamBreakerEligible now takes ctx and returns false for a selection probe (mirroring the existing hedge / internal-probe / composite skips).
  • The executor decides breakerEligible once and reuses it for both permit-acquire and outcome-record, so the two can never disagree.

Behaviour change to note

Probes no longer contribute to the breaker's success/failure counters at all. Recovery is driven purely by real-traffic HalfOpen trials (after selection re-includes the upstream on the probe signal). This is the intended, more-correct behaviour, but it is a genuine change from today — worth watching for any upstream that has an open breaker and near-zero real traffic.

Tests

  • upstream/breaker_eligibility_test.go — a selection probe is breaker-ineligible; ordinary traffic stays eligible; hedge stays ineligible; ctx marker round-trips (incl. nil).

Verified: probe branch builds and tests pass standalone (without #3's breaker.go change), confirming the two PRs are independent.

🤖 Generated with Claude Code

…ion wedge defence-in-depth)

Defence-in-depth for the same probe/selection wedge as the half-open permit
leak: a selection-recovery probe rides u.Forward through the upstream circuit
breaker. When the breaker is open, TryAcquirePermit denies the probe, Forward
returns ErrFailsafeCircuitBreakerOpen, and the prober records that denial as a
probe FAILURE in the selection tracker. The upstream's error rate stays high, the
selection policy keeps it excluded, the next probe is denied too — the upstream
can never gather a real health signal and stays excluded until restart.

Fix: mark a probe's context as breaker-ineligible so it neither acquires a permit
nor records an outcome into the breaker. The probe reaches the upstream
regardless of breaker state and its REAL result (success or genuine failure)
flows through the selection tracker, so the policy re-admits the upstream once it
is actually healthy. The breaker still recovers independently via its HalfOpen
trials on real traffic — probes don't pollute the breaker window.

Implementation is a context flag, NOT a request mutation: the prober shares the
NormalizedRequest with live client traffic (mirror() forwards the same object),
so flipping a field on it would corrupt the in-flight client request. The flag
lives on the probe's own detached ctx instead.

  - common: WithSelectionProbe / IsSelectionProbe (new selection_probe.go)
  - upstream: upstreamBreakerEligible now consults ctx; eligibility decided once
    so permit acquisition and outcome recording stay consistent
  - internal/policy/prober: tag the probe ctx in mirror()

Tests: upstreamBreakerEligible returns false for a probe ctx, true for ordinary
traffic, false for hedges; context marker roundtrip. policy + upstream + failsafe
packages still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
File Lines Key changes Risk
🟢 selection_probe.go +34/-0 WithSelectionProbe, IsSelectionProbe
🔵 upstream_executor.go +16/-8 upstreamBreakerEligible
1 test files +38

xray — see through AI slop with deterministic architecture PR diff reviews

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant