diff --git a/src/client.ts b/src/client.ts index 00d11d0..f014e88 100644 --- a/src/client.ts +++ b/src/client.ts @@ -31,10 +31,12 @@ import type { ClientOptions, RetryConfig, ServerInfo } from "./types.js"; import { ConfigWatcher } from "./watcher.js"; /** - * Options for get() with nullable support. + * Options for get() with nullable and per-call timeout support. */ interface GetOptions { readonly nullable?: boolean; + /** Per-call timeout in ms. Overrides the client default. */ + readonly timeout?: number; } /** @@ -122,9 +124,24 @@ export class ConfigClient { /** * Get a config value converted to the specified type. */ - get(tenantId: string, fieldPath: string, type: typeof Number): Promise; - get(tenantId: string, fieldPath: string, type: typeof Boolean): Promise; - get(tenantId: string, fieldPath: string, type: typeof String): Promise; + get( + tenantId: string, + fieldPath: string, + type: typeof Number, + options?: { timeout?: number }, + ): Promise; + get( + tenantId: string, + fieldPath: string, + type: typeof Boolean, + options?: { timeout?: number }, + ): Promise; + get( + tenantId: string, + fieldPath: string, + type: typeof String, + options?: { timeout?: number }, + ): Promise; /** * Get a config value with nullable support. * Returns null if the field has no value instead of throwing. @@ -133,19 +150,19 @@ export class ConfigClient { tenantId: string, fieldPath: string, type: typeof Number, - options: { nullable: true }, + options: { nullable: true; timeout?: number }, ): Promise; get( tenantId: string, fieldPath: string, type: typeof Boolean, - options: { nullable: true }, + options: { nullable: true; timeout?: number }, ): Promise; get( tenantId: string, fieldPath: string, type: typeof String, - options: { nullable: true }, + options: { nullable: true; timeout?: number }, ): Promise; get( tenantId: string, @@ -157,11 +174,10 @@ export class ConfigClient { const nullable = options?.nullable ?? false; const fn = async () => { - const resp = await this.callGetField({ - tenantId, - fieldPath, - includeDescription: false, - }); + const resp = await this.callGetField( + { tenantId, fieldPath, includeDescription: false }, + options?.timeout, + ); const cv = resp.value; if (cv === undefined || cv.value === undefined) { @@ -189,12 +205,12 @@ export class ConfigClient { * * @returns A record mapping field paths to their string values. */ - async getAll(tenantId: string): Promise> { + async getAll(tenantId: string, options?: { timeout?: number }): Promise> { const fn = async () => { - const resp = await this.callGetConfig({ - tenantId, - includeDescriptions: false, - }); + const resp = await this.callGetConfig( + { tenantId, includeDescriptions: false }, + options?.timeout, + ); const result: Record = {}; if (resp.config) { @@ -212,13 +228,17 @@ export class ConfigClient { * Set a config value. The value is sent as a string -- the server * coerces it to the schema-defined type. */ - async set(tenantId: string, fieldPath: string, value: string): Promise { + async set( + tenantId: string, + fieldPath: string, + value: string, + options?: { timeout?: number }, + ): Promise { const fn = async () => { - await this.callSetField({ - tenantId, - fieldPath, - value: { stringValue: value }, - }); + await this.callSetField( + { tenantId, fieldPath, value: { stringValue: value } }, + options?.timeout, + ); }; return this.withRetryAndMap(fn); @@ -233,18 +253,17 @@ export class ConfigClient { async setMany( tenantId: string, values: Record, - options?: { description?: string }, + options?: { description?: string; timeout?: number }, ): Promise { const fn = async () => { const updates = Object.entries(values).map(([fieldPath, v]) => ({ fieldPath, value: { stringValue: v }, })); - await this.callSetFields({ - tenantId, - updates, - description: options?.description, - }); + await this.callSetFields( + { tenantId, updates, description: options?.description }, + options?.timeout, + ); }; return this.withRetryAndMap(fn); @@ -253,13 +272,13 @@ export class ConfigClient { /** * Set a config field to null. */ - async setNull(tenantId: string, fieldPath: string): Promise { + async setNull( + tenantId: string, + fieldPath: string, + options?: { timeout?: number }, + ): Promise { const fn = async () => { - await this.callSetField({ - tenantId, - fieldPath, - value: undefined, - }); + await this.callSetField({ tenantId, fieldPath, value: undefined }, options?.timeout); }; return this.withRetryAndMap(fn); @@ -321,12 +340,12 @@ export class ConfigClient { } } - private callGetField(request: GetFieldRequest): Promise { + private callGetField(request: GetFieldRequest, timeoutMs?: number): Promise { return new Promise((resolve, reject) => { this.configStub.getField( request, this.metadata, - { deadline: Date.now() + this.timeout }, + { deadline: Date.now() + (timeoutMs ?? this.timeout) }, (err: ServiceError | null, resp: GetFieldResponse) => { if (err) reject(err); else resolve(resp); @@ -335,12 +354,12 @@ export class ConfigClient { }); } - private callGetConfig(request: GetConfigRequest): Promise { + private callGetConfig(request: GetConfigRequest, timeoutMs?: number): Promise { return new Promise((resolve, reject) => { this.configStub.getConfig( request, this.metadata, - { deadline: Date.now() + this.timeout }, + { deadline: Date.now() + (timeoutMs ?? this.timeout) }, (err: ServiceError | null, resp: GetConfigResponse) => { if (err) reject(err); else resolve(resp); @@ -349,12 +368,12 @@ export class ConfigClient { }); } - private callSetField(request: SetFieldRequest): Promise { + private callSetField(request: SetFieldRequest, timeoutMs?: number): Promise { return new Promise((resolve, reject) => { this.configStub.setField( request, this.metadata, - { deadline: Date.now() + this.timeout }, + { deadline: Date.now() + (timeoutMs ?? this.timeout) }, (err: ServiceError | null, resp: SetFieldResponse) => { if (err) reject(err); else resolve(resp); @@ -363,12 +382,12 @@ export class ConfigClient { }); } - private callSetFields(request: SetFieldsRequest): Promise { + private callSetFields(request: SetFieldsRequest, timeoutMs?: number): Promise { return new Promise((resolve, reject) => { this.configStub.setFields( request, this.metadata, - { deadline: Date.now() + this.timeout }, + { deadline: Date.now() + (timeoutMs ?? this.timeout) }, (err: ServiceError | null, resp: SetFieldsResponse) => { if (err) reject(err); else resolve(resp); diff --git a/test/client.test.ts b/test/client.test.ts index 778f853..3ed54f2 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -382,6 +382,144 @@ describe("ConfigClient", () => { }); }); + describe("per-call timeout", () => { + it("get() uses per-call timeout over client default", async () => { + let capturedDeadline: number | undefined; + const before = Date.now(); + configStub.getField.mockImplementation( + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { + capturedDeadline = opts.deadline; + cb(null, { + value: { fieldPath: "f", value: { stringValue: "v" }, checksum: "c" }, + }); + }, + ); + + await client.get("tenant-1", "f", String, { timeout: 500 }); + + expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500); + expect(capturedDeadline).toBeLessThan(before + 10_000); + }); + + it("getAll() uses per-call timeout over client default", async () => { + let capturedDeadline: number | undefined; + const before = Date.now(); + configStub.getConfig.mockImplementation( + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { + capturedDeadline = opts.deadline; + cb(null, { config: { tenantId: "t", version: 1, values: [] } }); + }, + ); + + await client.getAll("tenant-1", { timeout: 500 }); + + expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500); + expect(capturedDeadline).toBeLessThan(before + 10_000); + }); + + it("set() uses per-call timeout over client default", async () => { + let capturedDeadline: number | undefined; + const before = Date.now(); + configStub.setField.mockImplementation( + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { + capturedDeadline = opts.deadline; + cb(null, { configVersion: { version: 1 } }); + }, + ); + + await client.set("tenant-1", "f", "v", { timeout: 500 }); + + expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500); + expect(capturedDeadline).toBeLessThan(before + 10_000); + }); + + it("setMany() uses per-call timeout over client default", async () => { + let capturedDeadline: number | undefined; + const before = Date.now(); + configStub.setFields.mockImplementation( + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { + capturedDeadline = opts.deadline; + cb(null, { configVersion: { version: 1 } }); + }, + ); + + await client.setMany("tenant-1", { f: "v" }, { timeout: 500 }); + + expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500); + expect(capturedDeadline).toBeLessThan(before + 10_000); + }); + + it("setNull() uses per-call timeout over client default", async () => { + let capturedDeadline: number | undefined; + const before = Date.now(); + configStub.setField.mockImplementation( + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { + capturedDeadline = opts.deadline; + cb(null, { configVersion: { version: 1 } }); + }, + ); + + await client.setNull("tenant-1", "f", { timeout: 500 }); + + expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500); + expect(capturedDeadline).toBeLessThan(before + 10_000); + }); + + it("falls back to client default when no per-call timeout", async () => { + let capturedDeadline: number | undefined; + const clientWithTimeout = new ConfigClient("localhost:9090", { + subject: "u", + timeout: 3000, + retry: false, + }); + const before = Date.now(); + configStub.getField.mockImplementation( + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { + capturedDeadline = opts.deadline; + cb(null, { + value: { fieldPath: "f", value: { stringValue: "v" }, checksum: "c" }, + }); + }, + ); + + await clientWithTimeout.get("tenant-1", "f"); + clientWithTimeout.close(); + + expect(capturedDeadline).toBeGreaterThanOrEqual(before + 3000); + expect(capturedDeadline).toBeLessThan(before + 10_000); + }); + }); + describe("auth metadata", () => { it("sets subject and role metadata headers", async () => { configStub.getField.mockImplementation( diff --git a/test/watcher.test.ts b/test/watcher.test.ts index 4c34ba6..e519b45 100644 --- a/test/watcher.test.ts +++ b/test/watcher.test.ts @@ -593,7 +593,7 @@ describe("ConfigWatcher", () => { it("stops on non-retryable error", async () => { const watcher = createWatcher(); - const fee = watcher.field("payments.fee", Number, { default: 0.01 }); + const _fee = watcher.field("payments.fee", Number, { default: 0.01 }); mockGetConfigSuccess([]);