Skip to content

fix(health-check): cap the pre-healthy backoff at 2s - #125

Merged
mhenrixon merged 2 commits into
mainfrom
fix/cap-pre-healthy-backoff
Aug 29, 2026
Merged

fix(health-check): cap the pre-healthy backoff at 2s#125
mhenrixon merged 2 commits into
mainfrom
fix/cap-pre-healthy-backoff

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • internal/server/health_check.go: add maxPreHealthyDelay = 2s; the not-yet-healthy branch is now min(delay*2, maxPreHealthyDelay, hc.interval). Steady state (delay = hc.interval after first success) is untouched; nextDelay still clamps to interval so sub-2s intervals keep their faster cadence.
  • Add preHealthyFastWindow = 60s: the 2s ceiling applies only while a target is younger than that; afterwards the backoff resumes doubling toward interval. This closes the one unbounded path — deploy --force skips WaitUntilHealthy, so a target that never comes up would otherwise be probed every 2s indefinitely. Normal deploys dispose such a target at DeployTimeout (30s default) and never reach the window's end.
  • Ceiling and window are unexported fields on HealthCheck, defaulted from the constants via a newHealthCheck constructor so tests can shrink them; NewHealthCheck's signature is unchanged.
  • internal/server/health_check_backoff_test.go:
    • TestHealthCheck_PreHealthyBackoffIsCappedBelowTheInterval — 20s interval, backend flips to 200 at 3.5s, asserts first success < 6s (capped schedule lands at 5.15s; uncapped 6.35s) and no further probes in the 500ms after success.
    • TestHealthCheck_FastWindowExpiresForATargetThatNeverBecomesHealthy — cap 100ms / window 300ms / 503 forever: 7 probes in 1.5s; 16 with the window disabled (verified RED).

With a 20s interval the old schedule was 0.05, 0.15, 0.35, 0.75, 1.55, 3.15, 6.35, 12.75, 25.55s; now it is 0.05, 0.15, 0.35, 0.75, 1.55, 2, 2, 2… for 60s, then 4, 8, 16, 20, 20… so a booting target ready at T is seen by T+2s.

Closes #124

Test plan

Deviations & judgment calls

  • Fast window added beyond the issue's scope (agreed in review of the first cut): the issue's constant-only cap left deploy --force + never-healthy target probing at 2s forever. The dash gem never passes --force, so this only affects manual operator runs, but a 60s window is cheap and restores the old doubling behaviour after it. 60s is 2× the default deploy timeout so a normal deploy never observes the window's end.
  • Test timing: the issue offered a 7s flip / < 9.5s assert; used the shorter 3.5s flip / < 6s variant it also described to keep the suite ~4s faster. Margin is 0.85s on the pass side and 0.35s on the fail side; confirmed RED at 6.00s before the fix.
  • make lint: clean with golangci-lint 2.13.2 (built with go1.27). The ci.yml-pinned v2.11.3 cannot read go1.27 export data locally; CI still runs it on its own toolchain and passed.
  • Release (v1.1.0.1) and gem PR intentionally not done here — release is user-supervised per repo rules.

https://claude.ai/code/session_01QkuqoL4xoqxxWkzo7zJgpt

The pre-healthy backoff doubled from 50ms up to --health-check-interval.
With a 20s interval the probe schedule reaches 12.75s then 25.55s, so a
Rails app ready at 13s was not noticed until 25.55s — every deploy on
cosmos took ~26s regardless of how fast the app booted.

The steady-state interval says how often to re-check a target in service.
Before the first success nothing is routed there and a probe is cheap, so
clamp the pre-healthy delay to min(delay*2, 2s, interval). After the first
success the interval governs exactly as before; sub-2s intervals still win.

## Test Coverage
- TestHealthCheck_PreHealthyBackoffIsCappedBelowTheInterval: 20s interval,
  ready at 3.5s, healthy by 5.15s (uncapped: 6.35s); no extra probes after

## Verification
- [x] gofmt -l internal/ cmd/ clean
- [x] make test passes, go test -race on health check tests
- [x] go vet ./... clean

Closes #124

Claude-Session: https://claude.ai/code/session_01QkuqoL4xoqxxWkzo7zJgpt
A normal deploy disposes a target that misses its deploy timeout, but
`deploy --force` skips that wait and installs the target unhealthy. With
the 2s ceiling alone such a target would be probed at boot cadence for
as long as it never answered. Past a 60s window the backoff resumes
doubling toward the configured interval, as it did before the cap.

The ceiling and window are fields with the constants as defaults so the
test can shrink them; NewHealthCheck's signature is unchanged.

## Test Coverage
- TestHealthCheck_FastWindowExpiresForATargetThatNeverBecomesHealthy:
  cap 100ms / window 300ms, 503 forever — 7 probes in 1.5s, 16 without
  the window

## Verification
- [x] gofmt -l internal/ cmd/ clean, go vet ./... clean
- [x] go test -race ./internal/server/ passes

Claude-Session: https://claude.ai/code/session_01QkuqoL4xoqxxWkzo7zJgpt
@mhenrixon mhenrixon self-assigned this Aug 29, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Aug 29, 2026
@mhenrixon
mhenrixon merged commit 1cbdeb2 into main Aug 29, 2026
3 checks passed
@mhenrixon
mhenrixon deleted the fix/cap-pre-healthy-backoff branch August 29, 2026 06:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cap the pre-healthy health-check backoff at 2s so boot detection is decoupled from --health-check-interval

1 participant