Skip to content

Commit bfc9cf8

Browse files
authored
feat(client): add per-call timeout override to all public methods
- All public methods (get, getAll, set, setMany, setNull) 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 absent, preserving existing behavior. - Six new unit tests verify per-call override and fallback for each method. Closes #45
1 parent f657758 commit bfc9cf8

3 files changed

Lines changed: 201 additions & 44 deletions

File tree

src/client.ts

Lines changed: 62 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ import type { ClientOptions, RetryConfig, ServerInfo } from "./types.js";
3131
import { ConfigWatcher } from "./watcher.js";
3232

3333
/**
34-
* Options for get() with nullable support.
34+
* Options for get() with nullable and per-call timeout support.
3535
*/
3636
interface GetOptions {
3737
readonly nullable?: boolean;
38+
/** Per-call timeout in ms. Overrides the client default. */
39+
readonly timeout?: number;
3840
}
3941

4042
/**
@@ -122,9 +124,24 @@ export class ConfigClient {
122124
/**
123125
* Get a config value converted to the specified type.
124126
*/
125-
get(tenantId: string, fieldPath: string, type: typeof Number): Promise<number>;
126-
get(tenantId: string, fieldPath: string, type: typeof Boolean): Promise<boolean>;
127-
get(tenantId: string, fieldPath: string, type: typeof String): Promise<string>;
127+
get(
128+
tenantId: string,
129+
fieldPath: string,
130+
type: typeof Number,
131+
options?: { timeout?: number },
132+
): Promise<number>;
133+
get(
134+
tenantId: string,
135+
fieldPath: string,
136+
type: typeof Boolean,
137+
options?: { timeout?: number },
138+
): Promise<boolean>;
139+
get(
140+
tenantId: string,
141+
fieldPath: string,
142+
type: typeof String,
143+
options?: { timeout?: number },
144+
): Promise<string>;
128145
/**
129146
* Get a config value with nullable support.
130147
* Returns null if the field has no value instead of throwing.
@@ -133,19 +150,19 @@ export class ConfigClient {
133150
tenantId: string,
134151
fieldPath: string,
135152
type: typeof Number,
136-
options: { nullable: true },
153+
options: { nullable: true; timeout?: number },
137154
): Promise<number | null>;
138155
get(
139156
tenantId: string,
140157
fieldPath: string,
141158
type: typeof Boolean,
142-
options: { nullable: true },
159+
options: { nullable: true; timeout?: number },
143160
): Promise<boolean | null>;
144161
get(
145162
tenantId: string,
146163
fieldPath: string,
147164
type: typeof String,
148-
options: { nullable: true },
165+
options: { nullable: true; timeout?: number },
149166
): Promise<string | null>;
150167
get(
151168
tenantId: string,
@@ -157,11 +174,10 @@ export class ConfigClient {
157174
const nullable = options?.nullable ?? false;
158175

159176
const fn = async () => {
160-
const resp = await this.callGetField({
161-
tenantId,
162-
fieldPath,
163-
includeDescription: false,
164-
});
177+
const resp = await this.callGetField(
178+
{ tenantId, fieldPath, includeDescription: false },
179+
options?.timeout,
180+
);
165181

166182
const cv = resp.value;
167183
if (cv === undefined || cv.value === undefined) {
@@ -189,12 +205,12 @@ export class ConfigClient {
189205
*
190206
* @returns A record mapping field paths to their string values.
191207
*/
192-
async getAll(tenantId: string): Promise<Record<string, string>> {
208+
async getAll(tenantId: string, options?: { timeout?: number }): Promise<Record<string, string>> {
193209
const fn = async () => {
194-
const resp = await this.callGetConfig({
195-
tenantId,
196-
includeDescriptions: false,
197-
});
210+
const resp = await this.callGetConfig(
211+
{ tenantId, includeDescriptions: false },
212+
options?.timeout,
213+
);
198214

199215
const result: Record<string, string> = {};
200216
if (resp.config) {
@@ -212,13 +228,17 @@ export class ConfigClient {
212228
* Set a config value. The value is sent as a string -- the server
213229
* coerces it to the schema-defined type.
214230
*/
215-
async set(tenantId: string, fieldPath: string, value: string): Promise<void> {
231+
async set(
232+
tenantId: string,
233+
fieldPath: string,
234+
value: string,
235+
options?: { timeout?: number },
236+
): Promise<void> {
216237
const fn = async () => {
217-
await this.callSetField({
218-
tenantId,
219-
fieldPath,
220-
value: { stringValue: value },
221-
});
238+
await this.callSetField(
239+
{ tenantId, fieldPath, value: { stringValue: value } },
240+
options?.timeout,
241+
);
222242
};
223243

224244
return this.withRetryAndMap(fn);
@@ -233,18 +253,17 @@ export class ConfigClient {
233253
async setMany(
234254
tenantId: string,
235255
values: Record<string, string>,
236-
options?: { description?: string },
256+
options?: { description?: string; timeout?: number },
237257
): Promise<void> {
238258
const fn = async () => {
239259
const updates = Object.entries(values).map(([fieldPath, v]) => ({
240260
fieldPath,
241261
value: { stringValue: v },
242262
}));
243-
await this.callSetFields({
244-
tenantId,
245-
updates,
246-
description: options?.description,
247-
});
263+
await this.callSetFields(
264+
{ tenantId, updates, description: options?.description },
265+
options?.timeout,
266+
);
248267
};
249268

250269
return this.withRetryAndMap(fn);
@@ -253,13 +272,13 @@ export class ConfigClient {
253272
/**
254273
* Set a config field to null.
255274
*/
256-
async setNull(tenantId: string, fieldPath: string): Promise<void> {
275+
async setNull(
276+
tenantId: string,
277+
fieldPath: string,
278+
options?: { timeout?: number },
279+
): Promise<void> {
257280
const fn = async () => {
258-
await this.callSetField({
259-
tenantId,
260-
fieldPath,
261-
value: undefined,
262-
});
281+
await this.callSetField({ tenantId, fieldPath, value: undefined }, options?.timeout);
263282
};
264283

265284
return this.withRetryAndMap(fn);
@@ -321,12 +340,12 @@ export class ConfigClient {
321340
}
322341
}
323342

324-
private callGetField(request: GetFieldRequest): Promise<GetFieldResponse> {
343+
private callGetField(request: GetFieldRequest, timeoutMs?: number): Promise<GetFieldResponse> {
325344
return new Promise((resolve, reject) => {
326345
this.configStub.getField(
327346
request,
328347
this.metadata,
329-
{ deadline: Date.now() + this.timeout },
348+
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
330349
(err: ServiceError | null, resp: GetFieldResponse) => {
331350
if (err) reject(err);
332351
else resolve(resp);
@@ -335,12 +354,12 @@ export class ConfigClient {
335354
});
336355
}
337356

338-
private callGetConfig(request: GetConfigRequest): Promise<GetConfigResponse> {
357+
private callGetConfig(request: GetConfigRequest, timeoutMs?: number): Promise<GetConfigResponse> {
339358
return new Promise((resolve, reject) => {
340359
this.configStub.getConfig(
341360
request,
342361
this.metadata,
343-
{ deadline: Date.now() + this.timeout },
362+
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
344363
(err: ServiceError | null, resp: GetConfigResponse) => {
345364
if (err) reject(err);
346365
else resolve(resp);
@@ -349,12 +368,12 @@ export class ConfigClient {
349368
});
350369
}
351370

352-
private callSetField(request: SetFieldRequest): Promise<SetFieldResponse> {
371+
private callSetField(request: SetFieldRequest, timeoutMs?: number): Promise<SetFieldResponse> {
353372
return new Promise((resolve, reject) => {
354373
this.configStub.setField(
355374
request,
356375
this.metadata,
357-
{ deadline: Date.now() + this.timeout },
376+
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
358377
(err: ServiceError | null, resp: SetFieldResponse) => {
359378
if (err) reject(err);
360379
else resolve(resp);
@@ -363,12 +382,12 @@ export class ConfigClient {
363382
});
364383
}
365384

366-
private callSetFields(request: SetFieldsRequest): Promise<SetFieldsResponse> {
385+
private callSetFields(request: SetFieldsRequest, timeoutMs?: number): Promise<SetFieldsResponse> {
367386
return new Promise((resolve, reject) => {
368387
this.configStub.setFields(
369388
request,
370389
this.metadata,
371-
{ deadline: Date.now() + this.timeout },
390+
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
372391
(err: ServiceError | null, resp: SetFieldsResponse) => {
373392
if (err) reject(err);
374393
else resolve(resp);

test/client.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,144 @@ describe("ConfigClient", () => {
382382
});
383383
});
384384

385+
describe("per-call timeout", () => {
386+
it("get() uses per-call timeout over client default", async () => {
387+
let capturedDeadline: number | undefined;
388+
const before = Date.now();
389+
configStub.getField.mockImplementation(
390+
(
391+
_req: unknown,
392+
_meta: unknown,
393+
opts: { deadline?: number },
394+
cb: (...args: unknown[]) => void,
395+
) => {
396+
capturedDeadline = opts.deadline;
397+
cb(null, {
398+
value: { fieldPath: "f", value: { stringValue: "v" }, checksum: "c" },
399+
});
400+
},
401+
);
402+
403+
await client.get("tenant-1", "f", String, { timeout: 500 });
404+
405+
expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500);
406+
expect(capturedDeadline).toBeLessThan(before + 10_000);
407+
});
408+
409+
it("getAll() uses per-call timeout over client default", async () => {
410+
let capturedDeadline: number | undefined;
411+
const before = Date.now();
412+
configStub.getConfig.mockImplementation(
413+
(
414+
_req: unknown,
415+
_meta: unknown,
416+
opts: { deadline?: number },
417+
cb: (...args: unknown[]) => void,
418+
) => {
419+
capturedDeadline = opts.deadline;
420+
cb(null, { config: { tenantId: "t", version: 1, values: [] } });
421+
},
422+
);
423+
424+
await client.getAll("tenant-1", { timeout: 500 });
425+
426+
expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500);
427+
expect(capturedDeadline).toBeLessThan(before + 10_000);
428+
});
429+
430+
it("set() uses per-call timeout over client default", async () => {
431+
let capturedDeadline: number | undefined;
432+
const before = Date.now();
433+
configStub.setField.mockImplementation(
434+
(
435+
_req: unknown,
436+
_meta: unknown,
437+
opts: { deadline?: number },
438+
cb: (...args: unknown[]) => void,
439+
) => {
440+
capturedDeadline = opts.deadline;
441+
cb(null, { configVersion: { version: 1 } });
442+
},
443+
);
444+
445+
await client.set("tenant-1", "f", "v", { timeout: 500 });
446+
447+
expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500);
448+
expect(capturedDeadline).toBeLessThan(before + 10_000);
449+
});
450+
451+
it("setMany() uses per-call timeout over client default", async () => {
452+
let capturedDeadline: number | undefined;
453+
const before = Date.now();
454+
configStub.setFields.mockImplementation(
455+
(
456+
_req: unknown,
457+
_meta: unknown,
458+
opts: { deadline?: number },
459+
cb: (...args: unknown[]) => void,
460+
) => {
461+
capturedDeadline = opts.deadline;
462+
cb(null, { configVersion: { version: 1 } });
463+
},
464+
);
465+
466+
await client.setMany("tenant-1", { f: "v" }, { timeout: 500 });
467+
468+
expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500);
469+
expect(capturedDeadline).toBeLessThan(before + 10_000);
470+
});
471+
472+
it("setNull() uses per-call timeout over client default", async () => {
473+
let capturedDeadline: number | undefined;
474+
const before = Date.now();
475+
configStub.setField.mockImplementation(
476+
(
477+
_req: unknown,
478+
_meta: unknown,
479+
opts: { deadline?: number },
480+
cb: (...args: unknown[]) => void,
481+
) => {
482+
capturedDeadline = opts.deadline;
483+
cb(null, { configVersion: { version: 1 } });
484+
},
485+
);
486+
487+
await client.setNull("tenant-1", "f", { timeout: 500 });
488+
489+
expect(capturedDeadline).toBeGreaterThanOrEqual(before + 500);
490+
expect(capturedDeadline).toBeLessThan(before + 10_000);
491+
});
492+
493+
it("falls back to client default when no per-call timeout", async () => {
494+
let capturedDeadline: number | undefined;
495+
const clientWithTimeout = new ConfigClient("localhost:9090", {
496+
subject: "u",
497+
timeout: 3000,
498+
retry: false,
499+
});
500+
const before = Date.now();
501+
configStub.getField.mockImplementation(
502+
(
503+
_req: unknown,
504+
_meta: unknown,
505+
opts: { deadline?: number },
506+
cb: (...args: unknown[]) => void,
507+
) => {
508+
capturedDeadline = opts.deadline;
509+
cb(null, {
510+
value: { fieldPath: "f", value: { stringValue: "v" }, checksum: "c" },
511+
});
512+
},
513+
);
514+
515+
await clientWithTimeout.get("tenant-1", "f");
516+
clientWithTimeout.close();
517+
518+
expect(capturedDeadline).toBeGreaterThanOrEqual(before + 3000);
519+
expect(capturedDeadline).toBeLessThan(before + 10_000);
520+
});
521+
});
522+
385523
describe("auth metadata", () => {
386524
it("sets subject and role metadata headers", async () => {
387525
configStub.getField.mockImplementation(

test/watcher.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,7 @@ describe("ConfigWatcher", () => {
593593

594594
it("stops on non-retryable error", async () => {
595595
const watcher = createWatcher();
596-
const fee = watcher.field("payments.fee", Number, { default: 0.01 });
596+
const _fee = watcher.field("payments.fee", Number, { default: 0.01 });
597597

598598
mockGetConfigSuccess([]);
599599

0 commit comments

Comments
 (0)