Skip to content

Commit 1897c37

Browse files
benashbyBen Ashby
authored andcommitted
fix(qdrant): resolve URL port handling bug for HTTPS URLs (RooCodeInc#4992)
Co-authored-by: Ben Ashby <[email protected]>
1 parent 0d1ad6d commit 1897c37

File tree

2 files changed

+337
-11
lines changed

2 files changed

+337
-11
lines changed

src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts

Lines changed: 245 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ describe("QdrantVectorStore", () => {
5858
it("should correctly initialize QdrantClient and collectionName in constructor", () => {
5959
expect(QdrantClient).toHaveBeenCalledTimes(1)
6060
expect(QdrantClient).toHaveBeenCalledWith({
61-
url: mockQdrantUrl,
61+
host: "mock-qdrant",
62+
https: false,
63+
port: 6333,
6264
apiKey: mockApiKey,
6365
headers: {
6466
"User-Agent": "Roo-Code",
@@ -75,7 +77,9 @@ describe("QdrantVectorStore", () => {
7577
const vectorStoreWithDefaults = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize)
7678

7779
expect(QdrantClient).toHaveBeenLastCalledWith({
78-
url: "http://localhost:6333", // Should use default QDRANT_URL
80+
host: "localhost",
81+
https: false,
82+
port: 6333,
7983
apiKey: undefined,
8084
headers: {
8185
"User-Agent": "Roo-Code",
@@ -87,14 +91,252 @@ describe("QdrantVectorStore", () => {
8791
const vectorStoreWithoutKey = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize)
8892

8993
expect(QdrantClient).toHaveBeenLastCalledWith({
90-
url: mockQdrantUrl,
94+
host: "mock-qdrant",
95+
https: false,
96+
port: 6333,
9197
apiKey: undefined,
9298
headers: {
9399
"User-Agent": "Roo-Code",
94100
},
95101
})
96102
})
97103

104+
describe("URL Parsing and Explicit Port Handling", () => {
105+
describe("HTTPS URL handling", () => {
106+
it("should use explicit port 443 for HTTPS URLs without port (fixes the main bug)", () => {
107+
const vectorStore = new QdrantVectorStore(
108+
mockWorkspacePath,
109+
"https://qdrant.ashbyfam.com",
110+
mockVectorSize,
111+
)
112+
expect(QdrantClient).toHaveBeenLastCalledWith({
113+
host: "qdrant.ashbyfam.com",
114+
https: true,
115+
port: 443,
116+
apiKey: undefined,
117+
headers: {
118+
"User-Agent": "Roo-Code",
119+
},
120+
})
121+
expect((vectorStore as any).qdrantUrl).toBe("https://qdrant.ashbyfam.com")
122+
})
123+
124+
it("should use explicit port for HTTPS URLs with explicit port", () => {
125+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "https://example.com:9000", mockVectorSize)
126+
expect(QdrantClient).toHaveBeenLastCalledWith({
127+
host: "example.com",
128+
https: true,
129+
port: 9000,
130+
apiKey: undefined,
131+
headers: {
132+
"User-Agent": "Roo-Code",
133+
},
134+
})
135+
expect((vectorStore as any).qdrantUrl).toBe("https://example.com:9000")
136+
})
137+
138+
it("should use port 443 for HTTPS URLs with paths and query parameters", () => {
139+
const vectorStore = new QdrantVectorStore(
140+
mockWorkspacePath,
141+
"https://example.com/api/v1?key=value",
142+
mockVectorSize,
143+
)
144+
expect(QdrantClient).toHaveBeenLastCalledWith({
145+
host: "example.com",
146+
https: true,
147+
port: 443,
148+
apiKey: undefined,
149+
headers: {
150+
"User-Agent": "Roo-Code",
151+
},
152+
})
153+
expect((vectorStore as any).qdrantUrl).toBe("https://example.com/api/v1?key=value")
154+
})
155+
})
156+
157+
describe("HTTP URL handling", () => {
158+
it("should use explicit port 80 for HTTP URLs without port", () => {
159+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://example.com", mockVectorSize)
160+
expect(QdrantClient).toHaveBeenLastCalledWith({
161+
host: "example.com",
162+
https: false,
163+
port: 80,
164+
apiKey: undefined,
165+
headers: {
166+
"User-Agent": "Roo-Code",
167+
},
168+
})
169+
expect((vectorStore as any).qdrantUrl).toBe("http://example.com")
170+
})
171+
172+
it("should use explicit port for HTTP URLs with explicit port", () => {
173+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:8080", mockVectorSize)
174+
expect(QdrantClient).toHaveBeenLastCalledWith({
175+
host: "localhost",
176+
https: false,
177+
port: 8080,
178+
apiKey: undefined,
179+
headers: {
180+
"User-Agent": "Roo-Code",
181+
},
182+
})
183+
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:8080")
184+
})
185+
186+
it("should use port 80 for HTTP URLs while preserving paths and query parameters", () => {
187+
const vectorStore = new QdrantVectorStore(
188+
mockWorkspacePath,
189+
"http://example.com/api/v1?key=value",
190+
mockVectorSize,
191+
)
192+
expect(QdrantClient).toHaveBeenLastCalledWith({
193+
host: "example.com",
194+
https: false,
195+
port: 80,
196+
apiKey: undefined,
197+
headers: {
198+
"User-Agent": "Roo-Code",
199+
},
200+
})
201+
expect((vectorStore as any).qdrantUrl).toBe("http://example.com/api/v1?key=value")
202+
})
203+
})
204+
205+
describe("Hostname handling", () => {
206+
it("should convert hostname to http with port 80", () => {
207+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "qdrant.example.com", mockVectorSize)
208+
expect(QdrantClient).toHaveBeenLastCalledWith({
209+
host: "qdrant.example.com",
210+
https: false,
211+
port: 80,
212+
apiKey: undefined,
213+
headers: {
214+
"User-Agent": "Roo-Code",
215+
},
216+
})
217+
expect((vectorStore as any).qdrantUrl).toBe("http://qdrant.example.com")
218+
})
219+
220+
it("should handle hostname:port format with explicit port", () => {
221+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "localhost:6333", mockVectorSize)
222+
expect(QdrantClient).toHaveBeenLastCalledWith({
223+
host: "localhost",
224+
https: false,
225+
port: 6333,
226+
apiKey: undefined,
227+
headers: {
228+
"User-Agent": "Roo-Code",
229+
},
230+
})
231+
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
232+
})
233+
234+
it("should handle explicit HTTP URLs correctly", () => {
235+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:9000", mockVectorSize)
236+
expect(QdrantClient).toHaveBeenLastCalledWith({
237+
host: "localhost",
238+
https: false,
239+
port: 9000,
240+
apiKey: undefined,
241+
headers: {
242+
"User-Agent": "Roo-Code",
243+
},
244+
})
245+
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:9000")
246+
})
247+
})
248+
249+
describe("IP address handling", () => {
250+
it("should convert IP address to http with port 80", () => {
251+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100", mockVectorSize)
252+
expect(QdrantClient).toHaveBeenLastCalledWith({
253+
host: "192.168.1.100",
254+
https: false,
255+
port: 80,
256+
apiKey: undefined,
257+
headers: {
258+
"User-Agent": "Roo-Code",
259+
},
260+
})
261+
expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100")
262+
})
263+
264+
it("should handle IP:port format with explicit port", () => {
265+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100:6333", mockVectorSize)
266+
expect(QdrantClient).toHaveBeenLastCalledWith({
267+
host: "192.168.1.100",
268+
https: false,
269+
port: 6333,
270+
apiKey: undefined,
271+
headers: {
272+
"User-Agent": "Roo-Code",
273+
},
274+
})
275+
expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100:6333")
276+
})
277+
})
278+
279+
describe("Edge cases", () => {
280+
it("should handle undefined URL with host-based config", () => {
281+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize)
282+
expect(QdrantClient).toHaveBeenLastCalledWith({
283+
host: "localhost",
284+
https: false,
285+
port: 6333,
286+
apiKey: undefined,
287+
headers: {
288+
"User-Agent": "Roo-Code",
289+
},
290+
})
291+
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
292+
})
293+
294+
it("should handle empty string URL with host-based config", () => {
295+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "", mockVectorSize)
296+
expect(QdrantClient).toHaveBeenLastCalledWith({
297+
host: "localhost",
298+
https: false,
299+
port: 6333,
300+
apiKey: undefined,
301+
headers: {
302+
"User-Agent": "Roo-Code",
303+
},
304+
})
305+
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
306+
})
307+
308+
it("should handle whitespace-only URL with host-based config", () => {
309+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, " ", mockVectorSize)
310+
expect(QdrantClient).toHaveBeenLastCalledWith({
311+
host: "localhost",
312+
https: false,
313+
port: 6333,
314+
apiKey: undefined,
315+
headers: {
316+
"User-Agent": "Roo-Code",
317+
},
318+
})
319+
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
320+
})
321+
})
322+
323+
describe("Invalid URL fallback", () => {
324+
it("should treat invalid URLs as hostnames with port 80", () => {
325+
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "invalid-url-format", mockVectorSize)
326+
expect(QdrantClient).toHaveBeenLastCalledWith({
327+
host: "invalid-url-format",
328+
https: false,
329+
port: 80,
330+
apiKey: undefined,
331+
headers: {
332+
"User-Agent": "Roo-Code",
333+
},
334+
})
335+
expect((vectorStore as any).qdrantUrl).toBe("http://invalid-url-format")
336+
})
337+
})
338+
})
339+
98340
describe("initialize", () => {
99341
it("should create a new collection if none exists and return true", async () => {
100342
// Mock getCollection to throw a 404-like error

src/services/code-index/vector-store/qdrant-client.ts

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,105 @@ export class QdrantVectorStore implements IVectorStore {
2424
* @param url Optional URL to the Qdrant server
2525
*/
2626
constructor(workspacePath: string, url: string, vectorSize: number, apiKey?: string) {
27-
this.qdrantUrl = url || "http://localhost:6333"
28-
this.client = new QdrantClient({
29-
url: this.qdrantUrl,
30-
apiKey,
31-
headers: {
32-
"User-Agent": "Roo-Code",
33-
},
34-
})
27+
// Parse the URL to determine the appropriate QdrantClient configuration
28+
const parsedUrl = this.parseQdrantUrl(url)
29+
30+
// Store the resolved URL for our property
31+
this.qdrantUrl = parsedUrl
32+
33+
try {
34+
const urlObj = new URL(parsedUrl)
35+
36+
// Always use host-based configuration with explicit ports to avoid QdrantClient defaults
37+
let port: number
38+
let useHttps: boolean
39+
40+
if (urlObj.port) {
41+
// Explicit port specified - use it and determine protocol
42+
port = Number(urlObj.port)
43+
useHttps = urlObj.protocol === "https:"
44+
} else {
45+
// No explicit port - use protocol defaults
46+
if (urlObj.protocol === "https:") {
47+
port = 443
48+
useHttps = true
49+
} else {
50+
// http: or other protocols default to port 80
51+
port = 80
52+
useHttps = false
53+
}
54+
}
55+
56+
this.client = new QdrantClient({
57+
host: urlObj.hostname,
58+
https: useHttps,
59+
port: port,
60+
apiKey,
61+
headers: {
62+
"User-Agent": "Roo-Code",
63+
},
64+
})
65+
} catch (urlError) {
66+
// If URL parsing fails, fall back to URL-based config
67+
this.client = new QdrantClient({
68+
url: parsedUrl,
69+
apiKey,
70+
headers: {
71+
"User-Agent": "Roo-Code",
72+
},
73+
})
74+
}
3575

3676
// Generate collection name from workspace path
3777
const hash = createHash("sha256").update(workspacePath).digest("hex")
3878
this.vectorSize = vectorSize
3979
this.collectionName = `ws-${hash.substring(0, 16)}`
4080
}
4181

82+
/**
83+
* Parses and normalizes Qdrant server URLs to handle various input formats
84+
* @param url Raw URL input from user
85+
* @returns Properly formatted URL for QdrantClient
86+
*/
87+
private parseQdrantUrl(url: string | undefined): string {
88+
// Handle undefined/null/empty cases
89+
if (!url || url.trim() === "") {
90+
return "http://localhost:6333"
91+
}
92+
93+
const trimmedUrl = url.trim()
94+
95+
// Check if it starts with a protocol
96+
if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://") && !trimmedUrl.includes("://")) {
97+
// No protocol - treat as hostname
98+
return this.parseHostname(trimmedUrl)
99+
}
100+
101+
try {
102+
// Attempt to parse as complete URL - return as-is, let constructor handle ports
103+
const parsedUrl = new URL(trimmedUrl)
104+
return trimmedUrl
105+
} catch {
106+
// Failed to parse as URL - treat as hostname
107+
return this.parseHostname(trimmedUrl)
108+
}
109+
}
110+
111+
/**
112+
* Handles hostname-only inputs
113+
* @param hostname Raw hostname input
114+
* @returns Properly formatted URL with http:// prefix
115+
*/
116+
private parseHostname(hostname: string): string {
117+
if (hostname.includes(":")) {
118+
// Has port - add http:// prefix if missing
119+
return hostname.startsWith("http") ? hostname : `http://${hostname}`
120+
} else {
121+
// No port - add http:// prefix without port (let constructor handle port assignment)
122+
return `http://${hostname}`
123+
}
124+
}
125+
42126
private async getCollectionInfo(): Promise<Schemas["CollectionInfo"] | null> {
43127
try {
44128
const collectionInfo = await this.client.getCollection(this.collectionName)

0 commit comments

Comments
 (0)