diff --git a/packages/js-client-grpc/src/api-client.ts b/packages/js-client-grpc/src/api-client.ts index 70bd87c..2a0d401 100644 --- a/packages/js-client-grpc/src/api-client.ts +++ b/packages/js-client-grpc/src/api-client.ts @@ -80,7 +80,7 @@ export function createApis(baseUrl: string, {timeout, apiKey, compression, heade (next) => (req) => next(req).catch((error) => { if (error instanceof ConnectError && error.code === Code.ResourceExhausted) { - const retryAfterHeader = error.metadata.get('retry-after')?.[0]; + const retryAfterHeader = error.metadata.get('retry-after'); if (retryAfterHeader) { throw new QdrantClientResourceExhaustedError(error.rawMessage, retryAfterHeader); } diff --git a/packages/js-client-grpc/src/errors.ts b/packages/js-client-grpc/src/errors.ts index 4e25f51..2b25707 100644 --- a/packages/js-client-grpc/src/errors.ts +++ b/packages/js-client-grpc/src/errors.ts @@ -13,9 +13,15 @@ export class QdrantClientConfigError extends CustomError {} export class QdrantClientResourceExhaustedError extends ConnectError { retry_after: number; + // ConnectError overrides `instanceof` to match any ConnectError, so restore the prototype chain check. + static override [Symbol.hasInstance](value: unknown): boolean { + return Function.prototype[Symbol.hasInstance].call(this, value); + } + constructor(message: string, retryAfter: string) { + // Keep the `ConnectError` name set by the base class: connect only passes an error thrown + // in an interceptor through unchanged if it is recognized as a ConnectError. super(message, Code.ResourceExhausted); - this.name = this.constructor.name; const retryAfterNumber = Number(retryAfter); if (isNaN(retryAfterNumber)) { diff --git a/packages/js-client-grpc/tests/unit/api-client.test.ts b/packages/js-client-grpc/tests/unit/api-client.test.ts new file mode 100644 index 0000000..df87165 --- /dev/null +++ b/packages/js-client-grpc/tests/unit/api-client.test.ts @@ -0,0 +1,61 @@ +import {createServer, Http2Server, ServerHttp2Session} from 'node:http2'; +import {AddressInfo} from 'node:net'; +import {Code, ConnectError, ConnectRouter} from '@connectrpc/connect'; +import {connectNodeAdapter} from '@connectrpc/connect-node'; +import {afterEach, describe, expect, test} from 'vitest'; +import {createApis} from '../../src/api-client.js'; +import {QdrantClientResourceExhaustedError} from '../../src/errors.js'; +import {Qdrant} from '../../src/proto/qdrant_pb.js'; + +describe('apiClient', () => { + let server: Http2Server | undefined; + const sessions = new Set(); + + afterEach(async () => { + const current = server; + server = undefined; + if (current) { + // The client keeps its HTTP/2 session open, so close it to let the server shut down. + sessions.forEach((session) => session.destroy()); + sessions.clear(); + await new Promise((resolve) => current.close(() => resolve())); + } + }); + + const startServer = async (error: ConnectError) => { + const routes = (router: ConnectRouter) => + router.service(Qdrant, { + healthCheck() { + throw error; + }, + }); + const current = createServer(connectNodeAdapter({routes})); + current.on('session', (session) => sessions.add(session)); + server = current; + await new Promise((resolve) => current.listen(0, '127.0.0.1', resolve)); + return `http://127.0.0.1:${(current.address() as AddressInfo).port}`; + }; + + test('resource exhausted with retry-after', async () => { + const baseUrl = await startServer( + new ConnectError('rate limited', Code.ResourceExhausted, {'retry-after': '15'}), + ); + const apis = createApis(baseUrl, {timeout: Infinity, compression: false}); + + const error = await apis.service.healthCheck({}).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(QdrantClientResourceExhaustedError); + expect(error).toMatchObject({code: Code.ResourceExhausted, retry_after: 15}); + }); + + test('other errors are not QdrantClientResourceExhaustedError', async () => { + const baseUrl = await startServer(new ConnectError('not found', Code.NotFound)); + const apis = createApis(baseUrl, {timeout: Infinity, compression: false}); + + const error = await apis.service.healthCheck({}).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ConnectError); + expect(error).toMatchObject({code: Code.NotFound}); + expect(error).not.toBeInstanceOf(QdrantClientResourceExhaustedError); + }); +});