From 0f1b745daaa51890dbf04d8fc0a6e92621779245 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Wed, 20 May 2026 10:21:40 +0300 Subject: [PATCH 1/2] feat(client): add per-call timeout override to all public methods All public methods (get, getAll, set, setMany, setNull) now accept an optional `timeout?: number` (ms) that overrides the client-level default for that call only. Private helpers receive the value as `timeoutMs?` and fall back to `this.timeout` when it is absent. Closes #45 Co-Authored-By: Claude --- src/client.ts | 84 +++++++++++++++++----------------- test/client.test.ts | 108 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 43 deletions(-) diff --git a/src/client.ts b/src/client.ts index 00d11d0..a57de18 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,9 @@ 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 +135,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 +159,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 +190,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 +213,12 @@ 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 +233,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 +252,12 @@ 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 +319,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 +333,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 +347,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 +361,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..bca5753 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -382,6 +382,114 @@ 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( From 79b91c383de317d35038e7df6081b3b28e7ffad5 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Wed, 20 May 2026 10:30:48 +0300 Subject: [PATCH 2/2] style: reformat long signatures and fix unused variable in watcher test Biome reformatted overload signatures and method declarations in client.ts that exceeded the line length limit. Mock callback signatures in client.test.ts received the same treatment. Renamed `fee` to `_fee` in watcher.test.ts to resolve a pre-existing noUnusedVariables lint warning that was surfaced by CI. Co-Authored-By: Claude --- src/client.ts | 39 ++++++++++++++++++++++++++++++--------- test/client.test.ts | 42 ++++++++++++++++++++++++++++++++++++------ test/watcher.test.ts | 2 +- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/client.ts b/src/client.ts index a57de18..f014e88 100644 --- a/src/client.ts +++ b/src/client.ts @@ -124,9 +124,24 @@ export class ConfigClient { /** * Get a config value converted to the specified type. */ - 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( + 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. @@ -213,7 +228,12 @@ 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, options?: { timeout?: number }): Promise { + async set( + tenantId: string, + fieldPath: string, + value: string, + options?: { timeout?: number }, + ): Promise { const fn = async () => { await this.callSetField( { tenantId, fieldPath, value: { stringValue: value } }, @@ -252,12 +272,13 @@ export class ConfigClient { /** * Set a config field to null. */ - async setNull(tenantId: string, fieldPath: string, options?: { timeout?: number }): Promise { + async setNull( + tenantId: string, + fieldPath: string, + options?: { timeout?: number }, + ): Promise { const fn = async () => { - await this.callSetField( - { tenantId, fieldPath, value: undefined }, - options?.timeout, - ); + await this.callSetField({ tenantId, fieldPath, value: undefined }, options?.timeout); }; return this.withRetryAndMap(fn); diff --git a/test/client.test.ts b/test/client.test.ts index bca5753..3ed54f2 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -387,7 +387,12 @@ describe("ConfigClient", () => { let capturedDeadline: number | undefined; const before = Date.now(); configStub.getField.mockImplementation( - (_req: unknown, _meta: unknown, opts: { deadline?: number }, cb: (...args: unknown[]) => void) => { + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { capturedDeadline = opts.deadline; cb(null, { value: { fieldPath: "f", value: { stringValue: "v" }, checksum: "c" }, @@ -405,7 +410,12 @@ describe("ConfigClient", () => { let capturedDeadline: number | undefined; const before = Date.now(); configStub.getConfig.mockImplementation( - (_req: unknown, _meta: unknown, opts: { deadline?: number }, cb: (...args: unknown[]) => void) => { + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { capturedDeadline = opts.deadline; cb(null, { config: { tenantId: "t", version: 1, values: [] } }); }, @@ -421,7 +431,12 @@ describe("ConfigClient", () => { let capturedDeadline: number | undefined; const before = Date.now(); configStub.setField.mockImplementation( - (_req: unknown, _meta: unknown, opts: { deadline?: number }, cb: (...args: unknown[]) => void) => { + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { capturedDeadline = opts.deadline; cb(null, { configVersion: { version: 1 } }); }, @@ -437,7 +452,12 @@ describe("ConfigClient", () => { let capturedDeadline: number | undefined; const before = Date.now(); configStub.setFields.mockImplementation( - (_req: unknown, _meta: unknown, opts: { deadline?: number }, cb: (...args: unknown[]) => void) => { + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { capturedDeadline = opts.deadline; cb(null, { configVersion: { version: 1 } }); }, @@ -453,7 +473,12 @@ describe("ConfigClient", () => { let capturedDeadline: number | undefined; const before = Date.now(); configStub.setField.mockImplementation( - (_req: unknown, _meta: unknown, opts: { deadline?: number }, cb: (...args: unknown[]) => void) => { + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { capturedDeadline = opts.deadline; cb(null, { configVersion: { version: 1 } }); }, @@ -474,7 +499,12 @@ describe("ConfigClient", () => { }); const before = Date.now(); configStub.getField.mockImplementation( - (_req: unknown, _meta: unknown, opts: { deadline?: number }, cb: (...args: unknown[]) => void) => { + ( + _req: unknown, + _meta: unknown, + opts: { deadline?: number }, + cb: (...args: unknown[]) => void, + ) => { capturedDeadline = opts.deadline; cb(null, { value: { fieldPath: "f", value: { stringValue: "v" }, checksum: "c" }, 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([]);