mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
f22b4d128f
The deliberate catch-and-rethrow sites in `loadProjectHashes` and
`getCollectionInfo` were re-throwing the raw Qdrant client error. With
no wrapping, the original message ("Internal Server Error") landed
verbatim in the MCP response and `codebase_status` `lastCompleted.error`
field, leaving consumers no way to distinguish which operation failed,
which collection it targeted, or whether the underlying error carried
an HTTP status code.
Add a `wrapQdrantError(operation, context, err)` helper and apply it at
both rethrow sites. The new error message looks like:
loadProjectHashes(collName=codebase_xxx) failed [status 500]: Internal Server Error
The original error is preserved via `cause`, so any consumer that walks
the cause chain still has full access. Also pick up `statusCode` as a
fallback for clients that use that field name.
Behaviour preserved:
- 404 / not-found still returns null from `getCollectionInfo` (no change).
- Re-throw is still the path for transient/unknown errors (no behaviour
change for the deliberate hardening that protects against destructive
clean-start cascades).
Adds `tests/unit/qdrant-error-wrapping.test.ts` covering the 404 pass-
through, wrapped-message format, status-code inclusion, missing-status
case, and `cause` preservation.
Closes #55.
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
//
|
|
// Tests for issue #55: re-thrown Qdrant errors must surface with operation
|
|
// context (function name, parameters, status code, original message) instead
|
|
// of the bare "Internal Server Error" the Qdrant REST client produces. Without
|
|
// this wrapping the only signal a tool consumer gets via MCP is the literal
|
|
// string "Internal Server Error" with no clue about which operation failed.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("../../src/services/logger.js", () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockGetCollection = vi.fn();
|
|
const mockGetCollections = vi.fn();
|
|
const mockRetrieve = vi.fn();
|
|
const mockCreateCollection = vi.fn();
|
|
const mockCreatePayloadIndex = vi.fn();
|
|
|
|
vi.mock("@qdrant/js-client-rest", () => ({
|
|
QdrantClient: class {
|
|
getCollection = mockGetCollection;
|
|
getCollections = mockGetCollections;
|
|
retrieve = mockRetrieve;
|
|
createCollection = mockCreateCollection;
|
|
createPayloadIndex = mockCreatePayloadIndex;
|
|
},
|
|
}));
|
|
|
|
describe("qdrant error wrapping (issue #55)", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
mockGetCollection.mockReset();
|
|
mockGetCollections.mockReset();
|
|
mockRetrieve.mockReset();
|
|
mockCreateCollection.mockReset();
|
|
mockCreatePayloadIndex.mockReset();
|
|
// Default: metadata collection already exists, so ensureMetadataCollection
|
|
// returns without trying to create one. Tests that need a fresh state
|
|
// override this in their own setup.
|
|
mockGetCollections.mockResolvedValue({
|
|
collections: [{ name: "socraticode_metadata" }],
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("getCollectionInfo", () => {
|
|
it("still returns null on 404 (collection-not-found is not an error)", async () => {
|
|
const notFound: Error & { status?: number } = new Error("Not found");
|
|
notFound.status = 404;
|
|
mockGetCollection.mockRejectedValueOnce(notFound);
|
|
|
|
const { getCollectionInfo } = await import("../../src/services/qdrant.js");
|
|
const result = await getCollectionInfo("missing_collection");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("wraps non-404 errors with operation name, collection, and status", async () => {
|
|
const serverErr: Error & { status?: number } = new Error("Internal Server Error");
|
|
serverErr.status = 500;
|
|
mockGetCollection.mockRejectedValueOnce(serverErr);
|
|
|
|
const { getCollectionInfo } = await import("../../src/services/qdrant.js");
|
|
await expect(getCollectionInfo("some_collection")).rejects.toThrow(
|
|
/getCollectionInfo\(collection=some_collection\) failed \[status 500\]: Internal Server Error/,
|
|
);
|
|
});
|
|
|
|
it("preserves the original error via `cause`", async () => {
|
|
const originalErr: Error & { status?: number } = new Error("boom");
|
|
originalErr.status = 503;
|
|
mockGetCollection.mockRejectedValueOnce(originalErr);
|
|
|
|
const { getCollectionInfo } = await import("../../src/services/qdrant.js");
|
|
try {
|
|
await getCollectionInfo("c1");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect((err as Error & { cause?: unknown }).cause).toBe(originalErr);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("loadProjectHashes", () => {
|
|
it("wraps Qdrant errors with collName context", async () => {
|
|
const serverErr: Error & { status?: number } = new Error("Internal Server Error");
|
|
serverErr.status = 500;
|
|
mockRetrieve.mockRejectedValueOnce(serverErr);
|
|
|
|
const { loadProjectHashes } = await import("../../src/services/qdrant.js");
|
|
await expect(loadProjectHashes("codebase_xyz")).rejects.toThrow(
|
|
/loadProjectHashes\(collName=codebase_xyz\) failed \[status 500\]: Internal Server Error/,
|
|
);
|
|
});
|
|
|
|
it("includes original error as `cause`", async () => {
|
|
const original: Error & { statusCode?: number } = new Error("connection refused");
|
|
original.statusCode = 502;
|
|
mockRetrieve.mockRejectedValueOnce(original);
|
|
|
|
const { loadProjectHashes } = await import("../../src/services/qdrant.js");
|
|
try {
|
|
await loadProjectHashes("codebase_abc");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect((err as Error).message).toContain("loadProjectHashes(collName=codebase_abc)");
|
|
expect((err as Error).message).toContain("[status 502]");
|
|
expect((err as Error).message).toContain("connection refused");
|
|
expect((err as Error & { cause?: unknown }).cause).toBe(original);
|
|
}
|
|
});
|
|
|
|
it("works without a status code on the underlying error", async () => {
|
|
const noStatus: Error = new Error("opaque failure");
|
|
mockRetrieve.mockRejectedValueOnce(noStatus);
|
|
|
|
const { loadProjectHashes } = await import("../../src/services/qdrant.js");
|
|
await expect(loadProjectHashes("codebase_no_status")).rejects.toThrow(
|
|
/loadProjectHashes\(collName=codebase_no_status\) failed: opaque failure/,
|
|
);
|
|
});
|
|
});
|
|
});
|