Skip to content

feat(remote): accept pre-built httpx.Client and httpx.AsyncClient - #1283

Closed
Harsh23Kashyap wants to merge 3 commits into
qdrant:masterfrom
Harsh23Kashyap:feat/http-client-injection
Closed

feat(remote): accept pre-built httpx.Client and httpx.AsyncClient#1283
Harsh23Kashyap wants to merge 3 commits into
qdrant:masterfrom
Harsh23Kashyap:feat/http-client-injection

Conversation

@Harsh23Kashyap

Copy link
Copy Markdown

Summary

Adds an optional http_client parameter to QdrantClient, AsyncQdrantClient, QdrantRemote, AsyncQdrantRemote, ApiClient, and AsyncApiClient. When provided, the client uses the caller's httpx.Client / httpx.AsyncClient verbatim instead of constructing its own. Backward compatible — the default path is unchanged.

Motivation

QdrantRemote builds its own httpx.Client / httpx.AsyncClient from the constructor kwargs (limits, http2, timeout, auth, ...). Callers who need to attach middleware, event hooks, custom transports, or observability instrumentation (Prometheus exporters, OpenTelemetry hooks) currently have no way to do that.

This unblocks the most common use case from #1068: a custom httpx.Client with transport-level instrumentation.

What's in this PR

The injection half of #1068. Plumbing the user-supplied client through every layer that builds one internally.

  • ApiClient.__init__ / AsyncApiClient.__init__: new http_client: Client | None = None / http_client: AsyncClient | None = None parameter. When set, used as-is. When None, the existing Client(**kwargs) / AsyncClient(**kwargs) path runs.
  • QdrantRemote.__init__ / AsyncQdrantRemote.__init__: new http_client parameter, forwarded to ApiClient / AsyncApiClient via _rest_args["http_client"] after the existing kwargs are processed.
  • QdrantClient.__init__ / AsyncQdrantClient.__init__: new http_client parameter, forwarded to QdrantRemote / AsyncQdrantRemote. The init_options round-trip preserves it (sync -> async or async -> sync).
  • tools/generate_async_client.sh: a sed step that remaps httpx.Client to httpx.AsyncClient in the regenerated async_qdrant_remote.py and async_qdrant_client.py signatures. The AST transformer that produces the async files propagates parameter annotations from the sync source verbatim, so the new http_client: httpx.Client annotation in the sync source would leak into the async signature without this fix. Future regens stay consistent with the manually-edited files.

What's NOT in this PR

The request-metrics half of #1068. That's a real API design call (which metrics, sync vs per-request, what surface) and deserves its own focused discussion. Open to a follow-up PR once this injection lands.

Design notes

When the caller supplies an http_client, the qdrant-client constructor kwargs (limits, http2, timeout, auth) are not applied to it. The caller owns their client; this matches the standard httpx convention where you build the client once and let it own its transport. Comments in the source make this explicit.

The rest_headers middleware is still attached on top of the user-supplied client — observability of the qdrant-client does not regress as a side effect of opting in. Covered by a behavioral test.

ApiClient / AsyncApiClient are exported types from qdrant_client.http, so the new parameter is part of the public surface for anyone constructing them directly.

Test plan

tests/test_http_client_injection.py covers:

  • The injected httpx.Client is the actual instance used by QdrantClient._client.openapi_client.client._client (identity check, not isinstance).
  • The same check at the QdrantRemote level directly, bypassing the QdrantClient facade.
  • The default-no-client path still constructs a real httpx.Client (regression check).
  • The injected client's chain still routes through the rest_headers middleware (behavioral test: set a context header, drive the middleware, assert the header is stamped onto the request).
  • _init_options["http_client"] is preserved, so QdrantClient(http_client=...) -> AsyncQdrantClient(**sync._init_options) round-trip works.
  • All three checks repeated for the async path with httpx.AsyncClient.
$ .ossvenv/bin/pytest tests/test_http_client_injection.py -v
============================== 8 passed in 0.36s ==============================

Existing tests/test_tracing.py still passes (14/14).

mypy qdrant_client/qdrant_remote.py qdrant_client/qdrant_client.py qdrant_client/async_qdrant_remote.py qdrant_client/async_qdrant_client.py is clean (the four non-tests files mypy actually checks; qdrant_client/http/ is excluded by mypy.ini).

ruff check is clean on all changed files. The 3 pre-existing F541 warnings in qdrant_remote.py / async_qdrant_remote.py are not introduced by this PR. ruff format on qdrant_client/http/api_client.py would touch unrelated pre-existing overload signatures; left alone to keep the diff focused.

Out of scope

Backward compatibility

Purely additive. All new parameters default to None, the default path constructs a fresh httpx.Client / httpx.AsyncClient exactly as before. No public signatures changed, no public behavior changed for callers that don't pass http_client.

Fixes #1068

Adds an optional `http_client` parameter to `QdrantClient`,
`AsyncQdrantClient`, `QdrantRemote`, `AsyncQdrantRemote`,
`ApiClient`, and `AsyncApiClient`. When provided, the client uses the
caller's `httpx.Client` / `httpx.AsyncClient` verbatim instead of
constructing its own.

The user's transport, middleware, and event hooks stay under their
control. The qdrant-client kwargs (limits, http2, timeout, auth) are
only applied when this class builds its own httpx client.

Also updates `tools/generate_async_client.sh` so the regen pipeline
remaps the sync `httpx.Client` type to `httpx.AsyncClient` in the
generated async signatures (the AST transformer propagates annotations
verbatim and the AST itself cannot rewrite them).

First half of #1068. The request-metrics half is left for a follow-up
PR — that's a bigger API design call and splitting keeps this one
focused.

Tests: tests/test_http_client_injection.py covers the sync and async
paths, including a behavioral check that rest_headers middleware stays
in the chain when the caller supplies their own client.
@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit fd5c961
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a645840056b180008a17a3c
😎 Deploy Preview https://deploy-preview-1283--poetic-froyo-8baba7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b8d60e7-c084-474a-8fda-4ca2680ba228

📥 Commits

Reviewing files that changed from the base of the PR and between be6c321 and fd5c961.

📒 Files selected for processing (1)
  • tests/test_http_client_injection.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_http_client_injection.py

📝 Walkthrough

Walkthrough

The sync and async Qdrant clients now accept optional pre-built httpx clients, propagate them through remote and REST API layers, and reuse them without taking ownership. Default clients remain instance-owned and are closed accordingly. Initialization options exclude injected clients, middleware remains active, tests cover sync and async injection, lifecycle, defaults, and option filtering, and async generation corrects copied type annotations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the main change: accepting pre-built httpx clients.
Description check ✅ Passed The description is on-topic and matches the implemented client-injection changes.
Linked Issues check ✅ Passed Implements the custom httpx client injection portion of #1068, enabling external request instrumentation.
Out of Scope Changes check ✅ Passed The added tests, lifecycle handling, and async regen fix all support the injection feature; no unrelated changes stand out.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
qdrant_client/qdrant_client.py (1)

105-115: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent sync/async http_client round-tripping.

QdrantHostClient preserves http_client in _init_options, so AsyncQdrantClient(**sync_client.init_options) can pass an httpx.Client into async AsyncApiClient, and AsyncQdrantHostClient(..., http_client=httpx.AsyncClient()) can preserve an async client through sync construction. ApiClient.send() ultimately calls self._client.send(request), while async REST methods await client.send(), so keep the option or rebuild http_client for the target client type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/qdrant_client.py` around lines 105 - 115, Update
QdrantHostClient initialization and init_options handling so sync/async
conversions never pass an httpx.Client to AsyncApiClient or an httpx.AsyncClient
to the sync client. Exclude incompatible http_client instances from
round-tripped options or rebuild them for the target client type, while
preserving compatible clients. Ensure
AsyncQdrantClient(**sync_client.init_options) and the reverse remain valid
through ApiClient.send.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@qdrant_client/http/api_client.py`:
- Around line 68-81: The ApiClient and AsyncApiClient wrappers currently close
caller-owned HTTP clients during cleanup. Track whether each client was created
internally from the `http_client is None` condition, and update
`ApiClient.close()` and `AsyncApiClient.aclose()` to close only internally owned
clients while leaving injected clients untouched.

In `@qdrant_client/qdrant_remote.py`:
- Around line 234-239: The remote upload worker paths must not receive
caller-owned httpx clients through _rest_args. In qdrant_client/qdrant_remote.py
lines 234-239 and qdrant_client/async_qdrant_remote.py lines 190-196, prevent
injected http_client values from being passed to ParallelWorkerPool arguments,
or reject parallel uploads when one is supplied; preserve direct client usage
for non-worker requests and apply the same ownership rule to both sync and async
uploaders.

---

Outside diff comments:
In `@qdrant_client/qdrant_client.py`:
- Around line 105-115: Update QdrantHostClient initialization and init_options
handling so sync/async conversions never pass an httpx.Client to AsyncApiClient
or an httpx.AsyncClient to the sync client. Exclude incompatible http_client
instances from round-tripped options or rebuild them for the target client type,
while preserving compatible clients. Ensure
AsyncQdrantClient(**sync_client.init_options) and the reverse remain valid
through ApiClient.send.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ff97c666-9b5b-448d-a69e-13c8a934363f

📥 Commits

Reviewing files that changed from the base of the PR and between 200d865 and 0218744.

📒 Files selected for processing (7)
  • qdrant_client/async_qdrant_client.py
  • qdrant_client/async_qdrant_remote.py
  • qdrant_client/http/api_client.py
  • qdrant_client/qdrant_client.py
  • qdrant_client/qdrant_remote.py
  • tests/test_http_client_injection.py
  • tools/generate_async_client.sh

Comment thread qdrant_client/http/api_client.py
Comment thread qdrant_client/qdrant_remote.py Outdated
Three follow-ups from the bot review of #1283:

- `ApiClient.close()` / `AsyncApiClient.aclose()` now track whether
  the underlying httpx client was built internally or supplied by the
  caller, and only close the internally-built one. The caller owns
  their client's lifecycle and may keep using it after the qdrant
  client is closed.

- The user-supplied http_client is no longer placed in
  `self._rest_args`. That dict is also forwarded to per-process
  upload workers (`_upload_collection` -> `RestBatchUploader`); a
  shared client across forked workers is unsafe (httpx connection
  pools don't survive fork) and would serialize workers on the
  shared connection pool. The client is now passed as a direct kwarg
  to the main `SyncApis` / `AsyncApis` constructor only.

- `http_client` is excluded from `_init_options` capture in
  `QdrantClient` and `AsyncQdrantClient`. The round-trip path
  (`AsyncQdrantClient(**sync._init_options)`) would otherwise
  leak a sync httpx.Client into the async client (or vice versa) and
  fail at runtime. Callers who want an http_client on both sides
  pass it explicitly to each constructor; non-http_client args
  (timeout, headers, etc.) still round-trip.

New tests cover: `QdrantClient.close()` not closing the injected
client, the built-client path still closing, the round-trip
exclusion, and the async counterparts.
@Harsh23Kashyap

Copy link
Copy Markdown
Author

Re: the round-trip concern about httpx.Client leaking into AsyncQdrantClient and vice versa — fixed in be6c321. http_client is now excluded from _init_options capture in both QdrantClient and AsyncQdrantClient. The round-trip path (AsyncQdrantClient(**sync._init_options)) no longer carries the http_client over. Callers who want an http_client on both sides pass it explicitly to each constructor; non-http_client args (timeout, headers, etc.) still round-trip. Two new tests cover this: test_init_options_round_trip_excludes_http_client (sync) and the async counterpart.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_http_client_injection.py`:
- Around line 87-104: The lifecycle tests currently bypass ownership assertions.
In tests/test_http_client_injection.py:87-104, assert the injected client
remains open after QdrantClient.close() instead of swallowing probe exceptions;
at 105-121, assert the constructor-set _owns_client value without overwriting
it; and at 138-144, await client.close() and verify the default-created async
client is closed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 975d81b7-1e13-450b-9181-1293ad902496

📥 Commits

Reviewing files that changed from the base of the PR and between 0218744 and be6c321.

📒 Files selected for processing (6)
  • qdrant_client/async_qdrant_client.py
  • qdrant_client/async_qdrant_remote.py
  • qdrant_client/http/api_client.py
  • qdrant_client/qdrant_client.py
  • qdrant_client/qdrant_remote.py
  • tests/test_http_client_injection.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • qdrant_client/qdrant_remote.py
  • qdrant_client/qdrant_client.py
  • qdrant_client/http/api_client.py
  • qdrant_client/async_qdrant_client.py

Comment thread tests/test_http_client_injection.py
- test_close_does_not_close_injected_client: assert `not
  injected.is_closed` directly, instead of swallowing probe
  exceptions. Also assert the constructor set `_owns_client = False`.

- test_close_does_close_built_client: stop monkey-patching the
  `_owns_client` flag. Assert the constructor set it to True, then
  verify the default-built client is closed by `api_client.close()`.

- test_default_path_still_builds_its_own_async_client: actually call
  `client.close()` and verify the default-built `httpx.AsyncClient`
  is closed, instead of just calling `aclose()` on the inner client
  directly.

- test_aclose_does_not_close_injected_client: mirror the sync
  ownership check (`_owns_client is False` + `is_closed`).

All 12 tests still pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose request metrics (e.g., duration) and allow custom httpx.Client injection

1 participant