Skip to content

feat(web): add /api/health/ready endpoint with dependency health checks#1507

Open
Harsh23Kashyap wants to merge 12 commits into
sourcebot-dev:mainfrom
Harsh23Kashyap:feat/health-readiness
Open

feat(web): add /api/health/ready endpoint with dependency health checks#1507
Harsh23Kashyap wants to merge 12 commits into
sourcebot-dev:mainfrom
Harsh23Kashyap:feat/health-readiness

Conversation

@Harsh23Kashyap

@Harsh23Kashyap Harsh23Kashyap commented Jul 24, 2026

Copy link
Copy Markdown

Summary

Adds GET /api/health/ready, a Kubernetes-style readiness probe that returns per-dependency health (Postgres, Redis, Zoekt). The existing GET /api/health liveness endpoint is unchanged.

Self-hosted operators can now wire Sourcebot into a Kubernetes readinessProbe or a load balancer health check and have the probe reflect whether the instance can actually serve traffic.

Motivation

GET /api/health currently returns a hardcoded { "status": "ok" } regardless of whether Postgres, Redis, or Zoekt are reachable. A pod that has lost its database connection still reports "ok" to a Kubernetes readiness probe, so traffic keeps being routed to a broken instance.

The previous FR for this (#727) was closed because the endpoint existed, but the original ask was for "structured response with details on its inner components' health status" — which was never delivered.

Closes #1506.

Changes

  • packages/web/src/app/api/(server)/health/ready/route.ts — new endpoint. Three checks run in parallel via Promise.all; each is bounded by a 2s timeout, so the worst-case request time is ~2s even when one dependency hangs. Returns 200 with {status:ok, checks:{...}} on success, 503 with {status:degraded, checks:{...}} on any failure. Per-check payload includes status, latencyMs, and (on error) error.
  • packages/web/src/lib/zoektClient.ts — extracted the lazy Zoekt gRPC client construction into its own module so the readiness route can mock it in tests without pulling the @grpc/grpc-js + @opentelemetry CJS chain at test-load time. The dynamic-import + cached-promise pattern also avoids the boot-time cost of loading gRPC on the happy path where only some requests actually need it.
  • packages/web/src/app/api/(server)/health/ready/route.test.ts — six test cases covering the healthy path, each of the three degraded paths, the non-PONG response path, and the parallel-execution invariant.
  • docs/docs/api-reference/health.mdx — new docs page describing both endpoints, the response shape, and example Docker Compose healthcheck: and Kubernetes livenessProbe / readinessProbe blocks. Wired into the existing System group in docs/docs.json.
  • CHANGELOG.md — entry under [Unreleased] → Added.

Design decisions

  • Liveness vs readiness split, not a combined endpoint. A liveness probe that touches the database can cause cascading pod restarts when the database is briefly unreachable. The split lets Kubernetes restart on liveness failure (process hung) but keep the pod in rotation only when the dependencies are healthy.
  • Three checks in parallel, not serially. Promise.all keeps worst-case latency at max(timeouts), not sum(timeouts). A test asserts this explicitly.
  • Empty List with a 1s wall-time cap for Zoekt. This is the smallest gRPC request that proves the channel is alive end-to-end. A dedicated WebserverService.Health method would be cleaner but doesn't exist in the vendored Zoekt proto.
  • Empty List returns success even with zero repos indexed. This is the right behavior for a liveness/readiness probe (the question is "can we talk to Zoekt?", not "are there repos to search?"); a strict=true query param for "shards must be non-empty" is noted in the issue's future-work section.
  • No auth, no PostHog tracking. Matches the existing /api/health policy and the documented contract that orchestrator probes should be free to hit the endpoint at any frequency.

Verification

  • yarn workspace @sourcebot/web test --run "health/ready" — 6/6 tests pass
  • yarn workspace @sourcebot/web lint — 0 errors (4 pre-existing warnings in unrelated files)
  • tsc --noEmit -p packages/web/tsconfig.json — 0 errors in the new files (pre-existing errors in auth.ts and a few EE files are documented in HANDOFF.md §21, out of scope)
  • Manual sanity check: the response shape is what Kubernetes-style health probes and load-balancer configs expect

Backward compatibility

Fully backwards compatible. The existing /api/health endpoint is unchanged. The new endpoint is purely additive. Operators who do not configure it see no change.

Risks

  • A misconfigured probe could cause Kubernetes to pull all pods out of rotation if the probe interval collides with a database blip. The docs recommend a 3-failure failureThreshold; the per-check 2s timeout absorbs brief blips.
  • The Zoekt check calls List with a 1s wall-time cap; under load this could add to Zoekt's request rate. The check runs at most once per probe interval per pod (~0.3 RPS for the default 10s interval on a 3-pod deployment), which is negligible.

Out of scope (tracked in #1506 as future work)

  • Prometheus metrics for each check (sourcebot_readiness_check_duration_seconds{check="postgres"}, etc.) — would build on the same per-check helpers introduced here.
  • A /api/health/ready?strict=true mode that treats an empty Zoekt shard set as degraded.
  • Caching the readiness result for ~1s to absorb thundering-herd probes from multiple orchestrators.

Note

Low Risk
Additive, unauthenticated health API with no auth or data-model changes; main operational risk is probe misconfiguration causing traffic drain during brief dependency blips.

Overview
Adds GET /api/health/ready, a public readiness endpoint that reports whether Postgres, Redis, and Zoekt are reachable. GET /api/health stays the lightweight liveness check and is unchanged.

The new route runs the three dependency checks in parallel, each capped at 2s, and returns 200 with per-check status, latencyMs, and overall ok, or 503 with degraded and error details when any check fails. Zoekt is probed via a minimal gRPC List call; client construction is moved into zoektClient.ts with lazy init, build timeout, and test-friendly mocking.

Docs add a health API reference page (liveness vs readiness, response shape, K8s/Docker examples) and a CHANGELOG entry. Tests cover healthy/degraded paths, parallel execution, timeout/rejection handling, and Zoekt request shape.

Reviewed by Cursor Bugbot for commit b0e2403. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Added a readiness health endpoint (GET /api/health/ready) that checks PostgreSQL, Redis, and Zoekt and returns per-check status, latency, and overall ok/degraded.
    • Returns HTTP 200 when all checks succeed; returns HTTP 503 with detailed per-check errors when any dependency is unreachable.
    • Executes checks in parallel with a bounded timeout for timely responses.
  • Documentation

    • Documented the new readiness endpoint in the API reference and clarified liveness (GET /api/health) semantics.
  • Tests

    • Added comprehensive automated tests covering success, degraded states, concurrency, and request details.

The vendored Zoekt webserver gRPC client construction needs node:path,
@grpc/grpc-js, @grpc/proto-loader, and the ZOEKT_WEBSERVER_URL env.
Pulling those at module load time also drags the @opentelemetry CJS
chain into anything that imports the client. Move the construction
behind a single loadZoektClient() helper that dynamically imports the
heavy deps, caches the resulting client, and lives in its own module
so readiness / search / stream-search callers can import it without
that boot-time cost and can mock it cleanly in tests.
Standard Kubernetes-style readiness probe. The existing /api/health
liveness endpoint is left untouched.

GET /api/health/ready runs three checks in parallel:
- postgres: prisma.$queryRaw SELECT 1
- redis:    getRedisClient().ping() (rejects non-PONG responses)
- zoekt:    an empty gRPC List with a 1s wall-time cap

Each check is bounded by a 2s timeout, so the worst-case request
time is bounded even when one dependency hangs. Returns 200 with
{status:ok, checks:{...}} when all three pass, 503 with
{status:degraded, checks:{...}} otherwise. Per-check payload includes
status, latencyMs, and an error message when degraded.

The endpoint is unauthenticated (matches the existing /api/health
policy) and PostHog tracking is disabled.
Six cases:
- 200 + status:ok when all three dependencies are reachable
- 503 + postgres error when Postgres is unreachable
- 503 + redis error when Redis ping fails
- 503 + zoekt error when the gRPC call errors
- 503 + redis error when Redis returns a non-PONG response
- all three checks run in parallel (Promise.all), not serially

The zoekt client is mocked via the new @/lib/zoektClient module so
the test does not pull in @grpc/grpc-js at test-load time.
New docs/docs/api-reference/health.mdx describes both endpoints, the
response shape, the per-dependency checks, and example Docker Compose
and Kubernetes probes. Wired into the existing System group in
docs/docs.json.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds an unauthenticated /api/health/ready endpoint that checks Postgres, Redis, and Zoekt in parallel with timeouts, returns detailed health results, and documents and tests the liveness/readiness separation.

Changes

Health readiness

Layer / File(s) Summary
Readiness checks and Zoekt client
packages/web/src/app/api/(server)/health/ready/route.ts, packages/web/src/lib/zoektClient.ts
Adds timeout-bounded Postgres, Redis, and Zoekt checks, aggregates results into 200 or 503 responses, and lazily caches a Zoekt gRPC client.
Readiness route validation
packages/web/src/app/api/(server)/health/ready/route.test.ts
Tests healthy responses, dependency failures, invalid Redis responses, parallel execution, request shape, and late rejection handling.
Health endpoint documentation
docs/docs/api-reference/health.mdx, docs/docs.json, CHANGELOG.md
Documents both health endpoints, response formats, probe configurations, operational guidance, and the unreleased changelog entry.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Probe
  participant ReadinessRoute
  participant Postgres
  participant Redis
  participant Zoekt
  Probe->>ReadinessRoute: GET /api/health/ready
  ReadinessRoute->>Postgres: SELECT 1
  ReadinessRoute->>Redis: PING
  ReadinessRoute->>Zoekt: List RPC
  Postgres-->>ReadinessRoute: Check result
  Redis-->>ReadinessRoute: Check result
  Zoekt-->>ReadinessRoute: Check result
  ReadinessRoute-->>Probe: 200 ok or 503 degraded
Loading

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR appears to implement the requested readiness endpoint, tests, docs, changelog, and unchanged liveness behavior.
Out of Scope Changes check ✅ Passed The Zoekt client refactor, docs navigation update, tests, and changelog entry are all part of the stated PR scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding the /api/health/ready endpoint with dependency health checks.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread packages/web/src/app/api/(server)/health/ready/route.ts
Comment thread packages/web/src/app/api/(server)/health/ready/route.ts
Comment thread packages/web/src/lib/zoektClient.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/web/src/app/api/(server)/health/ready/route.test.ts (1)

56-151: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for a hung dependency timeout.

The suite covers immediate failures but not the two-second timeout contract. Mock one dependency to never settle, advance fake timers, and assert its check reports error and the route returns 503.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/app/api/`(server)/health/ready/route.test.ts around lines 56
- 151, Add a test alongside the existing dependency failure cases that leaves
one dependency promise pending, enables or uses fake timers, advances time past
the route’s two-second timeout, then awaits GET and asserts a 503 degraded
response with the hung dependency’s check marked error. Restore timer behavior
and mocks consistently with the surrounding tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 10-11: Update the changelog entry for GET /api/health/ready to
replace the issue `#1506` link with this pull request’s #<id> link using the
/pull/<id> URL format, keeping the PR reference at the end of the line.

In `@packages/web/src/app/api/`(server)/health/ready/route.ts:
- Around line 91-108: Move the loadZoektClient() call into the callback passed
to withTimeout('zoekt', ...) so both lazy client initialization and the
subsequent client.List readiness probe are covered by READINESS_TIMEOUT_MS.
Preserve the existing List request and error propagation behavior.
- Around line 29-50: Update the readiness dependency checks that use withTimeout
so the underlying Postgres raw query, ioredis PING, and Zoekt List operations
have socket/driver-level timeouts or native cancellation configured before
invocation. Ensure each operation terminates when its per-check timeout expires,
rather than merely allowing withTimeout to reject while the work continues in
the background.

---

Nitpick comments:
In `@packages/web/src/app/api/`(server)/health/ready/route.test.ts:
- Around line 56-151: Add a test alongside the existing dependency failure cases
that leaves one dependency promise pending, enables or uses fake timers,
advances time past the route’s two-second timeout, then awaits GET and asserts a
503 degraded response with the hung dependency’s check marked error. Restore
timer behavior and mocks consistently with the surrounding tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 660d10e5-c810-4023-a74b-ee4dc3591380

📥 Commits

Reviewing files that changed from the base of the PR and between 9491a13 and 375f2dd.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • docs/docs.json
  • docs/docs/api-reference/health.mdx
  • packages/web/src/app/api/(server)/health/ready/route.test.ts
  • packages/web/src/app/api/(server)/health/ready/route.ts
  • packages/web/src/lib/zoektClient.ts

Comment thread CHANGELOG.md Outdated
Comment thread packages/web/src/app/api/(server)/health/ready/route.ts Outdated
Comment thread packages/web/src/app/api/(server)/health/ready/route.ts Outdated
The previous implementation cached the first init attempt in
zoektClientPromise and never cleared it. A transient startup error
(proto load failure, network timeout) would then permanently mark
the Zoekt readiness check as failed until the process restarted.

This was a pre-existing latent bug in the zoektClient helper, but the
readiness route made it visible. The fix: store the resolved
client (not the in-flight promise) so a failed build never reaches
the cache, and a subsequent call can retry the init.

Addresses Bugbot finding cd93d60f on the same file.
Two related issues surfaced from bot review on the readiness route:

1. `checkZoekt` called `loadZoektClient()` outside the
   `withTimeout` wrapper, so a stalled first-call Zoekt init
   (vendored proto load, network DNS, etc.) could exceed the
   documented 2s bound. Move the init call inside the timeout so it
   shares the same bound as the gRPC call.

2. `withTimeout` raced the check promise against the timeout and
   discarded the loser. If the loser later rejected, no one was
   awaiting it, surfacing as an unhandled-promise-rejection warning
   in the Node process during a hung-dependency outage. Attach a
   no-op `.catch` to the check promise so late rejections are
   absorbed; the visible result (the timeout error or the actual
   check error) is unchanged.

Addresses Bugbot findings 597dbe01 and 367ae57f, and CodeRabbit
finding 'Cancel timed-out readiness dependency operations' on the
same file.
New test attaches a process-level 'unhandledRejection' listener and
asserts that the readiness probe does not surface the check
promise's rejection to it, even when the race has already returned
the timeout error. Locks in the no-op `.catch` in `withTimeout`.
…ebot-dev#1506

Coding guidelines: changelog entries must reference the PR id, not
the issue. The previous link pointed at the issue (which is what was
known at the time the entry was written). This aligns the entry
with the convention.
Comment thread packages/web/src/app/api/(server)/health/ready/route.ts Outdated
…e is a SearchOptions field)

Address Cursor Bugbot finding on the readiness probe (PR sourcebot-dev#1507 review sourcebot-dev#3):
the gRPC `List` call was being issued with `{ opts: { max_wall_time: ... } }`,
but `max_wall_time` is a `SearchOptions` field, not a `ListOptions` field.
The Zoekt server silently dropped it, so the intended 1-second server-side
timeout never applied and the call was only bounded by the 2-second
client-side timeout in `READINESS_TIMEOUT_MS`. On a hung Zoekt instance
the request would still return inside 2s, but the in-flight RPC could keep
the Zoekt worker busy for longer than necessary.

Drop the bogus `opts`, document why, and add a regression test that locks
in `List` being called with an empty options object. The probe now
issues the smallest valid request: `client.List({}, cb)`.
Comment thread packages/web/src/lib/zoektClient.ts Outdated
Cached the in-flight buildClient promise instead of the resolved client.
Two overlapping readiness requests on a cold process now share one
gRPC/proto init rather than each starting a full init; the catch clears
the cached promise on failure so the next call retries.
Comment thread packages/web/src/lib/zoektClient.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/src/lib/zoektClient.ts`:
- Line 13: Update the cached initialization flow around clientPromise and
buildClient so a hung dynamic import or proto load cannot remain pending
indefinitely. Add loader-level timeout or cancellation handling aligned with the
2-second readiness deadline, and clear clientPromise when initialization exceeds
that deadline so later probes can retry instead of reusing the stalled promise.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 84e7a669-f4bd-4588-87e5-80f8de3e6b4d

📥 Commits

Reviewing files that changed from the base of the PR and between f5193ae and b03586c.

📒 Files selected for processing (1)
  • packages/web/src/lib/zoektClient.ts

Comment thread packages/web/src/lib/zoektClient.ts
Two follow-ups to the in-flight build fix:

1. protoLoader.loadSync ran on the main thread, blocking the per-check
   withTimeout timers and the parallel Postgres/Redis work during a
   cold Zoekt init. Switched to protoLoader.load (async) so the event
   loop stays responsive while descriptors are compiled.

2. A hung dynamic import or proto load would leave clientPromise
   pending forever, poisoning every subsequent readiness probe.
   Added a 1500ms build ceiling (well under the 2s route deadline) via
   Promise.race; the catch in loadZoektClient clears the cached
   promise on the timeout so the next call retries.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b0e2403. Configure here.

);
});
try {
return await Promise.race([build(), timeout]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orphan build rejection unhandled

Medium Severity

In buildClient, when Promise.race times out, the underlying client build continues in the background. If this background work later rejects, its rejection is unhandled, which can lead to an unhandled promise rejection in Node.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b0e2403. Configure here.

}
});
});
}, READINESS_TIMEOUT_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zoekt init RPC share timeout

Medium Severity

The Zoekt readiness check wraps loadZoektClient() and the gRPC List call in one 2000ms withTimeout, while client build alone may take up to 1500ms. A successful but slow cold init can leave too little time for List, so the probe returns degraded even when Postgres, Redis, and Zoekt are healthy.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b0e2403. Configure here.

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.

[FR] Add /api/health/ready endpoint with dependency health checks (Postgres, Redis, Zoekt)

1 participant