Skip to content

[Backend] A hung or failing *optional* health probe is reported as required, so /health/ready pulls a working instance out of rotation #548

Description

@MOHITKOURAV01

health_check_service is built on one distinction, stated in its own
module docstring:

Required and optional are different too. Gemini or Twilio being
unconfigured is degraded: the assistant and SMS summaries stop
working and the core cycle-tracking product does not. Firestore being
mocked is down.

and enforced by HealthReport.ready:

@property
def ready(self) -> bool:
    return not any(
        component.required and component.status == STATUS_DOWN
        for component in self.components
    )

Every check declares its own required flag, and the two optional ones
declare required=False. But the flag is only carried on the paths where
the check returns. On the two paths where it does not, _run_one
invents one:

# backend/services/health_check_service.py — _run_one
except FutureTimeout:
    ...
    return ComponentHealth(
        name=name,
        status=STATUS_DOWN,
        required=True,          # <-- not the check's own value
        detail=f"Check did not respond within {timeout} seconds.",
        ...
    )
except Exception as exc:
    ...
    return ComponentHealth(
        name=name,
        status=STATUS_DOWN,
        required=True,          # <-- same
        detail="Check failed. See server logs for details.",
        ...
    )

So a check that returns degraded, required=False keeps this instance
in rotation, and the same check raising takes it out — for the same
underlying dependency, with required decided by which branch the
failure happened to take.

Reproduction

check_sms_config reads three environment variables. Give it something
that raises rather than returns — the simplest is a monkeypatched
os.getenv, but any future check that opens a socket to Twilio or Gemini
gets there without help:

import services.health_check_service as hc

def boom():
    raise RuntimeError("probe blew up")
boom.__name__ = "check_sms"

report = hc.run_checks()          # with boom in CHECKS
assert report.ready               # FAILS — ready is False

GET /health/ready then answers 503, the orchestrator pulls the instance
out of the load balancer, and every user gets an error — because the
Twilio configuration check raised. Firestore is fine. Auth is fine. The
product works. Nobody can reach it.

The timeout path is the more likely one in practice. _run_one bounds
every check at HEALTH_CHECK_TIMEOUT (default 3s). The moment any
optional probe does real network work — and check_assistant_config's
docstring already anticipates growing into one — a slow third party
becomes a fleet-wide outage on a 3-second stopwatch.

Also wrong on the same two lines

name is recovered from check.__name__.replace("check_", ""), so a
timed-out or raised check is reported under a name derived from the
function rather than the name the check itself uses. check_auth_config
returns name="auth" when it succeeds and is reported as
"auth_config" when it times out, so an operator grepping their
dashboards for a component finds it under two different names depending
on how it failed.

Expected

Whether a dependency is required is a property of the dependency. It
should be declared once, alongside the check, and used identically on
the success, timeout and exception paths — so a probe that hangs
produces the same verdict about readiness that the same probe
returning down would.

test_a_hanging_check_becomes_a_result_not_a_hang in
backend/tests/test_health_checks.py already covers the hang itself; it
asserts on elapsed wall-clock and does not look at required.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions