fix(docker): include api-key header in external Qdrant readiness probe

External Qdrant deployments that require authentication (notably Qdrant Cloud,
which returns 403 on every endpoint including /healthz without an api-key
header) cannot pass the existing readiness check, so codebase_index fails with
"Cannot reach external Qdrant" even though codebase_health and codebase_search
still succeed -- their qdrant-js client paths attach the key automatically.

Forward QDRANT_API_KEY as an `api-key` request header from
ensureExternalQdrantReady() by extending waitForService() with an optional
RequestInit. Docker-managed and unauthenticated external Qdrant deployments
remain unaffected (no init forwarded -> no headers).

Covered by two new unit tests in docker.test.ts.
This commit is contained in:
tazawa-masayoshi
2026-04-28 13:43:47 +09:00
parent 9e4fcf5192
commit 812fcd8905
2 changed files with 62 additions and 5 deletions
+17 -5
View File
@@ -7,6 +7,7 @@ import {
OLLAMA_HOST,
OLLAMA_IMAGE,
OLLAMA_PORT,
QDRANT_API_KEY,
QDRANT_CONTAINER_NAME,
QDRANT_GRPC_PORT,
QDRANT_HOST,
@@ -203,13 +204,20 @@ async function ensureExternalQdrantReady(onProgress?: InfraProgressCallback): Pr
onProgress?.(`Checking external Qdrant at ${baseUrl}...`);
logger.info("Checking external Qdrant", { url: baseUrl });
// Qdrant Cloud requires authentication on every endpoint, including /healthz
// (returns 403 without an api-key header). Locally run Qdrant typically does
// not, so the header is sent only when QDRANT_API_KEY is configured.
const init: RequestInit | undefined = QDRANT_API_KEY
? { headers: { "api-key": QDRANT_API_KEY } }
: undefined;
try {
// 5 retries × 1 s — fast fail for misconfiguration, brief grace for transient flakiness
await waitForService(healthUrl, "Qdrant", 5, 1000);
await waitForService(healthUrl, "Qdrant", 5, 1000, init);
} catch {
throw new Error(
`Cannot reach external Qdrant at ${baseUrl}.\n` +
"Verify that QDRANT_URL (or QDRANT_HOST/QDRANT_PORT) is correct and the server is reachable.",
"Verify that QDRANT_URL (or QDRANT_HOST/QDRANT_PORT) and QDRANT_API_KEY (if required) are correct and the server is reachable.",
);
}
@@ -348,12 +356,16 @@ export async function ensureOllamaContainerReady(onProgress?: InfraProgressCallb
// ── Shared ────────────────────────────────────────────────────────────────
/** Wait for an HTTP service to respond with 200 */
async function waitForService(url: string, serviceName: string, retries = 30, delayMs = 1000): Promise<void> {
/** Wait for an HTTP service to respond with 200.
*
* `init` is forwarded to fetch(), allowing callers to attach headers (e.g. an
* `api-key` header for Qdrant Cloud, whose /healthz endpoint requires auth).
*/
async function waitForService(url: string, serviceName: string, retries = 30, delayMs = 1000, init?: RequestInit): Promise<void> {
logger.info(`Waiting for ${serviceName} to be ready`, { url });
for (let i = 0; i < retries; i++) {
try {
const resp = await fetch(url);
const resp = await fetch(url, init);
if (resp.ok) {
logger.info(`${serviceName} is ready`);
return;
+45
View File
@@ -397,4 +397,49 @@ describe("ensureQdrantReady external mode", () => {
fetchSpy.mockRestore();
}, 30_000);
it("sends api-key header to /healthz when QDRANT_API_KEY is configured", async () => {
const docker = await loadDockerWithExternalMode({
QDRANT_URL: "https://cloud-qdrant.example:6333",
QDRANT_HOST: "cloud-qdrant.example",
QDRANT_API_KEY: "secret-key-xyz",
});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue({ ok: true } as Response);
const result = await docker.ensureQdrantReady();
expect(result).toEqual({ started: false, pulled: false });
expect(fetchSpy).toHaveBeenCalledWith(
"https://cloud-qdrant.example:6333/healthz",
expect.objectContaining({
headers: expect.objectContaining({ "api-key": "secret-key-xyz" }),
}),
);
fetchSpy.mockRestore();
});
it("omits api-key header when QDRANT_API_KEY is not set", async () => {
const docker = await loadDockerWithExternalMode({
QDRANT_URL: "http://local-qdrant:6333",
QDRANT_HOST: "local-qdrant",
// QDRANT_API_KEY intentionally undefined (matches local self-hosted Qdrant)
});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue({ ok: true } as Response);
await docker.ensureQdrantReady();
expect(fetchSpy).toHaveBeenCalledWith(
"http://local-qdrant:6333/healthz",
undefined,
);
fetchSpy.mockRestore();
});
});