Preserve Qdrant 4xx on the Vision gateway and stop leaking upstream errors.

Map Qdrant 400-499 through as the same status with a short public detail, and keep 5xx and transport failures as sanitized 502. CLIP and other services still use the old helper default.
This commit is contained in:
2026-08-25 07:58:48 +02:00
parent bd0abab759
commit 1713f0ff79
3 changed files with 505 additions and 49 deletions
+273
View File
@@ -0,0 +1,273 @@
from __future__ import annotations
import unittest
from typing import Any, Dict, Optional
from unittest.mock import patch
import httpx
from tests.test_gateway_llm import load_gateway_module
QDRANT = "http://qdrant-svc:8000"
LEAK = "SECRET-QDRANT-BODY-DO-NOT-LEAK"
AUTH = {"X-API-Key": "test-key"}
class StubVectorClient:
def __init__(
self,
*,
post_response: Optional[httpx.Response] = None,
get_response: Optional[httpx.Response] = None,
delete_response: Optional[httpx.Response] = None,
post_exception: Optional[Exception] = None,
get_exception: Optional[Exception] = None,
delete_exception: Optional[Exception] = None,
):
self.post_response = post_response
self.get_response = get_response
self.delete_response = delete_response
self.post_exception = post_exception
self.get_exception = get_exception
self.delete_exception = delete_exception
self.last_post: Dict[str, Any] = {}
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
self.last_post = {"url": url, **kwargs}
if self.post_exception is not None:
raise self.post_exception
if self.post_response is None:
return httpx.Response(404, json={"detail": f"No stub for POST {url}"})
return self.post_response
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
if self.get_exception is not None:
raise self.get_exception
if self.get_response is None:
return httpx.Response(404, json={"detail": f"No stub for GET {url}"})
return self.get_response
async def delete(self, url: str, **kwargs: Any) -> httpx.Response:
if self.delete_exception is not None:
raise self.delete_exception
if self.delete_response is None:
return httpx.Response(404, json={"detail": f"No stub for DELETE {url}"})
return self.delete_response
def _json_response(status: int, payload: Dict[str, Any], method: str = "POST", url: str = f"{QDRANT}/search") -> httpx.Response:
return httpx.Response(status, json=payload, request=httpx.Request(method, url))
class GatewayVectorErrorTests(unittest.IsolatedAsyncioTestCase):
async def _request(
self,
module: Any,
method: str,
path: str,
*,
json_payload: Optional[Dict[str, Any]] = None,
files: Any = None,
data: Any = None,
) -> httpx.Response:
transport = httpx.ASGITransport(app=module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
return await client.request(
method,
path,
headers=AUTH,
json=json_payload,
files=files,
data=data,
)
async def _search(self, module: Any, stub: StubVectorClient, payload: Optional[Dict[str, Any]] = None) -> httpx.Response:
with patch.object(module, "get_http_client", return_value=stub):
return await self._request(
module,
"POST",
"/vectors/search",
json_payload=payload or {"url": "https://cdn.example/art.webp", "limit": 12},
)
def _assert_sanitized(self, response: httpx.Response) -> None:
body = response.text
self.assertNotIn(LEAK, body)
self.assertNotIn("qdrant-svc", body)
self.assertNotIn("http://qdrant-svc:8000", body)
async def test_search_success_unchanged(self):
module = load_gateway_module(llm_enabled=False)
payload = {"results": [{"id": 7, "score": 0.91}]}
stub = StubVectorClient(post_response=_json_response(200, payload))
response = await self._search(module, stub)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), payload)
async def test_search_preserves_client_errors(self):
module = load_gateway_module(llm_enabled=False)
cases = (400, 401, 403, 404, 408, 409, 413, 422, 429)
for status in cases:
with self.subTest(status=status):
stub = StubVectorClient(
post_response=_json_response(status, {"detail": LEAK}),
)
response = await self._search(module, stub)
self.assertEqual(response.status_code, status)
self._assert_sanitized(response)
self.assertEqual(response.json()["detail"], "Vector service rejected the request.")
async def test_search_maps_server_errors_to_502(self):
module = load_gateway_module(llm_enabled=False)
for status in (500, 503):
with self.subTest(status=status):
stub = StubVectorClient(
post_response=_json_response(status, {"detail": LEAK}),
)
response = await self._search(module, stub)
self.assertEqual(response.status_code, 502)
self._assert_sanitized(response)
self.assertEqual(response.json()["detail"], "Vector service unavailable.")
async def test_search_transport_error_is_sanitized_502(self):
module = load_gateway_module(llm_enabled=False)
stub = StubVectorClient(
post_exception=httpx.ConnectError("boom", request=httpx.Request("POST", f"{QDRANT}/search")),
)
response = await self._search(module, stub)
self.assertEqual(response.status_code, 502)
body = response.text
self.assertNotIn("boom", body)
self.assertNotIn("qdrant-svc", body)
self.assertNotIn("http://", body)
self.assertEqual(response.json()["detail"], "Vector service unavailable.")
async def test_search_timeout_is_sanitized_502(self):
module = load_gateway_module(llm_enabled=False)
stub = StubVectorClient(
post_exception=httpx.ReadTimeout("timed out", request=httpx.Request("POST", f"{QDRANT}/search")),
)
response = await self._search(module, stub)
self.assertEqual(response.status_code, 502)
self.assertNotIn("timed out", response.text)
self.assertEqual(response.json()["detail"], "Vector service unavailable.")
async def test_search_file_success_and_errors(self):
module = load_gateway_module(llm_enabled=False)
stub_ok = StubVectorClient(post_response=_json_response(200, {"results": []}, url=f"{QDRANT}/search/file"))
with patch.object(module, "get_http_client", return_value=stub_ok):
ok = await self._request(
module,
"POST",
"/vectors/search/file",
files={"file": ("query.webp", b"image-bytes", "image/webp")},
data={"limit": "5"},
)
self.assertEqual(ok.status_code, 200)
self.assertIn("file", stub_ok.last_post.get("files", {}))
self.assertEqual(stub_ok.last_post.get("data", {}).get("limit"), "5")
stub_422 = StubVectorClient(post_response=_json_response(422, {"detail": LEAK}, url=f"{QDRANT}/search/file"))
with patch.object(module, "get_http_client", return_value=stub_422):
bad = await self._request(
module,
"POST",
"/vectors/search/file",
files={"file": ("query.webp", b"image-bytes", "image/webp")},
data={"limit": "5"},
)
self.assertEqual(bad.status_code, 422)
self._assert_sanitized(bad)
stub_500 = StubVectorClient(post_response=_json_response(500, {"detail": LEAK}, url=f"{QDRANT}/search/file"))
with patch.object(module, "get_http_client", return_value=stub_500):
server = await self._request(
module,
"POST",
"/vectors/search/file",
files={"file": ("query.webp", b"image-bytes", "image/webp")},
data={"limit": "5"},
)
self.assertEqual(server.status_code, 502)
self._assert_sanitized(server)
stub_net = StubVectorClient(
post_exception=httpx.ConnectError("boom", request=httpx.Request("POST", f"{QDRANT}/search/file")),
)
with patch.object(module, "get_http_client", return_value=stub_net):
net = await self._request(
module,
"POST",
"/vectors/search/file",
files={"file": ("query.webp", b"image-bytes", "image/webp")},
data={"limit": "5"},
)
self.assertEqual(net.status_code, 502)
self.assertNotIn("boom", net.text)
async def test_get_collection_404_and_500(self):
module = load_gateway_module(llm_enabled=False)
stub_404 = StubVectorClient(
get_response=_json_response(404, {"detail": LEAK}, method="GET", url=f"{QDRANT}/collections/nonexistent"),
)
with patch.object(module, "get_http_client", return_value=stub_404):
missing = await self._request(module, "GET", "/vectors/collections/nonexistent")
self.assertEqual(missing.status_code, 404)
self._assert_sanitized(missing)
stub_500 = StubVectorClient(
get_response=_json_response(500, {"detail": LEAK}, method="GET", url=f"{QDRANT}/collections/nonexistent"),
)
with patch.object(module, "get_http_client", return_value=stub_500):
server = await self._request(module, "GET", "/vectors/collections/nonexistent")
self.assertEqual(server.status_code, 502)
self._assert_sanitized(server)
async def test_delete_collection_semantics(self):
module = load_gateway_module(llm_enabled=False)
stub_ok = StubVectorClient(delete_response=_json_response(200, {"ok": True}, method="DELETE", url=f"{QDRANT}/collections/test"))
with patch.object(module, "get_http_client", return_value=stub_ok):
ok = await self._request(module, "DELETE", "/vectors/collections/test")
self.assertEqual(ok.status_code, 200)
self.assertEqual(ok.json(), {"ok": True})
stub_404 = StubVectorClient(delete_response=_json_response(404, {"detail": LEAK}, method="DELETE", url=f"{QDRANT}/collections/test"))
with patch.object(module, "get_http_client", return_value=stub_404):
missing = await self._request(module, "DELETE", "/vectors/collections/test")
self.assertEqual(missing.status_code, 404)
self._assert_sanitized(missing)
stub_500 = StubVectorClient(delete_response=_json_response(500, {"detail": LEAK}, method="DELETE", url=f"{QDRANT}/collections/test"))
with patch.object(module, "get_http_client", return_value=stub_500):
server = await self._request(module, "DELETE", "/vectors/collections/test")
self.assertEqual(server.status_code, 502)
self._assert_sanitized(server)
stub_net = StubVectorClient(
delete_exception=httpx.ConnectError("boom", request=httpx.Request("DELETE", f"{QDRANT}/collections/test")),
)
with patch.object(module, "get_http_client", return_value=stub_net):
net = await self._request(module, "DELETE", "/vectors/collections/test")
self.assertEqual(net.status_code, 502)
self.assertNotIn("boom", net.text)
async def test_clip_422_still_mapped_to_502(self):
module = load_gateway_module(llm_enabled=False)
stub = StubVectorClient(
post_response=httpx.Response(
422,
json={"detail": "SECRET-CLIP-BODY"},
request=httpx.Request("POST", "http://clip:8000/analyze"),
),
)
with patch.object(module, "get_http_client", return_value=stub):
response = await self._request(
module,
"POST",
"/analyze/clip",
json_payload={"url": "https://cdn.example/art.webp"},
)
self.assertEqual(response.status_code, 502)
self.assertNotEqual(response.status_code, 422)