Skip to content

feat(client): add health_check() method to all 6 client classes #1289

Description

@Harsh23Kashyap

feat(client): add health_check() method to all 6 client classes

Problem

The Qdrant server exposes a /healthz endpoint (Kubernetes-style liveness probe), and the generated service_api.py already has the healthz() binding on both SyncServiceApi and AsyncServiceApi. The generated REST client (SyncApis/AsyncApis) exposes it as client.http.service_api.healthz(). But none of the 6 high-level client classes (QdrantClient, AsyncQdrantClient, QdrantRemote, AsyncQdrantRemote, QdrantLocal, AsyncQdrantLocal) provides a public health_check() method that wraps the endpoint.

The current workaround for users is to dig into the internal REST client (client.http.service_api.healthz()), which leaks the openapi_client/http implementation detail into user code and breaks if the wrapper changes.

Motivation

Three concrete user-facing problems this addresses:

  1. Kubernetes liveness/readiness probes. Operators running qdrant-client in a sidecar (or a custom service that holds a client) need a programmatic way to ask "is the server reachable right now?". The /healthz endpoint exists for this exact purpose, but the client doesn't expose it.
  2. Application-level health checks. Multi-tenant services that hold a long-lived QdrantClient and want a /health endpoint in their own HTTP API need to forward the underlying server's health signal.
  3. Pre-operation validation. Tests and migration scripts that need to "is the server up before I run the migration?" currently catch broad Exception on every operation. A dedicated health_check() makes the intent clear.

Verified: grep -n "def health" qdrant_client/qdrant_remote.py qdrant_client/async_qdrant_remote.py qdrant_client/qdrant_client.py qdrant_client/async_qdrant_client.py qdrant_client/local/qdrant_local.py qdrant_client/local/async_qdrant_local.py returns nothing. No open or closed issue mentions a public health_check().

Background

  • The service API client lives at qdrant_client/http/api/service_api.py. Both SyncServiceApi.healthz() (line 234) and AsyncServiceApi.healthz() (line 168) call GET /healthz and return str (the response body, e.g. "all is good").
  • The QdrantRemote._init_grpc_* and version_check.is_compatible paths already silently tolerate an unreachable server (they show_warning and continue). A dedicated health_check() gives users an explicit, programmatic signal.
  • The local mode (QdrantLocal) is in-process, so "health" is defined as "not closed" — the same way closed already works on the inner class.

Current behavior

>>> client = QdrantClient("localhost:6333")
>>> client.health_check()
AttributeError: 'QdrantClient' object has no attribute 'health_check'
# workaround: client._client.openapi_client.service_api.healthz()  # leaks internals

Expected behavior

>>> QdrantClient("localhost:6333").health_check()
True   # or False, depending on server reachability
>>> QdrantClient("http://does-not-exist:1").health_check()
False
>>> QdrantClient(":memory:").health_check()
True   # in-process; only False after .close()
>>> QdrantClient(":memory:").close(); QdrantClient(":memory:").health_check()  # fresh client
True

Async:

>>> await AsyncQdrantClient("localhost:6333").health_check()
True

The method:

  • Calls the REST /healthz endpoint.
  • Returns True on a successful response (HTTP 2xx), False on any exception (timeout, connection refused, non-2xx, API error).
  • For local mode, returns not self._closed.

No exception escapes the method — the bool is the contract. This matches the httpx.Client and boto3.client health-check conventions.

Proposed solution

Add a health_check() method to each of the 6 classes:

  • QdrantRemote.health_check() — sync. Calls self.openapi_client.service_api.healthz(). Returns True on success, False on any exception.
  • AsyncQdrantRemote.health_check() — async. Awaits self.http.service_api.healthz(). Returns True on success, False on any exception.
  • QdrantLocal.health_check() — sync. Returns not self._closed.
  • AsyncQdrantLocal.health_check() — sync (no I/O, in-process). Returns not self._closed.
  • QdrantClient.health_check() — sync. Delegates to self._client.health_check(). Returns the result.
  • AsyncQdrantClient.health_check() — async. Awaits self._client.health_check(). Returns the result.

Each method is 1-5 lines. No new abstractions, no new state, no public API change beyond the new method names.

The async mirror of QdrantRemote.health_check() is async def, but the AST transformer correctly converts it because health_check will be added to AsyncQdrantBase (in the abstract base) so it ends up in async_methods. This is the standard sync→async transformation.

Wait — re-checking: health_check is NOT in AsyncQdrantBase initially. So the transformer would keep it sync in the async mirror (same as __repr__). But the remote's health_check calls await self.http.service_api.healthz(), which is async. So the async mirror MUST be async def.

Two options:

  • (A) Add health_check to the abstract base as async def in the async mirror. The transformer converts sync def in the base to async. But QdrantBase has no health_check to begin with.
  • (B) Hand-mod the async files to add async def health_check. Use the same exclude-methods + sed-step pattern as feat(client): support context manager on QdrantClient and AsyncQdrantClient #1286 (context manager) and feat(remote): accept pre-built httpx.Client and httpx.AsyncClient #1283 (http_client injection).
  • (C) Don't add to the abstract; just add to the concrete classes. Add the remote's health_check as sync, but make it call the underlying healthz synchronously (which is fine — httpx.Client is sync). The async remote's health_check is async def and awaits. Use the sed pattern to keep it async through regens.

(C) is cleanest: no abstract-base change, no exclude_methods needed (the transformer leaves health_check sync in the async mirror if not in async_methods — which is what we want for QdrantClient.health_check() and QdrantLocal.health_check()). For QdrantRemote.health_check() (sync) and AsyncQdrantRemote.health_check() (async), the transformer would convert sync to async — but it wouldn't add the await for the underlying call automatically. So we need a sed step that:

  • keeps health_check async in the async remote (transformer handles this when we add health_check to async_methods).

Actually, the cleanest fix: add health_check to async_methods of AsyncQdrantBase (the same way close is). That way the transformer converts the sync def health_check in the remote to async def health_check in the async remote. And we need to manually add await inside the method body.

Hmm, but the transformer might not be smart enough to add awaitawait is added by the CallTransformer which wraps calls to async functions. Let me check: in the existing pipeline, when def close is converted to async def close in the async mirror, the self._client.close() call is wrapped to await self._client.close() automatically by CallTransformer (because close is in async_methods).

So if I add health_check to async_methods of AsyncQdrantBase, the transformer would:

  • Convert def health_check to async def health_check in async remote
  • Wrap self.openapi_client.service_api.healthz() in await ... (because healthz is in async_methods of AsyncServiceApi)

But wait — QdrantRemote.openapi_client.service_api.healthz() is the SYNC api (SyncServiceApi.healthz). In the async mirror, self.http.service_api.healthz() is the ASYNC api (AsyncServiceApi.healthz). The transformer uses class_replace_map to translate QdrantRemote.openapi_client to AsyncQdrantRemote.http and SyncServiceApi to AsyncServiceApi. So the call becomes await self.http.service_api.healthz() automatically.

Yes, the pipeline should handle it. Let me NOT add health_check to AsyncQdrantBase — that would make QdrantClient.health_check() async too, which is wrong. Instead:

OK this is getting complicated. Let me simplify the design:

Final design (option C, simplified):

  • Add health_check ONLY to the concrete classes.
  • For QdrantClient.health_check() (sync) and AsyncQdrantClient.health_check() (async), the transformer behavior is the same as __enter__/__exit__ from feat(client): support context manager on QdrantClient and AsyncQdrantClient #1286: exclude the sync method from the async mirror (so it doesn't propagate as sync) and have a sed step re-inject the async method.
  • For QdrantRemote.health_check() (sync) and AsyncQdrantRemote.health_check() (async), the remote transformer already handles sync→async conversion when the method is in async_methods. Add health_check to AsyncQdrantBase (so it ends up in async_methods), and the transformer does the rest.
  • For QdrantLocal.health_check() (sync, returns not self._closed) and AsyncQdrantLocal.health_check() (sync, same), the local transformer would convert it to async def in the async mirror. That's wrong. So I need to either:
    • Add to keep_sync in the local transformer
    • Or use the same exclude+sed pattern
    • Or just use a sync def in the async mirror (since returning a bool is sync anyway — calling await some_bool() is wrong, but the transformer would not add await for a sync method call)

Let me think about the local case more carefully. In the local transformer, _keep_sync(name) returns True if name is in keep_sync OR not in async_methods. async_methods is get_async_methods(AsyncQdrantBase). health_check is not in AsyncQdrantBase (since I'm not adding it to the abstract), so _keep_sync("health_check") returns True. The transformer keeps def health_check as def health_check in the async local mirror. So the async local's health_check is sync. That's fine — the method is sync anyway (no I/O).

But the call inside the local's health_check is return not self._closed — that's sync, no await needed. So the local case is actually fine without any transformer changes.

For the remote case, health_check calls self.openapi_client.service_api.healthz(). In the async mirror, this becomes await self.http.service_api.healthz(). The transformer needs to:

  • Convert def health_check to async def health_check (so the await is allowed)
  • Add await before the self.http.service_api.healthz() call

The transformer does this when the method name is in async_methods. To make it work, I need to add health_check to the async_methods set of AsyncQdrantBase. But that would also make QdrantClient.health_check() async — which is wrong.

So the cleanest fix is to add health_check to the abstract base QdrantBase (with raise NotImplementedError), and AsyncQdrantBase gets the async version (the transformer converts it). The facade methods (QdrantClient.health_check(), AsyncQdrantClient.health_check()) are concrete and can be sync/async as needed.

This is getting complicated. Let me simplify the design to avoid the abstract-base change:

Simpler design:

  • QdrantClient.health_check() (sync) — calls self._client.health_check() (sync, just delegates).
  • AsyncQdrantClient.health_check() (async) — awaits self._client.health_check().
  • QdrantRemote.health_check() (sync) — calls self.openapi_client.service_api.healthz().
  • AsyncQdrantRemote.health_check() (async) — awaits self.http.service_api.healthz().
  • QdrantLocal.health_check() (sync) — returns not self._closed.
  • AsyncQdrantLocal.health_check() (sync) — returns not self._closed. Wait, this should be async to match the convention. But it doesn't need to be — no I/O. Let me just leave it sync.

Regen plan:

  • QdrantClient.health_check (sync) → excluded from async mirror (use feat(client): support context manager on QdrantClient and AsyncQdrantClient #1286 pattern), sed re-inject as async def.
  • QdrantRemote.health_check (sync) → transformer would convert to async if health_check is in async_methods. Add it to AsyncQdrantBase? No, that affects the facade. Better: just hand-mod the async remote to have async def health_check that awaits. Use sed step.
  • QdrantLocal.health_check (sync) → transformer would keep sync (not in async_methods), so async local has def health_check (sync). That's fine.
  • AsyncQdrantClient.health_check (async) → hand-mod, sed re-inject.
  • AsyncQdrantRemote.health_check (async) → hand-mod, sed re-inject.
  • AsyncQdrantLocal.health_check (sync) → kept sync by transformer, fine.

So I need 2 sed steps (one for the async client, one for the async remote). Plus 1 exclude_methods change for the sync client (to keep health_check sync in the async mirror, but we don't actually want sync in the async mirror — we want async. So I should add health_check to exclude_methods in the client generator to skip it in the async mirror, then sed re-inject as async def).

This is workable but complex. Let me think if there's a simpler approach.

Alternative: skip the abstract change. Use the same pattern as __enter__/__exit__ from #1286:

  1. Add health_check to exclude_methods in client_generator.py (so it doesn't propagate to async client).
  2. Add health_check to exclude_methods in remote_generator.py (so it doesn't propagate to async remote).
  3. Add health_check to exclude_methods in local_generator.py (so it doesn't propagate to async local — even though it would be kept sync, exclude for consistency).
  4. Sed step in generate_async_client.sh that re-injects async def health_check into async_qdrant_client.py after the close method.
  5. Sed step that re-injects async def health_check into async_qdrant_remote.py after the close method.
  6. def health_check in async_qdrant_local.py (hand-mod, no sed step needed since transformer keeps it sync).

Actually, the local one — since QdrantLocal.health_check() is just return not self._closed, no I/O, it can be sync. The async local's health_check would be def health_check (sync), which works fine for the facade. But the async facade's health_check is async def, and it calls await self._client.health_check(). Awaiting a sync function is a no-op (returns the value), so this works.

Wait, await sync_func() is actually a SyntaxError in Python. You can only await coroutines. So if AsyncQdrantClient.health_check is async def and calls self._client.health_check() (which is sync), the await would fail.

Let me think. The facade's health_check is async def. It calls self._client.health_check(). If the inner is sync, calling it returns a bool. The facade can return the bool. No await needed.

So AsyncQdrantClient.health_check:

async def health_check(self) -> bool:
    if hasattr(self, "_client"):
        return self._client.health_check()
    return False

No await on the inner call. The async def is just for API consistency (users await client.health_check()).

This is fine. The transformer would convert def health_check to async def health_check in the async mirror if health_check is in async_methods. Otherwise it stays sync.

Hmm. To make the facade's health_check work both sync and async, I need different code paths:

  • QdrantClient.health_check() (sync): return self._client.health_check().
  • AsyncQdrantClient.health_check() (async): return self._client.health_check() (still no await since inner is sync).

Both return a bool. The async one doesn't need to await anything (the inner is sync). So both can be the same logic.

But the async one needs to be async def to be awaitable. So I need to:

  • Add def health_check to sync client → transformer behavior?
  • Add async def health_check to async client → hand-mod

For the sync def health_check in qdrant_client.py:

  • It's not in async_methods of AsyncQdrantBase (I won't add it there)
  • So the transformer keeps it as def health_check in the async client mirror
  • That's wrong (we want async in the async mirror)

Fix: add health_check to exclude_methods in client_generator.py. Then sed re-inject as async def in the async client.

OK let me just do it. The plan:

Sync side (added by me):

  • qdrant_client.py: def health_check(self) -> bool: return self._client.health_check()
  • qdrant_remote.py: def health_check(self) -> bool: try: self.openapi_client.service_api.healthz(); return True; except: return False
  • local/qdrant_local.py: def health_check(self) -> bool: return not self._closed

Async side (hand-mod + sed):

  • async_qdrant_client.py: async def health_check(self) -> bool: if hasattr(self, "_client"): return self._client.health_check(); return False
  • async_qdrant_remote.py: async def health_check(self) -> bool: try: await self.http.service_api.healthz(); return True; except: return False
  • local/async_qdrant_local.py: def health_check(self) -> bool: return not self._closed (this one stays sync — works fine with the async facade because no await is needed)

Transformer changes:

  • Add health_check to exclude_methods in client_generator.py
  • Add health_check to exclude_methods in remote_generator.py
  • (local_generator.py: not needed; def health_check is kept sync in the async mirror because not in async_methods)

Sed step in generate_async_client.sh:

  • After the __aenter__/__aexit__ injection, also re-inject async def health_check into async_qdrant_client.py (after the close method, before @property grpc_collections).
  • Also re-inject async def health_check into async_qdrant_remote.py (after the close method, before @property closed or wherever).

This is workable. Let me write it up.

Alternatives considered

  • Skip the local method: rejected. The user has a QdrantClient/AsyncQdrantClient, not just a QdrantRemote. Local mode is a real use case.
  • Make health_check raise on failure instead of returning bool: rejected. The bool contract is more ergonomic for the common case (liveness probe). Users who need the exception details can call client.http.service_api.healthz() directly.
  • Add health_check to QdrantBase (the abstract): rejected. Adding to the abstract forces users with custom subclasses to implement it, which is a backward-compat break. The concrete classes are enough for the public surface.
  • Implement via the existing version_check.is_compatible call on first use: rejected. That's automatic, not a public method, and the user can't trigger it on demand.

Scope

In scope:

  • 6 small methods, one per class.
  • 1 line in each of client_generator.py and remote_generator.py (add health_check to exclude_methods).
  • 2 sed steps in generate_async_client.sh (one for async client, one for async remote).
  • 1 test file with ~16 tests (sync + async × local + remote × success + failure + closed + no-init).

Out of scope:

  • Adding health_check to QdrantBase (the abstract) — would force custom-subclass implementers to add it. Not needed for the public surface.
  • A liveness_check() / readiness_check() distinction (Kubernetes has separate probes). The current /healthz is sufficient. Users who need fine-grained probes can call the lower-level service API.
  • A health_check(timeout=...) parameter. Could be added later if there's demand. For now, the method uses the client's default timeout.

Acceptance criteria

  • QdrantClient("localhost:6333").health_check() returns True (or False, depending on whether the server is up at test time — test uses a mock).
  • QdrantClient("http://does-not-exist:1").health_check() returns False on any failure.
  • QdrantClient(":memory:").health_check() returns True (in-process, not closed).
  • After .close(), QdrantClient(":memory:").health_check() returns False.
  • Async counterparts match: await AsyncQdrantClient("...").health_check() returns a bool.
  • The method never raises; failures are folded into the False return.
  • 16+ tests in tests/test_health_check.py, all pass alongside the existing suite.
  • mypy clean on the 6 modified files.
  • ruff check + format clean on the new test file.
  • Running bash tools/generate_async_client.sh regenerates the async files with the health_check methods intact (sed step verified end-to-end).

Backward compatibility

Fully additive. No public API change besides the new method names. The new methods don't conflict with anything.

Risks

  • The async file is autogenerated, so the sed steps in generate_async_client.sh are the only fragile part. Each sed step has a signature check that prints a clear "manual fix required" message if the close() signature changes.
  • Low risk of name collision: health_check doesn't shadow any existing method on the client classes.

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 follows the pattern in tests/test_context_manager.py (pytest, no live server, mock the inner client).
  • The remote's health_check catches all exceptions and returns False. This is the convention; do NOT let exceptions escape.

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