Repository navigation
fix(health): bound Redis and DB checks with a timeout - #1599
Open
Nife-tanny wants to merge 6 commits into
Open
Nife-tanny wants to merge 6 commits into
Nife-tanny wants to merge 6 commits into
Conversation
Merge 38a3e2d kept the `checks` block of GET /health from main but dropped the code that defines indexerLagDegraded, indexerFailureDegraded, redisStatus and sorobanRpcOk, so every request threw a ReferenceError and /health answered 500 (Render and Docker health checks see it as down). Restore those definitions and the event-counter body fields from main's side of the merge (45e956d), keeping the ledger-lag additions from 87b656a. This is a pure merge repair with no behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Races a promise against a timer and rejects with a TimeoutError. The timer is always cleared, and the abandoned promise gets a no-op catch so a late rejection never becomes an unhandled rejection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /health could stall indefinitely on a connected but unresponsive Redis (ioredis has no command timeout configured), and the restored Soroban RPC check waits up to 3 s. Run every probe in parallel, each bounded by HEALTHCHECK_TIMEOUT_MS (default 800 ms), so the endpoint answers in under 1 s even when a dependency hangs. - Redis is actually pinged; a client that is not `ready` is reported as unavailable without pinging. The stale isRedisAvailable() flag is no longer used here. - Components report ok / down|unavailable / timeout, with a new top-level `redis` field. Redis-only degradation sets status "degraded" but keeps HTTP 200, so a Redis outage does not fail liveness probes. - Failures are logged once on state change; no error details are returned. Responses are Cache-Control: no-store. Closes LabsCrypt#1492 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover a hanging, failing and not-ready Redis, DB failure and timeout, both failing, parallel execution, HEALTHCHECK_TIMEOUT_MS, no error leakage and log de-duplication, using a fake clock plus one real-time check. Mock the Redis client and Soroban RPC so tests stay offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sync the generated spec and frontend API types with the /health schema changes in swagger.ts so the OpenAPI drift check passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #1492
Summary
GET /healthnow runs all of its dependency probes in parallel, each bounded byHEALTHCHECK_TIMEOUT_MS(default 800 ms), and actually pings Redis. A hung Redis now answers in ~0.8 s withredis: "timeout"instead of stalling./healthis currently broken onmain(fixed here in a separate commit)Merge 38a3e2d (Sept 26) kept the
checksblock of the route but dropped the code that definesindexerLagDegraded,indexerFailureDegraded,redisStatusandsorobanRpcOk. Onmain, every request throwsReferenceError: redisStatus is not definedand returns 500. Render'shealthCheckPath, the docker-compose healthcheck and the CI boot check would all treat the service as down. This is also why 8 tests intests/health.test.tsfail onmain.Commit 5954b30 is a pure merge repair. It restores those definitions and the event-counter body fields from main's side of the merge (45e956d) and keeps the ledger-lag additions from 87b656a, with no behaviour changes. It is separate so it can be reviewed or cherry-picked on its own. Note that #1579, #1530, #1570 and #1590 also edit this route and partly repair the same lines.
Also note: on
mainthe route never pinged Redis. Its status came fromisRedisAvailable(), a flag that is set once at connect and never cleared on disconnect, so it stayedokafter Redis died.Changes
backend/src/lib/with-timeout.ts(new):withTimeout(promise, ms, label)andTimeoutError. The timer is always cleared infinally, and the abandoned promise gets a no-opcatchso a late rejection is never unhandled.backend/src/routes/health.routes.ts:Promise.all. Each probe catches its own errors, so none reject.REDIS_URLis set but the ioredis client isn'tready(or never connected), the check returnsunavailableimmediately, without pinging.checkRpcHealth()defaults to a 3 s timeout, so it now gets the same bound.warnonce when a component starts failing and atinfoon recovery; per-probe errors go todebug. No error text, hosts or connection strings appear in the response.Cache-Control: no-store.backend/src/config/swagger.ts:HealthResponsegains theredisfield, addstimeoutto the database/redis enums, and documents the Redis-only 200 case.backend/.env.example: documentsHEALTHCHECK_TIMEOUT_MS.Response per scenario
Only the relevant fields are shown. Existing fields are unchanged and only
redisis added.statusdbredis(=checks.redis.status)checks.database.statusokconnectedokokREDIS_URLunset)okconnectednot_configuredokdegradedconnectedtimeoutokdegradedconnectedunavailableokdegradeddisconnecteddowndegradeddisconnectedtimeoutdegradeddisconnectedunavailable/timeoutdown/timeoutdegradedconnectedokExample (Redis hung, DB OK):
{"status":"degraded","db":"connected","redis":"timeout","indexerEnabled":false,"indexerLag":null,"indexerLedgerLag":null,"eventsProcessed":0,"eventsFailed":0,"lastErrorAt":null,"indexerDegraded":false,"uptime":12.3, "checks":{"database":{"status":"ok"},"indexer":{"status":"disabled","enabled":false,"lagSeconds":null},"redis":{"status":"timeout"},"sorobanRpc":{"status":"ok"}}}Redis keeps the pre-existing
unavailablevalue rather thandownfor backward compatibility with the documented enum.timeoutis new.Timeout budget
HEALTHCHECK_TIMEOUT_MS), not 1,000 ms. Probes run in parallel, so the worst case is about 800 ms plus request handling. That leaves ~200 ms of headroom for event-loop lag, JSON serialisation and the network under the issue's 1,000 ms target.❓ Question for the maintainer: HTTP status when only Redis is degraded
The issue asks for both a "fast 503" and
{ status: "degraded", redis: "timeout" }. This PR returns 200 withstatus: "degraded"when only Redis is down or timing out, for these reasons:isHealthyverdict". Redis only powers cross-instance SSE fan-out; single-instance mode works without it./healthis the only health route and is used as a liveness signal:render.yamlsetshealthCheckPath: /health, docker-compose useswget --spider(fails on non-2xx), and the CI boot check usescurl --fail. Returning 503 on a Redis outage would make the platform mark every API instance unhealthy (and potentially restart them) for a Redis-only problem, turning a partial degradation into a full outage.Side effect:
status: "degraded"used to always mean 503. Now it can also appear with 200, and the HTTP code pluscheckstell the two cases apart. If you'd prefer Redis failures to return 503, it's a one-line change (isHealthywould include!redisDegraded). A cleaner long-term split would be separate liveness and readiness routes (see suggestions).Acceptance criteria
answers at the timeout with redis "timeout" when the ping never resolves(fake clock: responds at 800–900 ms),responds in under 1,000 ms of real time when Redis never answers(real timers: ~820 ms),runs the checks in parallel: two 700 ms probes take ~700 ms, not 1400 ms,does not wait on a hanging Soroban RPC health check.returns 503 with database "down" when the DB query fails and Redis is fine,returns 503 with database "timeout" when the DB query hangs and Redis is fine,reports both failures when the DB and Redis are down, and the Redis-only cases (200 +degraded).tests/with-timeout.test.ts(6 tests).Tests
tests/with-timeout.test.ts(new, 6 tests) uses fake timers andvi.getTimerCount()to prove no timer is left behind. It covers:TimeoutErrorexactly at the deadline (799 ms: pending, 800 ms: rejected)unhandledRejectionwhen the abandoned promise rejects latertests/health.test.tskeeps all 8 existing tests, which failed onmainand now pass, and adds 18. The Redis client (getPublisher) and Soroban RPC are mocked, so the tests are offline. The fake-clock tests advance onlysetTimeoutin 10 ms steps while yielding real event-loop turns, which measures how much simulated time the endpoint needed. The new tests cover:HEALTHCHECK_TIMEOUT_MS=300unavailablereconnecting/connecting/end/close→unavailablewithout callingpingREDIS_URLset but not connected →unavailableREDIS_URLunset →not_configured, 200okdownandtimeout→ 503okwithCache-Control: no-storeVerification
Manual timing: the real app and the real ioredis client ran against a small fake Redis TCP server. The fake completes ioredis's ready check (
INFO) and then either answersPINGor never does, which is the "connected but unresponsive" hang. Docker wasn't available locally, so Postgres was unreachable, and every manual response below is 503 because of the DB. The Redis field and the timing are what these runs show; DB-up combinations are covered by the tests above.Checks: the backend has no lint script, and CI does not lint the backend.
tsc -p tsconfig.jsonmain→ 72, 0 new. The 4 removed are the undefined names inhealth.routes.ts; the rest are pre-existing (below)vitest run(full backend suite)main: 71 failed / 485 passed. This branch: 62 failed / 518 passed. No new failures (diffed by name); 8 health tests fixed; 1 flaky test (below) passednpm run codegen:openapiHealthResponsechange. The committedswagger/flowfi.openapi.jsonwas already out of date onmain(missing/metrics,/v1/streams/simulateand dead-letter routes), so I didn't regenerate it here to avoid mixing in unrelated driftany,ts-ignore,eslint-disable,.skip/.only) in changed filesmain(unrelated to this PR)All reproducible on
upstream/mainat 72ec8ef:npm cifails:package-lock.jsonis out of sync withpackage.json. This is why Frontend CI, Backend CI and the preview workflow fail at Install dependencies.prisma generatefails (P1012):schema.prismadefinesmodel IndexerDeadLetterEventtwice (lines 71 and 164), sonpm run build/npm testand the Docker image build fail. I verified locally with a client generated from a temporary, untracked de-duplicated copy of the schema; no schema change is included here.pg-pool.jshas no export namedgetPoolMetrics. The manual check ran throughvite-node.tscerrors, insorobanService,soroban-event-worker,admin.routes,stream.controller,prisma.tsand tests.indexer-service,soroban.service,soroban-event-worker,stream-simulation,pg-pool,worker-correlation-idandintegration/admin-*/reset-replay-race.stream.test.ts › withdraws the claimable amount for the recipientfailed once under full-suite load onmainand passes in isolation (3/3).main.Suggestions (not changed here)
commandTimeout:lib/redis.tssetsenableOfflineQueue: false, so commands fail fast while disconnected, but has nocommandTimeout. Any command to a connected-but-hung Redis (e.g. SSE publishes) waits indefinitely. SettingcommandTimeout(andconnectTimeout) on the clients would bound every command, not just this probe.isRedisAvailable()goes stale:_availableis set totrueon connect and never reset when the connection drops.sse.service.ts(lines 212 and 220) uses it to decide whether to publish through Redis, so during an outage it keeps trying Redis instead of falling back to local delivery.retryStrategyreturnsnullafter 3 attempts, so after a short Redis outage the clients end permanently and don't recover until the process restarts./healthwill then reportunavailableuntil a restart./health/live(process only, for restarts) and/health/ready(DB/Redis/indexer, for traffic routing). That would resolve the 200-vs-503 question above cleanly.statement_timeout(PG_STATEMENT_TIMEOUT_MS, default 30 s). Consider a shorter dedicated timeout for health queries if probes are frequent.