Skip to content

Commit 6b6bbe1

Browse files
zeevdrclaude
andauthored
feat(client): add AbortSignal support to all unary RPCs (#76)
Callers can now pass signal?: AbortSignal in the per-call options for get, getAll, set, setMany, and setNull. Aborting the signal calls cancel() on the underlying grpc-js ClientUnaryCall, which causes the in-flight RPC to reject with a CANCELLED status error. Closes #44 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b75f0da commit 6b6bbe1

2 files changed

Lines changed: 108 additions & 20 deletions

File tree

src/client.ts

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ import type { ClientOptions, RetryConfig, ServerInfo } from "./types.js";
3636
import { ConfigWatcher } from "./watcher.js";
3737

3838
/**
39-
* Options for get() with nullable and per-call timeout support.
39+
* Options for get() with nullable, per-call timeout, and cancellation support.
4040
*/
4141
interface GetOptions {
4242
readonly nullable?: boolean;
4343
/** Per-call timeout in ms. Overrides the client default. */
4444
readonly timeout?: number;
45+
/** Cancels the in-flight RPC when aborted. */
46+
readonly signal?: AbortSignal;
4547
}
4648

4749
/**
@@ -133,19 +135,19 @@ export class ConfigClient {
133135
tenantId: string,
134136
fieldPath: string,
135137
type: typeof Number,
136-
options?: { timeout?: number },
138+
options?: { timeout?: number; signal?: AbortSignal },
137139
): Promise<number>;
138140
get(
139141
tenantId: string,
140142
fieldPath: string,
141143
type: typeof Boolean,
142-
options?: { timeout?: number },
144+
options?: { timeout?: number; signal?: AbortSignal },
143145
): Promise<boolean>;
144146
get(
145147
tenantId: string,
146148
fieldPath: string,
147149
type: typeof String,
148-
options?: { timeout?: number },
150+
options?: { timeout?: number; signal?: AbortSignal },
149151
): Promise<string>;
150152
/**
151153
* Get a config value with nullable support.
@@ -155,19 +157,19 @@ export class ConfigClient {
155157
tenantId: string,
156158
fieldPath: string,
157159
type: typeof Number,
158-
options: { nullable: true; timeout?: number },
160+
options: { nullable: true; timeout?: number; signal?: AbortSignal },
159161
): Promise<number | null>;
160162
get(
161163
tenantId: string,
162164
fieldPath: string,
163165
type: typeof Boolean,
164-
options: { nullable: true; timeout?: number },
166+
options: { nullable: true; timeout?: number; signal?: AbortSignal },
165167
): Promise<boolean | null>;
166168
get(
167169
tenantId: string,
168170
fieldPath: string,
169171
type: typeof String,
170-
options: { nullable: true; timeout?: number },
172+
options: { nullable: true; timeout?: number; signal?: AbortSignal },
171173
): Promise<string | null>;
172174
get(
173175
tenantId: string,
@@ -182,6 +184,7 @@ export class ConfigClient {
182184
const resp = await this.callGetField(
183185
{ tenantId, fieldPath, includeDescription: false },
184186
options?.timeout,
187+
options?.signal,
185188
);
186189

187190
const cv = resp.value;
@@ -210,11 +213,15 @@ export class ConfigClient {
210213
*
211214
* @returns A record mapping field paths to their string values.
212215
*/
213-
async getAll(tenantId: string, options?: { timeout?: number }): Promise<Record<string, string>> {
216+
async getAll(
217+
tenantId: string,
218+
options?: { timeout?: number; signal?: AbortSignal },
219+
): Promise<Record<string, string>> {
214220
const fn = async () => {
215221
const resp = await this.callGetConfig(
216222
{ tenantId, includeDescriptions: false },
217223
options?.timeout,
224+
options?.signal,
218225
);
219226

220227
const result: Record<string, string> = {};
@@ -237,12 +244,13 @@ export class ConfigClient {
237244
tenantId: string,
238245
fieldPath: string,
239246
value: string,
240-
options?: { timeout?: number; idempotencyKey?: string },
247+
options?: { timeout?: number; idempotencyKey?: string; signal?: AbortSignal },
241248
): Promise<void> {
242249
const fn = async () => {
243250
await this.callSetField(
244251
{ tenantId, fieldPath, value: { stringValue: value } },
245252
options?.timeout,
253+
options?.signal,
246254
);
247255
};
248256

@@ -261,7 +269,12 @@ export class ConfigClient {
261269
async setMany(
262270
tenantId: string,
263271
values: Record<string, string>,
264-
options?: { description?: string; timeout?: number; idempotencyKey?: string },
272+
options?: {
273+
description?: string;
274+
timeout?: number;
275+
idempotencyKey?: string;
276+
signal?: AbortSignal;
277+
},
265278
): Promise<void> {
266279
const fn = async () => {
267280
const updates = Object.entries(values).map(([fieldPath, v]) => ({
@@ -271,6 +284,7 @@ export class ConfigClient {
271284
await this.callSetFields(
272285
{ tenantId, updates, description: options?.description },
273286
options?.timeout,
287+
options?.signal,
274288
);
275289
};
276290

@@ -286,10 +300,14 @@ export class ConfigClient {
286300
async setNull(
287301
tenantId: string,
288302
fieldPath: string,
289-
options?: { timeout?: number; idempotencyKey?: string },
303+
options?: { timeout?: number; idempotencyKey?: string; signal?: AbortSignal },
290304
): Promise<void> {
291305
const fn = async () => {
292-
await this.callSetField({ tenantId, fieldPath, value: undefined }, options?.timeout);
306+
await this.callSetField(
307+
{ tenantId, fieldPath, value: undefined },
308+
options?.timeout,
309+
options?.signal,
310+
);
293311
};
294312

295313
const codes = options?.idempotencyKey
@@ -361,9 +379,13 @@ export class ConfigClient {
361379
}
362380
}
363381

364-
private callGetField(request: GetFieldRequest, timeoutMs?: number): Promise<GetFieldResponse> {
382+
private callGetField(
383+
request: GetFieldRequest,
384+
timeoutMs?: number,
385+
signal?: AbortSignal,
386+
): Promise<GetFieldResponse> {
365387
return new Promise((resolve, reject) => {
366-
this.configStub.getField(
388+
const call = this.configStub.getField(
367389
request,
368390
this.metadata,
369391
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
@@ -372,12 +394,17 @@ export class ConfigClient {
372394
else resolve(resp);
373395
},
374396
);
397+
signal?.addEventListener("abort", () => call.cancel(), { once: true });
375398
});
376399
}
377400

378-
private callGetConfig(request: GetConfigRequest, timeoutMs?: number): Promise<GetConfigResponse> {
401+
private callGetConfig(
402+
request: GetConfigRequest,
403+
timeoutMs?: number,
404+
signal?: AbortSignal,
405+
): Promise<GetConfigResponse> {
379406
return new Promise((resolve, reject) => {
380-
this.configStub.getConfig(
407+
const call = this.configStub.getConfig(
381408
request,
382409
this.metadata,
383410
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
@@ -386,12 +413,17 @@ export class ConfigClient {
386413
else resolve(resp);
387414
},
388415
);
416+
signal?.addEventListener("abort", () => call.cancel(), { once: true });
389417
});
390418
}
391419

392-
private callSetField(request: SetFieldRequest, timeoutMs?: number): Promise<SetFieldResponse> {
420+
private callSetField(
421+
request: SetFieldRequest,
422+
timeoutMs?: number,
423+
signal?: AbortSignal,
424+
): Promise<SetFieldResponse> {
393425
return new Promise((resolve, reject) => {
394-
this.configStub.setField(
426+
const call = this.configStub.setField(
395427
request,
396428
this.metadata,
397429
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
@@ -400,12 +432,17 @@ export class ConfigClient {
400432
else resolve(resp);
401433
},
402434
);
435+
signal?.addEventListener("abort", () => call.cancel(), { once: true });
403436
});
404437
}
405438

406-
private callSetFields(request: SetFieldsRequest, timeoutMs?: number): Promise<SetFieldsResponse> {
439+
private callSetFields(
440+
request: SetFieldsRequest,
441+
timeoutMs?: number,
442+
signal?: AbortSignal,
443+
): Promise<SetFieldsResponse> {
407444
return new Promise((resolve, reject) => {
408-
this.configStub.setFields(
445+
const call = this.configStub.setFields(
409446
request,
410447
this.metadata,
411448
{ deadline: Date.now() + (timeoutMs ?? this.timeout) },
@@ -414,6 +451,7 @@ export class ConfigClient {
414451
else resolve(resp);
415452
},
416453
);
454+
signal?.addEventListener("abort", () => call.cancel(), { once: true });
417455
});
418456
}
419457

test/client.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,56 @@ describe("ConfigClient", () => {
558558
});
559559
});
560560

561+
describe("AbortSignal", () => {
562+
function makeCancellableStub(mock: MockInstance) {
563+
mock.mockImplementation(
564+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => ({
565+
cancel: () => cb(makeServiceError(status.CANCELLED, "rpc cancelled"), undefined),
566+
}),
567+
);
568+
}
569+
570+
it("get() cancels the in-flight call when signal is aborted", async () => {
571+
makeCancellableStub(configStub.getField);
572+
const controller = new AbortController();
573+
const p = client.get("tenant-1", "f", String, { signal: controller.signal });
574+
controller.abort();
575+
await expect(p).rejects.toThrow(DecreeError);
576+
});
577+
578+
it("getAll() cancels the in-flight call when signal is aborted", async () => {
579+
makeCancellableStub(configStub.getConfig);
580+
const controller = new AbortController();
581+
const p = client.getAll("tenant-1", { signal: controller.signal });
582+
controller.abort();
583+
await expect(p).rejects.toThrow(DecreeError);
584+
});
585+
586+
it("set() cancels the in-flight call when signal is aborted", async () => {
587+
makeCancellableStub(configStub.setField);
588+
const controller = new AbortController();
589+
const p = client.set("tenant-1", "f", "v", { signal: controller.signal });
590+
controller.abort();
591+
await expect(p).rejects.toThrow(DecreeError);
592+
});
593+
594+
it("setMany() cancels the in-flight call when signal is aborted", async () => {
595+
makeCancellableStub(configStub.setFields);
596+
const controller = new AbortController();
597+
const p = client.setMany("tenant-1", { f: "v" }, { signal: controller.signal });
598+
controller.abort();
599+
await expect(p).rejects.toThrow(DecreeError);
600+
});
601+
602+
it("setNull() cancels the in-flight call when signal is aborted", async () => {
603+
makeCancellableStub(configStub.setField);
604+
const controller = new AbortController();
605+
const p = client.setNull("tenant-1", "f", { signal: controller.signal });
606+
controller.abort();
607+
await expect(p).rejects.toThrow(DecreeError);
608+
});
609+
});
610+
561611
describe("TLS channel", () => {
562612
it("creates TLS channel by default", () => {
563613
const c = new ConfigClient("localhost:9090", { retry: false });

0 commit comments

Comments
 (0)