feat(web): add /api/health/ready endpoint with dependency health checks#1507
feat(web): add /api/health/ready endpoint with dependency health checks#1507Harsh23Kashyap wants to merge 5 commits into
Conversation
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.
References issue sourcebot-dev#1506.
WalkthroughAdds an unauthenticated ChangesHealth readiness
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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 375f2dd. Configure here.
| }, | ||
| ); | ||
| }); | ||
| }, READINESS_TIMEOUT_MS); |
There was a problem hiding this comment.
Zoekt load skips timeout
Medium Severity
In checkZoekt, loadZoektClient() runs before withTimeout, so Zoekt client initialization isn't timed out by READINESS_TIMEOUT_MS. This can cause the readiness probe to exceed expected latency, leading to orchestrator probe failures.
Reviewed by Cursor Bugbot for commit 375f2dd. Configure here.
| clearTimeout(timer); | ||
| } | ||
| } | ||
| }; |
There was a problem hiding this comment.
Timeout race unhandled rejections
Medium Severity
The withTimeout function uses Promise.race. If the timeout wins, the underlying check promise continues to execute. A subsequent rejection from this orphaned promise can lead to an unhandled promise rejection in the Node process, particularly when dependencies are hanging.
Reviewed by Cursor Bugbot for commit 375f2dd. Configure here.
| ); | ||
| })(); | ||
| } | ||
| return zoektClientPromise; |
There was a problem hiding this comment.
Failed Zoekt client cached forever
Medium Severity
loadZoektClient stores the first init attempt in zoektClientPromise and never clears it on failure. A transient startup error permanently marks the Zoekt readiness check as failed until the process restarts, unlike search code that rebuilds the client per call.
Reviewed by Cursor Bugbot for commit 375f2dd. Configure here.
There was a problem hiding this comment.
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 winAdd 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
errorand the route returns503.🤖 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
📒 Files selected for processing (6)
CHANGELOG.mddocs/docs.jsondocs/docs/api-reference/health.mdxpackages/web/src/app/api/(server)/health/ready/route.test.tspackages/web/src/app/api/(server)/health/ready/route.tspackages/web/src/lib/zoektClient.ts
| ### Added | ||
| - Added a `GET /api/health/ready` endpoint that returns per-dependency health (Postgres, Redis, Zoekt) for use as a Kubernetes `readinessProbe` or load-balancer health check. The existing `GET /api/health` endpoint is unchanged and remains the liveness probe. [#1506](https://github.com/sourcebot-dev/sourcebot/issues/1506) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Link the changelog entry to this PR, not issue #1506.
Replace the issue URL with this pull request’s [#<id>](https://github.com/sourcebot-dev/sourcebot/pull/<id>) link. As per coding guidelines, changelog entries must include the GitHub pull request id at the end of the line.
🤖 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 `@CHANGELOG.md` around lines 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.
Source: Coding guidelines
| // Wraps a check function in a per-check timeout. When the timeout fires | ||
| // first, the check resolves as an error result; the underlying promise is | ||
| // allowed to settle in the background (its result is discarded). | ||
| const withTimeout = async <T>( | ||
| label: string, | ||
| check: () => Promise<T>, | ||
| timeoutMs: number, | ||
| ): Promise<T> => { | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| const timeout = new Promise<never>((_, reject) => { | ||
| timer = setTimeout(() => { | ||
| reject(new Error(`${label} check timed out after ${timeoutMs}ms`)); | ||
| }, timeoutMs); | ||
| }); | ||
| try { | ||
| return await Promise.race([check(), timeout]); | ||
| } finally { | ||
| if (timer) { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| }; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate route and related files =="
fd -a 'route\.ts$' packages/web/src/app 2>/dev/null | sed 's#^\./##' | head -50
echo
echo "== find health readiness route =="
path="$(fd 'route\.ts$' packages/web/src/app 2>/dev/null | grep 'health/ready/route\.ts$' || true)"
echo "path=$path"
if [ -n "$path" ]; then
wc -l "$path"
cat -n "$path" | sed -n '1,220p'
fi
echo
echo "== search health checks and clients =="
rg -n "check(Postgres|postgres|Redis|redis|Zoekt|zoekt|withTimeout|Promise\.all|Promise\.race|loadZoektClient|setDeadline|deadline|AbortController|cancel)" packages/web/src -S || trueRepository: sourcebot-dev/sourcebot
Length of output: 4096
🏁 Script executed:
#!/bin/bash
set -u
echo "== health ready route =="
if [ -f 'packages/web/src/app/api/health/ready/route.ts' ]; then
wc -l 'packages/web/src/app/api/health/ready/route.ts'
cat -n 'packages/web/src/app/api/health/ready/route.ts' | sed -n '1,220p'
elif [ -f 'packages/web/src/app/api/(server)/health/ready/route.ts' ]; then
wc -l 'packages/web/src/app/api/(server)/health/ready/route.ts'
cat -n 'packages/web/src/app/api/(server)/health/ready/route.ts' | sed -n '1,220p'
else
echo "not found"
fi
echo
echo "== health checks references =="
rg -n "checkPostgres|checkRedis|checkZoekt|loadZoektClient|Zoekt|withTimeout|Promise\.all|setDeadline|AbortController|deadline|cancel" packages/web/src packages -S --glob '!**/node_modules/**' | head -200 || true
echo
echo "== file tree relevant =="
fd -t f . packages/web/src | rg 'postgres|redis|zoekt|health|middleware|auth' | head -200 || trueRepository: sourcebot-dev/sourcebot
Length of output: 36357
🏁 Script executed:
#!/bin/bash
set -u
echo "== read client implementations =="
for f in packages/web/src/lib/zoektClient.ts packages/web/src/lib/redis.ts; do
if [ -f "$f" ]; then
echo "--- $f ($(wc -l < "$f") lines) ---"
cat -n "$f" | sed -n '1,220p'
fi
done
echo
echo "== package deps relevant to clients =="
python3 - <<'PY'
import json
from pathlib import Path
for p in [Path('packages/web/package.json'), Path('package.json')]:
if p.exists():
data=json.loads(p.read_text())
print(f'--- {p} ---')
for sec in ['dependencies','devDependencies','peerDependencies']:
vals=data.get(sec, {})
hits={k:v for k,v in vals.items() if any(x in k.lower() for x in ['nestjs','redis','prisma','grpc','grpc-js','`@grpc/grpc-js`'])}
print(sec, hits)
PYRepository: sourcebot-dev/sourcebot
Length of output: 3220
🏁 Script executed:
#!/bin/bash
set -u
echo "== find shared redis/prisma client implementations =="
rg -n "createRedisClient|create.*Prisma|PrismaClient|`@prisma/client`|redis-4/ioredis|ioredis|`@redis`" . --glob '!**/node_modules/**' --glob '!**/.git/**' | head -200Repository: sourcebot-dev/sourcebot
Length of output: 22779
🏁 Script executed:
#!/bin/bash
set -u
echo "== shared redis implementation =="
cat -n packages/shared/src/redis.ts | sed -n '1,120p'
echo
echo "== prisma instance config =="
cat -n packages/web/src/prisma.ts | sed -n '1,120p'
echo
echo "== package versions relevant to ioredis/prisma =="
python3 - <<'PY'
import re, pathlib
for f in ["packages/shared/package.json", "packages/db/package.json", "packages/web/package.json", "yarn.lock", "package.json"]:
p=pathlib.Path(f)
if p.exists():
tok=p.read_text(errors="replace")
lines=[]
for m in re.finditer(r'("ioredis"|"`@ioredis/ioredis`"|"`@prisma/client`"|"`@grpc/grpc-js`"|`@ioredis/commands`|ioredis@\S*)', tok):
start=tok.count("\n",0,m.start())+1
end=tok.count("\n",0,m.end())+1
line=tok.splitlines()[m.start().count("\n")]
if "yarn.lock" in f:
line=tok.splitlines()[len(tok[:m.start()].splitlines())]
print(f"{f}:{start}: {line.strip()}")
PYRepository: sourcebot-dev/sourcebot
Length of output: 8146
🌐 Web query:
ioredis v5 socketTimeout default ping interval default maxRetriesPerRequest null behavior when ping hangs
💡 Result:
In ioredis v5, the following behaviors apply regarding the requested configurations: socketTimeout: This option does not have a default value (it is undefined by default) [1][2][3]. When configured, if the socket fails to receive data within the specified number of milliseconds, the socket is considered dead and is destroyed, any running commands are rejected, and the client triggers its reconnect strategy [1][4]. maxRetriesPerRequest: The default value for this option is 20 [5]. When set to null, this behavior is disabled, meaning every command will wait indefinitely for the connection to become alive again [6][5]. Ping Interval: ioredis does not have a configurable "ping interval" option in the standard RedisOptions interface [1][4]. Connection health is primarily maintained by the underlying Node.js socket keep-alive mechanisms [7]. If a PING command (or any other command) hangs, it will be subject to the standard command processing logic. If a socketTimeout is set, that timeout will govern the maximum duration the socket waits for data; if the socket exceeds this limit without receiving data (which would occur if a PING response never arrives), it will be destroyed as described above [1][4]. Top results: [1][4][6][5]
Citations:
- 1: https://github.com/redis/ioredis/blob/main/lib/redis/RedisOptions.ts
- 2: https://github.com/redis/ioredis/blob/131ee24/lib/redis/RedisOptions.ts
- 3: https://github.com/redis/ioredis/blob/4f167bb/lib/redis/RedisOptions.ts
- 4: https://redis.github.io/ioredis/interfaces/CommonRedisOptions.html
- 5: https://github.com/redis/ioredis
- 6: Wrong typing for
maxRetriesPerRequestin ioredis v5? redis/ioredis#1550 - 7: ioredis connection timeout redis/ioredis#1679
🌐 Web query:
Prisma Client v6 default raw query timeout or how setPrismaClient timeout $queryRaw no timeout
💡 Result:
In Prisma Client v6, there is no global configurable timeout specifically for individual raw queries ($queryRaw, $executeRaw) executed outside of a transaction [1][2]. Consequently, there is no "default" timeout value for these operations because they do not have a built-in time limit imposed by Prisma Client [3]. To manage timeouts for raw queries, you must use one of the following approaches: 1. Use Database-Level Timeouts: Since Prisma does not natively limit the duration of a raw SQL statement, you can rely on database-specific settings. For example, in PostgreSQL, you can set statement_timeout at the session level by executing SET statement_timeout TO '10s'; within the same transaction or connection pool session as your raw query [4][5]. 2. Wrap in an Interactive Transaction: If you require a managed timeout, you can execute your raw query within an interactive $transaction [6]. You can configure the timeout option for the transaction, which will roll back the transaction if it exceeds the specified duration [6]. await prisma.$transaction( async (tx) => { return await tx.$queryRawSELECT * FROM users;; }, { timeout: 10000, // 10 seconds timeout for the transaction }); 3. Differentiate from Pool Timeout: Be aware that Prisma does have a pool_timeout (default 10s), which is the maximum time a query will wait in the queue to acquire a connection from the pool before throwing an error [7][8]. This is separate from the time taken to execute the query once a connection is acquired [7][8]. You can adjust this in your database connection URL (e.g., ?pool_timeout=5 for 5 seconds) [8]. If you are using a driver adapter (e.g., node-postgres), you may be able to configure timeouts directly through the driver's configuration object when initializing the adapter, rather than relying on Prisma's internal handling [7][9].
Citations:
- 1: https://www.prisma.io/docs/orm/v6/prisma-client/using-raw-sql/raw-queries
- 2: https://www.prisma.io/docs/orm/v6/prisma-client/using-raw-sql
- 3: feat(engine-core): allow setting request timeout prisma/prisma#12542
- 4: Specify a query timeout for the client prisma/prisma#12419
- 5: Prisma Engine not respecting socket_timeout in connection string prisma/prisma#18241
- 6: https://www.prisma.io/docs/orm/v6/prisma-client/queries/transactions
- 7: https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/databases-connections/connection-pool
- 8: https://www.prisma.io/docs/orm/v6/prisma-client/setup-and-configuration/databases-connections/connection-pool
- 9: Multiple concurrent raw queries lead to timeouts prisma/prisma#24812
Cancel timed-out readiness dependency operations.
withTimeout only rejects the readiness request after two seconds; the Postgres raw query, ioredis PING, and Zoekt List callback can continue running afterward because the clients/operations here are not being cancelled. Add socket/driver-level timeouts or native cancellation before calling withTimeout to avoid accumulating hung readiness probes during outages.
🤖 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.ts around lines 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.
| const client = await loadZoektClient(); | ||
| await withTimeout('zoekt', async () => { | ||
| await new Promise<void>((resolve, reject) => { | ||
| // An empty List with a 1s wall-time cap is the smallest request | ||
| // that exercises the gRPC channel end-to-end. It returns an | ||
| // empty result, not an error, even when no repos are indexed. | ||
| client.List( | ||
| { opts: { max_wall_time: { seconds: 1, nanos: 0 } } }, | ||
| (err) => { | ||
| if (err) { | ||
| reject(err); | ||
| } else { | ||
| resolve(); | ||
| } | ||
| }, | ||
| ); | ||
| }); | ||
| }, READINESS_TIMEOUT_MS); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Include lazy Zoekt initialization in the timeout.
Line 91 awaits loadZoektClient() before the timeout starts, so the first Zoekt readiness check can exceed the documented two-second bound.
Proposed fix
- const client = await loadZoektClient();
await withTimeout('zoekt', async () => {
+ const client = await loadZoektClient();
await new Promise<void>((resolve, reject) => {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const client = await loadZoektClient(); | |
| await withTimeout('zoekt', async () => { | |
| await new Promise<void>((resolve, reject) => { | |
| // An empty List with a 1s wall-time cap is the smallest request | |
| // that exercises the gRPC channel end-to-end. It returns an | |
| // empty result, not an error, even when no repos are indexed. | |
| client.List( | |
| { opts: { max_wall_time: { seconds: 1, nanos: 0 } } }, | |
| (err) => { | |
| if (err) { | |
| reject(err); | |
| } else { | |
| resolve(); | |
| } | |
| }, | |
| ); | |
| }); | |
| }, READINESS_TIMEOUT_MS); | |
| await withTimeout('zoekt', async () => { | |
| const client = await loadZoektClient(); | |
| await new Promise<void>((resolve, reject) => { | |
| // An empty List with a 1s wall-time cap is the smallest request | |
| // that exercises the gRPC channel end-to-end. It returns an | |
| // empty result, not an error, even when no repos are indexed. | |
| client.List( | |
| { opts: { max_wall_time: { seconds: 1, nanos: 0 } } }, | |
| (err) => { | |
| if (err) { | |
| reject(err); | |
| } else { | |
| resolve(); | |
| } | |
| }, | |
| ); | |
| }); | |
| }, READINESS_TIMEOUT_MS); |
🤖 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.ts around lines 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.


Summary
Adds
GET /api/health/ready, a Kubernetes-style readiness probe that returns per-dependency health (Postgres, Redis, Zoekt). The existingGET /api/healthliveness endpoint is unchanged.Self-hosted operators can now wire Sourcebot into a Kubernetes
readinessProbeor a load balancer health check and have the probe reflect whether the instance can actually serve traffic.Motivation
GET /api/healthcurrently 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 viaPromise.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 includesstatus,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+@opentelemetryCJS 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-PONGresponse path, and the parallel-execution invariant.docs/docs/api-reference/health.mdx— new docs page describing both endpoints, the response shape, and example Docker Composehealthcheck:and KuberneteslivenessProbe/readinessProbeblocks. Wired into the existing System group indocs/docs.json.CHANGELOG.md— entry under[Unreleased] → Added.Design decisions
Promise.allkeeps worst-case latency atmax(timeouts), notsum(timeouts). A test asserts this explicitly.Listwith a 1s wall-time cap for Zoekt. This is the smallest gRPC request that proves the channel is alive end-to-end. A dedicatedWebserverService.Healthmethod would be cleaner but doesn't exist in the vendored Zoekt proto.Listreturns 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?"); astrict=truequery param for "shards must be non-empty" is noted in the issue's future-work section./api/healthpolicy 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 passyarn 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 inauth.tsand a few EE files are documented inHANDOFF.md§21, out of scope)Backward compatibility
Fully backwards compatible. The existing
/api/healthendpoint is unchanged. The new endpoint is purely additive. Operators who do not configure it see no change.Risks
failureThreshold; the per-check 2s timeout absorbs brief blips.Listwith 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)
sourcebot_readiness_check_duration_seconds{check="postgres"}, etc.) — would build on the same per-check helpers introduced here./api/health/ready?strict=truemode that treats an empty Zoekt shard set as degraded.Note
Low Risk
Additive public endpoint and docs; existing
/api/healthis untouched. Readiness probes add periodic DB/Redis/Zoekt traffic—misconfigured aggressive intervals could increase load or briefly drain pods from rotation.Overview
Adds
GET /api/health/ready, a public readiness probe that reports Postgres, Redis, and Zoekt health with per-checklatencyMsand errors. Checks run in parallel with a 2s cap each; the handler returns 200 when all pass and 503 withstatus: degradedotherwise.GET /api/healthis unchanged for liveness.Zoekt gRPC client setup moves into
packages/web/src/lib/zoektClient.ts(lazy load + cache) so the route can be unit-tested without pulling gRPC at test time. Vitest covers healthy, each failure mode, non-PONGRedis, and parallel execution.Docs add
docs/api-reference/health.mdx(liveness vs readiness, response shape, K8s/Docker examples) and wire it indocs/docs.json; CHANGELOG records the feature.Reviewed by Cursor Bugbot for commit 375f2dd. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
Documentation