Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/js-client-rest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ Or directly using an endpoint from the API:
await client.api('collections').getCollections();
```

### Custom `fetch`

By default the client uses [`undici`](https://github.com/nodejs/undici)'s `fetch` on Node.js
(with a connection pool you can size via `maxConnections`) and the platform's global `fetch`
elsewhere. You can override the transport entirely by passing your own `fetch` — useful for
proxies, custom dispatchers/agents, or to guarantee that the `fetch` and its dispatcher come
from the same `undici` version:

```ts
import {fetch} from 'undici';

const client = new QdrantClient({url: 'http://127.0.0.1:6333', fetch});
```

### Typed Error Handling

A non-ok fetch response throws a generic `ApiError`
Expand Down
2 changes: 1 addition & 1 deletion packages/js-client-rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
},
"dependencies": {
"@qdrant/openapi-typescript-fetch": "1.2.6",
"undici": "^6.24.0"
"undici": "^6.27.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.1.0",
Expand Down
27 changes: 17 additions & 10 deletions packages/js-client-rest/src/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {ApiError, Fetcher, Middleware} from '@qdrant/openapi-typescript-fetch';
import {paths} from './openapi/generated_schema.js';
import {createDispatcher} from './dispatcher.js';
import {createTransport} from './transport.js';
import {createNodeFetch} from './node-fetch.js';
import {
QdrantClientResourceExhaustedError,
QdrantClientTimeoutError,
Expand All @@ -20,7 +21,7 @@ export function createApis(baseUrl: string, args: RestArgs): ClientApi {

export type OpenApiClient = ReturnType<typeof createApis>;

export function createClient(baseUrl: string, {headers, timeout, connections}: RestArgs): Client {
export function createClient(baseUrl: string, {headers, timeout, connections, fetch}: RestArgs): Client {
const use: Middleware[] = [];
use.push((url, init, next) => {
const ctx = getContextHeaders();
Expand Down Expand Up @@ -68,19 +69,25 @@ export function createClient(baseUrl: string, {headers, timeout, connections}: R
throw QdrantClientUnexpectedResponseError.forResponse(response);
});

// Terminal middleware: performs the actual request. Must be last so its
// `next` sits closest to the transport and the middlewares above can wrap it.
//
// Fetch selection (kept as a ternary at the call site so the `undici` branch
// is tree-shaken from browser bundles, where `process` becomes `undefined`):
// A. a caller-supplied `fetch`;
// B. on Node, undici's fetch + Agent from the same package (fixes #134);
// C. otherwise, the global `fetch` (handled inside `createTransport`).
const fetchImpl =
fetch ??
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(typeof process !== 'undefined' && process.versions?.node ? createNodeFetch(connections) : undefined);
use.push(createTransport(fetchImpl));

const client = Fetcher.for<paths>();
// Configure client with 'undici' agent which is used in Node 18+
client.configure({
baseUrl,
init: {
headers,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
dispatcher:
typeof process !== 'undefined' &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
process.versions?.node
? createDispatcher(connections)
: undefined,
},
use,
});
Expand Down
23 changes: 0 additions & 23 deletions packages/js-client-rest/src/dispatcher.ts

This file was deleted.

32 changes: 32 additions & 0 deletions packages/js-client-rest/src/node-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */

import {Agent, fetch as undiciFetch} from 'undici';
import {FetchFn} from './types.js';

/**
* Build a `fetch` backed by `undici`'s own `fetch` and an `undici` `Agent` from
* the *same* package, so the dispatcher contract always matches the fetch that
* consumes it — regardless of the undici version Node ships (fixes #134).
*
* This module is the *only* place that imports `undici`, and it must only ever
* be referenced behind a `process` guard at the call site (see `api-client.ts`).
* Bundlers targeting the browser replace `process` with `undefined`, which makes
* that branch dead and lets them drop this whole module — and the `undici`
* dependency along with it — from the browser build.
*/
export function createNodeFetch(connections?: number): FetchFn {
const agent = new Agent({
// timeouts are handled by AbortSignal in our middleware
bodyTimeout: 0,
headersTimeout: 0,
// a sensible max connections value
connections,
// will be overridden by header Keep-Alive, just a sensible default
keepAliveTimeout: 10_000,
});
// undici's fetch/Response/RequestInit are structurally compatible with the
// DOM lib types we expose via FetchFn, but not assignable, so cast across.
type UndiciRequestInit = Parameters<typeof undiciFetch>[1];
return ((url: string, init?: RequestInit) =>
undiciFetch(url, {...init, dispatcher: agent} as unknown as UndiciRequestInit)) as unknown as FetchFn;
}
10 changes: 8 additions & 2 deletions packages/js-client-rest/src/qdrant-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {OpenApiClient, createApis} from './api-client.js';
import {QdrantClientConfigError} from './errors.js';
import {RestArgs, Schemas} from './types.js';
import {FetchFn, RestArgs, Schemas} from './types.js';
import {PACKAGE_VERSION, ClientVersion} from './client-version.js';
import {ClientApi} from './openapi/generated_client_type.js';

Expand Down Expand Up @@ -33,6 +33,12 @@ export type QdrantClientParams = {
* Check compatibility with the server version. Default: `true`
*/
checkCompatibility?: boolean;
/**
* Custom `fetch` implementation. When provided it is used for every request
* instead of the built-in transport. Useful to supply `undici`'s own `fetch`,
* a proxy-aware fetch, or a fetch with a custom dispatcher/Agent.
*/
fetch?: FetchFn;
};

export class QdrantClient {
Expand Down Expand Up @@ -114,7 +120,7 @@ export class QdrantClient {
const address = this._port ? `${this._host}:${this._port}` : this._host;
this._restUri = `${this._scheme}://${address}${this._prefix}`;
const connections = args.maxConnections;
const restArgs: RestArgs = {headers, timeout, connections};
const restArgs: RestArgs = {headers, timeout, connections, fetch: args.fetch};

this._openApiClient = createApis(this._restUri, restArgs);

Expand Down
76 changes: 76 additions & 0 deletions packages/js-client-rest/src/transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {ApiError, Middleware} from '@qdrant/openapi-typescript-fetch';
import {FetchFn} from './types.js';

/**
* bigint-aware JSON reviver, mirrored from `@qdrant/openapi-typescript-fetch`'s
* internal `fetcher.ts`. We re-implement the response parsing in our own
* terminal middleware (see `createTransport`), so we must keep the same
* large-integer handling: integers that don't fit into a safe JS number are
* parsed from their raw source text into a `bigint`. Only active on runtimes
* that support the JSON source-text access proposal (`JSON.rawJSON`).
*/
type JsonReviver = (this: unknown, key: string, value: unknown) => unknown;

const bigintReviver: JsonReviver | undefined =
'rawJSON' in JSON
? function (_key, val, context?: {source: string}) {
if (typeof val === 'number' && Number.isInteger(val) && !Number.isSafeInteger(val) && context) {
try {
return BigInt(context.source);
} catch {
return val;
}
}
return val;
}
: undefined;

/** Parse a `fetch` Response body the same way `@qdrant/openapi-typescript-fetch` does. */
async function getResponseData(response: Response): Promise<unknown> {
if (response.status === 204) {
return undefined;
}
const contentType = response.headers.get('content-type');
const responseText = await response.text();
if (contentType?.includes('application/json')) {
return JSON.parse(responseText, bigintReviver);
}
try {
return JSON.parse(responseText, bigintReviver);
} catch {
return responseText;
}
}

/**
* A terminal middleware that performs the actual request with `fetchImpl` and
* builds the `ApiResponse` expected by `@qdrant/openapi-typescript-fetch`.
*
* It intentionally ignores `next`: by handling the request here we bypass the
* library's own call to the *global* `fetch`. That's what lets us drive the
* request through a fetch implementation that matches our dispatcher (see
* `createNodeFetch`) and avoids the "two undici in one process" mismatch (#134).
*
* `fetchImpl` defaults to the platform's global `fetch` (browser / edge / a Node
* runtime without undici), so the only thing the caller must decide is whether
* to override it.
*/
export function createTransport(fetchImpl?: FetchFn): Middleware {
const doFetch: FetchFn = fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
return async (url, init) => {
const response = await doFetch(url, init);
const data = await getResponseData(response);
const result = {
headers: response.headers,
url: response.url,
ok: response.ok,
status: response.status,
statusText: response.statusText,
data,
};
if (result.ok) {
return result;
}
throw new ApiError(result);
};
}
11 changes: 11 additions & 0 deletions packages/js-client-rest/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import {components} from './openapi/generated_schema.js';

/**
* A WHATWG-compatible `fetch` implementation. Used to let callers inject their
* own transport (e.g. `undici`'s `fetch`, a proxy-aware fetch, or a test double).
*/
export type FetchFn = (input: string, init?: RequestInit) => Promise<Response>;

export interface RestArgs {
headers: Headers;
timeout: number;
connections?: number;
/**
* Custom `fetch` implementation. When provided it is used for every request
* instead of the built-in undici / global-fetch transport.
*/
fetch?: FetchFn;
}

// Definitions (in OpenAPI 2.0) or Schemas (in OpenAPI 3.0) – Data models that describe your API inputs and outputs.
Expand Down
40 changes: 26 additions & 14 deletions packages/js-client-rest/tests/unit/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,34 @@
import {createApis, createClient} from '../../src/api-client.js';
import {QdrantClientTimeoutError, QdrantClientUnexpectedResponseError} from '../../src/errors.js';
import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest';
import {vi, describe, test, expect} from 'vitest';

describe('apiClient', () => {
const headers = new Headers();
headers.set('content-type', 'application/json');
const createFetchResponse = (status: number) => ({
headers,
url: '',
ok: true,
status,
statusText: '',
json: () => new Promise((resolve) => resolve({error_message: 'response error'})),
text: () => new Promise((resolve) => resolve(JSON.stringify({error_message: 'response error'}))),
});
let originalFetch: typeof global.fetch;

beforeEach(() => {
originalFetch = global.fetch;
});

afterEach(() => {
global.fetch = originalFetch;
});

test('status 200', async () => {
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(200));
const fetch = vi.fn().mockResolvedValue(createFetchResponse(200));

const apis = createApis('http://my-domain.com', {
timeout: Infinity,
headers,
fetch,
});

await expect(apis.collectionExists({collection_name: 'my-collection'})).resolves.toMatchObject({
data: {error_message: 'response error'},
});

expect(global.fetch).toBeCalledWith(
expect(fetch).toBeCalledWith(
expect.stringMatching('http://my-domain.com/collections/my-collection/exists'),
expect.objectContaining({
method: 'GET',
Expand All @@ -43,11 +37,12 @@ describe('apiClient', () => {
});

test('status 400', async () => {
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(400));
const fetch = vi.fn().mockResolvedValue(createFetchResponse(400));

const client = createClient('http://my-domain.com', {
timeout: Infinity,
headers,
fetch,
});
const telemetry = client.path('/telemetry').method('get').create();

Expand All @@ -57,14 +52,31 @@ describe('apiClient', () => {
test('signal abort: timeout', async () => {
const err = new Error();
err.name = 'AbortError';
global.fetch = vi.fn().mockRejectedValue(err);
const fetch = vi.fn().mockRejectedValue(err);

const client = createClient('http://my-domain.com', {
timeout: 0,
headers,
fetch,
});
const telemetry = client.path('/telemetry').method('get').create();

await expect(telemetry({})).rejects.toThrowError(QdrantClientTimeoutError);
});

test('injected fetch is used and no dispatcher is set on init', async () => {
const fetch = vi.fn().mockResolvedValue(createFetchResponse(200));

const apis = createApis('http://my-domain.com', {
timeout: Infinity,
headers,
fetch,
});

await apis.collectionExists({collection_name: 'my-collection'});

expect(fetch).toHaveBeenCalledTimes(1);
const calls = fetch.mock.calls as unknown as [string, Record<string, unknown>][];
expect(calls[0][1]).not.toHaveProperty('dispatcher');
});
});
11 changes: 6 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.