Skip to content

feat(client): support with block on QdrantClient and async with on AsyncQdrantClient #1285

Description

@Harsh23Kashyap

feat(client): support with block on QdrantClient and async with on AsyncQdrantClient

Problem

QdrantClient and AsyncQdrantClient own external resources (gRPC channels, the underlying httpx client, and in local mode the SQLite database plus the portalocker lockfile). Users have to call .close() manually, so a missed call or an unhandled exception leaks the gRPC channel and the httpx client.

The Python protocol for resource-owning objects is __enter__/__exit__ (sync) and __aenter__/__aexit__ (async). Neither is implemented on either client class. No open or closed issue mentions context-manager support; verified with gh search issues "context manager OR __enter__ OR __aenter__" against the repository.

Motivation

Every long-running qdrant-client process in a service needs a clean shutdown path. Right now the standard pattern is:

client = QdrantClient("localhost:6333")
try:
    client.upsert(...)
finally:
    client.close()

That is the literal code with exists to remove. A with QdrantClient(...) as client: block makes the cleanup path impossible to forget and matches the convention every other resource-owning client in the Python ecosystem uses (httpx.Client, boto3.client, redis.Redis, motor.AsyncIOMotorClient).

Background

QdrantClient.close() and AsyncQdrantClient.close() already exist (sync and async, both delegate to self._client.close()). The methods are idempotent — QdrantRemote.close() sets self._closed = True and QdrantLocal.close() sets self._closed = True and unlocks the portalocker file. The context manager just needs to call close() on exit.

The async file qdrant_client/async_qdrant_client.py is generated from the sync qdrant_client/qdrant_client.py by tools/async_client_generator/. The transformer pipeline converts def → async def for everything not in keep_sync and not in exclude_methods. To add __aenter__/__aexit__ to the async mirror, we need a regen-script hook (same pattern PR #1283 used for the http_client annotation remap).

Current behavior

with QdrantClient("localhost") as c: raises AttributeError: __enter__. async with AsyncQdrantClient("localhost") as c: raises TypeError: object async with can't be used in 'await' expression (or AttributeError: __aenter__). Users manage cleanup manually.

Expected behavior

Sync:

with QdrantClient("localhost") as client:
    client.upsert(...)
# client.close() called here, regardless of whether the block raised

Async:

async with AsyncQdrantClient("localhost") as client:
    await client.upsert(...)
# client.close() awaited here, regardless of whether the block raised

Works for both remote and local modes (QdrantRemote and QdrantLocal already implement idempotent close()).

Proposed solution

  1. Add __enter__/__exit__ to QdrantClient in qdrant_client/qdrant_client.py (sync, one-liners).
  2. Add __aenter__/__aexit__ to AsyncQdrantClient in qdrant_client/async_qdrant_client.py (async, one-liners).
  3. Update tools/async_client_generator/client_generator.py to exclude __enter__ and __exit__ from the sync→async transformation (we don't want a sync __enter__ in the async mirror — Python's with would call it but the user expects the async one).
  4. Add a sed step in tools/generate_async_client.sh that re-injects the __aenter__/__aexit__ block from a template after regen (mirrors the existing sed step for the http_client annotation remap from PR feat(remote): accept pre-built httpx.Client and httpx.AsyncClient #1283).
  5. Add tests: tests/test_context_manager.py covering sync and async for both :memory: (local) and a mock-remote client. Tests cover the success path, the exception path, and idempotent re-entry.

Alternatives considered

  • Add __enter__/__exit__ only, async clients use aclose() manually: rejects the async context manager request. Goes against httpx.AsyncClient / motor.AsyncIOMotorClient convention.
  • Use @contextmanager decorator on a method: doesn't work because __enter__/__exit__ need to be defined on the class, not returned from a method.
  • Refactor to a ClientPool that handles lifecycle: out of scope. The existing close() is already correct; we just need to wire it to the context manager protocol.
  • Add __enter__/__exit__ to both sync and async facades (not just sync): rejected. Adding sync context manager methods to the async client is non-idiomatic — httpx.AsyncClient only has __aenter__/__aexit__. Users who need sync semantics on an async client are doing something unusual; the await client.close() call is fine for them.

Scope

Out of scope:

  • __enter__/__exit__ on the lower-level QdrantRemote/AsyncQdrantRemote/QdrantLocal (no public demand; users interact with the facade).
  • Async iterators / async generators on the client.
  • Changes to close() semantics.

In scope:

  • 4 small methods on the two facade classes.
  • 1 line in client_generator.py (add names to exclude_methods).
  • 1 sed step in generate_async_client.sh.
  • 1 test file.

Acceptance criteria

  • with QdrantClient(":memory:") as c: calls c.close() on exit (success and exception).
  • async with AsyncQdrantClient(":memory:") as c: awaits c.close() on exit (success and exception).
  • Calling close() manually before entering the with block is safe (idempotent).
  • with block works for both local and remote clients.
  • 12+12 new tests in tests/test_context_manager.py, all pass alongside the existing suite.
  • mypy clean on the two facade files.
  • ruff check + format clean on the new test file.
  • Running bash tools/generate_async_client.sh regenerates __aenter__/__aexit__ correctly in the async mirror (regen test).

Backward compatibility

Fully additive. No public API change. The 4 new methods are all special-method names that don't conflict with anything.

Risks

  • The qdrant_client/async_qdrant_client.py is autogenerated; a future maintainer running bash tools/generate_async_client.sh without the new sed step would lose the __aenter__/__aexit__ methods. The sed step needs to be added in the same commit, and a regen test added so the loss is caught in CI.
  • Low risk of name collision: __enter__/__exit__/__aenter__/__aexit__ are all reserved special-method names; no user code can override them on the client instances.

Notes for the implementer

  • The base branch for this work is upstream/dev (not master). Maintainer joein closed docs(client): correct gRPC default timeout in QdrantClient docstring #1269 on 2026-07-21 with "All the PRs should point dev branch, not master", and the PR template at .github/PULL_REQUEST_TEMPLATE.md:4 says the same.
  • The test file should follow the pattern in tests/test_http_client_injection.py and tests/test_tracing.py (pytest, no live server, monkeypatch where needed).

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