Summary
AsyncQdrantClient's REST transport silently drops the per-call timeout= argument at the HTTP-client level, so async REST operations that ask for extra time (e.g. await client.create_collection(..., timeout=30)) are still bound by httpx's default 5-second connect/read/write/pool timeout instead. The sync QdrantClient does not have this problem.
Environment
- qdrant-client version: current
dev/master (commit 550484d767d319857d4f46e97d4551ba419ee670), also present in the latest PyPI release — the code path is unchanged back to 2024
- Python: 3.12.10
- httpx: 0.28.1
prefer_grpc: default (False) — this affects the default REST transport, not just an opt-in path
Reproduction
Both ApiClient (sync) and AsyncApiClient (async) build a request the same way from qdrant_client/http/api_client.py, but only the sync one promotes a per-call timeout found in the query params up to httpx's own client-side timeout. This is directly observable without a running server, by inspecting the kwargs each one hands to build_request:
import asyncio
from unittest.mock import patch
from qdrant_client.http.api_client import ApiClient, AsyncApiClient
captured = {}
def make_capture(target):
def _capture(self, method, url, **kwargs):
captured[target] = kwargs
class Dummy: pass
return Dummy()
return _capture
sync_client = ApiClient(host="http://localhost:6333")
with patch.object(sync_client._client, "build_request", make_capture("sync").__get__(sync_client._client)):
try:
sync_client.request(type_=None, method="GET", url="/collections/{c}",
path_params={"c": "x"}, params={"timeout": "50"})
except Exception:
pass
async_client = AsyncApiClient(host="http://localhost:6333")
with patch.object(async_client._async_client, "build_request", make_capture("async").__get__(async_client._async_client)):
try:
asyncio.run(async_client.request(type_=None, method="GET", url="/collections/{c}",
path_params={"c": "x"}, params={"timeout": "50"}))
except Exception:
pass
print("sync build_request kwargs: ", captured["sync"])
print("async build_request kwargs:", captured["async"])
Output:
sync build_request kwargs: {'params': {'timeout': '50'}, 'timeout': 50}
async build_request kwargs: {'params': {'timeout': '50'}}
The sync call ends up passing timeout=50 to httpx's build_request, overriding httpx's default. The async call passes no timeout at all, so httpx's Timeout(timeout=5.0) default applies regardless of what the caller asked for.
Expected behavior
A per-call timeout= on any REST method (e.g. create_collection, query_points, upsert, ...) should give the underlying httpx client at least that much time, for both QdrantClient and AsyncQdrantClient — this is exactly what tests/test_qdrant_client.py::test_timeout_propagation asserts for the sync client.
Actual behavior
Only the sync client does this. Concretely, in qdrant_client/http/api_client.py:
# ApiClient.request (sync) — present
if "params" in kwargs and "timeout" in kwargs["params"]:
kwargs["timeout"] = int(kwargs["params"]["timeout"])
AsyncApiClient.request builds the request the same way (urljoin, build_request(method, url, **kwargs)) but has no equivalent block, so a slow operation that legitimately needs more than 5 seconds can fail with a spurious httpx ReadTimeout/ResponseHandlingException under AsyncQdrantClient even though the caller explicitly asked for more time via timeout=.
Analysis
This looks like sync/async drift rather than an intentional difference: the override block was added to the sync ApiClient.request by #534 ("propagate timeout from methods to httpx") and never ported to AsyncApiClient.request, which lives in the same file. There's a regression test for the sync path (tests/test_qdrant_client.py:1740, test_timeout_propagation) but no async equivalent in tests/test_async_qdrant_client.py.
This is related to but distinct from #948 ("Unexpected Timeout Behavior in AsyncQdrantClient") — that issue and its in-progress fix are about the gRPC transport (the protobuf timeout field not being populated), and a comment there explicitly scopes the HTTP path out: "HTTP path is out of scope for this PR — same timeout=timeout pattern, but httpx treats None as 'no timeout' which is a different bug; that one needs a separate discussion." This issue is that separate discussion, for the REST/HTTP transport specifically.
Proposed fix
Add the same if "params" in kwargs and "timeout" in kwargs["params"]: kwargs["timeout"] = int(kwargs["params"]["timeout"]) block to AsyncApiClient.request before it calls build_request, plus an async regression test mirroring test_timeout_propagation. Happy to open a PR for this.
Summary
AsyncQdrantClient's REST transport silently drops the per-calltimeout=argument at the HTTP-client level, so async REST operations that ask for extra time (e.g.await client.create_collection(..., timeout=30)) are still bound by httpx's default 5-second connect/read/write/pool timeout instead. The syncQdrantClientdoes not have this problem.Environment
dev/master(commit550484d767d319857d4f46e97d4551ba419ee670), also present in the latest PyPI release — the code path is unchanged back to 2024prefer_grpc: default (False) — this affects the default REST transport, not just an opt-in pathReproduction
Both
ApiClient(sync) andAsyncApiClient(async) build a request the same way fromqdrant_client/http/api_client.py, but only the sync one promotes a per-calltimeoutfound in the query params up to httpx's own client-side timeout. This is directly observable without a running server, by inspecting the kwargs each one hands tobuild_request:Output:
The sync call ends up passing
timeout=50to httpx'sbuild_request, overriding httpx's default. The async call passes notimeoutat all, so httpx'sTimeout(timeout=5.0)default applies regardless of what the caller asked for.Expected behavior
A per-call
timeout=on any REST method (e.g.create_collection,query_points,upsert, ...) should give the underlying httpx client at least that much time, for bothQdrantClientandAsyncQdrantClient— this is exactly whattests/test_qdrant_client.py::test_timeout_propagationasserts for the sync client.Actual behavior
Only the sync client does this. Concretely, in
qdrant_client/http/api_client.py:AsyncApiClient.requestbuilds the request the same way (urljoin,build_request(method, url, **kwargs)) but has no equivalent block, so a slow operation that legitimately needs more than 5 seconds can fail with a spurious httpxReadTimeout/ResponseHandlingExceptionunderAsyncQdrantClienteven though the caller explicitly asked for more time viatimeout=.Analysis
This looks like sync/async drift rather than an intentional difference: the override block was added to the sync
ApiClient.requestby #534 ("propagate timeout from methods to httpx") and never ported toAsyncApiClient.request, which lives in the same file. There's a regression test for the sync path (tests/test_qdrant_client.py:1740,test_timeout_propagation) but no async equivalent intests/test_async_qdrant_client.py.This is related to but distinct from #948 ("Unexpected Timeout Behavior in AsyncQdrantClient") — that issue and its in-progress fix are about the gRPC transport (the protobuf
timeoutfield not being populated), and a comment there explicitly scopes the HTTP path out: "HTTP path is out of scope for this PR — sametimeout=timeoutpattern, buthttpxtreatsNoneas 'no timeout' which is a different bug; that one needs a separate discussion." This issue is that separate discussion, for the REST/HTTP transport specifically.Proposed fix
Add the same
if "params" in kwargs and "timeout" in kwargs["params"]: kwargs["timeout"] = int(kwargs["params"]["timeout"])block toAsyncApiClient.requestbefore it callsbuild_request, plus an async regression test mirroringtest_timeout_propagation. Happy to open a PR for this.