fix(gateway): local infra defaults + qdrant collection retry-on-failure

- AI_LLM_BASE_URL default -> http://127.0.0.1:4014/v1 (was imrnes :20128/api/v1)
- QDRANT_URL fallback -> http://127.0.0.1:6333 (was imrnes :6333)
- ensureQdrantCollection: reset memoised promise on failure so a mid-way
  recreate abort (DELETE done, PUT failed) does not leave the collection
  permanently missing until process restart
- tests: qdrantEnsure.test.ts (3 cases: retry-on-failure, recreate, idempotent)
This commit is contained in:
asepharyana
2026-09-02 12:57:54 +07:00
parent e5304fde29
commit 4c38d53972
3 changed files with 89 additions and 8 deletions
@@ -50,10 +50,7 @@ export interface QdrantVerdictPayload {
}
function baseUrl(): string {
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
/\/+$/,
"",
);
return (config.QDRANT_URL ?? "http://127.0.0.1:6333").replace(/\/+$/, "");
}
function collectionName(): string {
@@ -166,6 +163,11 @@ export async function ensureQdrantCollection(
},
"Failed to ensure Qdrant collection",
);
// The memoised promise is permanently sticky: once it rejects (e.g. a
// recreate aborted mid-way — DELETE done, PUT failed), every later call
// returns the same rejected promise and the collection is never
// re-created until process restart. Reset so the next call retries.
ensureCollectionPromise = null;
return false;
}
})();
@@ -151,10 +151,7 @@ export const configSchema = z
.transform((v) => v === "true")
.default(false),
AI_LLM_API_KEY: z.string().optional(),
AI_LLM_BASE_URL: z
.string()
.url()
.default("http://100.121.180.82:20128/api/v1"),
AI_LLM_BASE_URL: z.string().url().default("http://127.0.0.1:4014/v1"),
AI_LLM_MODEL: z.string().default("text"),
// Vision uses the SAME router/base URL as text moderation
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// The qdrant client reads config at import time — we only need the
// reset-on-failure behavior, so mock global fetch and import fresh.
import {
ensureQdrantCollection,
resetQdrantCollectionCache,
} from "../src/modules/ai-moderation/qdrantClient.js";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
text: async () => JSON.stringify(body),
} as unknown as Response;
}
describe("ensureQdrantCollection retry-on-failure", () => {
beforeEach(() => {
resetQdrantCollectionCache();
vi.restoreAllMocks();
});
afterEach(() => {
resetQdrantCollectionCache();
vi.restoreAllMocks();
});
it("returns false on failure and does NOT get stuck — retries on next call", async () => {
// First call: GET collection fails hard (not a 404) → ensure rejects.
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(new Error("network down"))
// Second call: GET returns a matching-size collection → success.
.mockResolvedValueOnce(
jsonResponse({
result: { config: { params: { vectors: { size: 3072 } } } },
}),
);
const first = await ensureQdrantCollection(3072);
expect(first).toBe(false);
// Without the fix, the second call returns the memoised rejected promise
// and fetch is never called again. With the fix, it retries.
const second = await ensureQdrantCollection(3072);
expect(second).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("recreates collection when vector size changes (DELETE + PUT)", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({
result: { config: { params: { vectors: { size: 2048 } } } },
}),
) // GET old size
.mockResolvedValueOnce(jsonResponse({ result: true })) // DELETE
.mockResolvedValueOnce(jsonResponse({ result: true })); // PUT
const ok = await ensureQdrantCollection(3072);
expect(ok).toBe(true);
const methods = fetchMock.mock.calls.map(
(c) => (c[1] as RequestInit).method,
);
expect(methods).toEqual(["GET", "DELETE", "PUT"]);
});
it("is idempotent when the collection already matches", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
result: { config: { params: { vectors: { size: 3072 } } } },
}),
);
const ok = await ensureQdrantCollection(3072);
expect(ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});