Skip to content

AsyncQdrantClient (REST): per-call timeout= is not applied to the underlying httpx client #1325

Description

@blockgroot

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions