Skip to content
Merged
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
105 changes: 62 additions & 43 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<number>;
get(tenantId: string, fieldPath: string, type: typeof Boolean): Promise<boolean>;
get(tenantId: string, fieldPath: string, type: typeof String): Promise<string>;
get(
tenantId: string,
fieldPath: string,
type: typeof Number,
options?: { timeout?: number },
): Promise<number>;
get(
tenantId: string,
fieldPath: string,
type: typeof Boolean,
options?: { timeout?: number },
): Promise<boolean>;
get(
tenantId: string,
fieldPath: string,
type: typeof String,
options?: { timeout?: number },
): Promise<string>;
/**
* Get a config value with nullable support.
* Returns null if the field has no value instead of throwing.
Expand All @@ -133,19 +150,19 @@ export class ConfigClient {
tenantId: string,
fieldPath: string,
type: typeof Number,
options: { nullable: true },
options: { nullable: true; timeout?: number },
): Promise<number | null>;
get(
tenantId: string,
fieldPath: string,
type: typeof Boolean,
options: { nullable: true },
options: { nullable: true; timeout?: number },
): Promise<boolean | null>;
get(
tenantId: string,
fieldPath: string,
type: typeof String,
options: { nullable: true },
options: { nullable: true; timeout?: number },
): Promise<string | null>;
get(
tenantId: string,
Expand All @@ -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) {
Expand Down Expand Up @@ -189,12 +205,12 @@ export class ConfigClient {
*
* @returns A record mapping field paths to their string values.
*/
async getAll(tenantId: string): Promise<Record<string, string>> {
async getAll(tenantId: string, options?: { timeout?: number }): Promise<Record<string, string>> {
const fn = async () => {
const resp = await this.callGetConfig({
tenantId,
includeDescriptions: false,
});
const resp = await this.callGetConfig(
{ tenantId, includeDescriptions: false },
options?.timeout,
);

const result: Record<string, string> = {};
if (resp.config) {
Expand All @@ -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<void> {
async set(
tenantId: string,
fieldPath: string,
value: string,
options?: { timeout?: number },
): Promise<void> {
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);
Expand All @@ -233,18 +253,17 @@ export class ConfigClient {
async setMany(
tenantId: string,
values: Record<string, string>,
options?: { description?: string },
options?: { description?: string; timeout?: number },
): Promise<void> {
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);
Expand All @@ -253,13 +272,13 @@ export class ConfigClient {
/**
* Set a config field to null.
*/
async setNull(tenantId: string, fieldPath: string): Promise<void> {
async setNull(
tenantId: string,
fieldPath: string,
options?: { timeout?: number },
): Promise<void> {
const fn = async () => {
await this.callSetField({
tenantId,
fieldPath,
value: undefined,
});
await this.callSetField({ tenantId, fieldPath, value: undefined }, options?.timeout);
};

return this.withRetryAndMap(fn);
Expand Down Expand Up @@ -321,12 +340,12 @@ export class ConfigClient {
}
}

private callGetField(request: GetFieldRequest): Promise<GetFieldResponse> {
private callGetField(request: GetFieldRequest, timeoutMs?: number): Promise<GetFieldResponse> {
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);
Expand All @@ -335,12 +354,12 @@ export class ConfigClient {
});
}

private callGetConfig(request: GetConfigRequest): Promise<GetConfigResponse> {
private callGetConfig(request: GetConfigRequest, timeoutMs?: number): Promise<GetConfigResponse> {
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);
Expand All @@ -349,12 +368,12 @@ export class ConfigClient {
});
}

private callSetField(request: SetFieldRequest): Promise<SetFieldResponse> {
private callSetField(request: SetFieldRequest, timeoutMs?: number): Promise<SetFieldResponse> {
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);
Expand All @@ -363,12 +382,12 @@ export class ConfigClient {
});
}

private callSetFields(request: SetFieldsRequest): Promise<SetFieldsResponse> {
private callSetFields(request: SetFieldsRequest, timeoutMs?: number): Promise<SetFieldsResponse> {
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);
Expand Down
138 changes: 138 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion test/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);

Expand Down