Add abort API support for promise calls (#1478)

For #1478

- Also removed CallOptions in favor of PromiseCallOptions (to align with internal codebase)
- Also updated chrome to 112.0.5615.165 for supporting abort with reason.
This commit is contained in:
Eryu Xia 2025-04-20 14:55:04 -07:00 committed by GitHub
parent b5ff5d303d
commit 7ea072a94d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 138 additions and 41 deletions

View File

@ -32,6 +32,20 @@ const MethodDescriptor = goog.require('grpc.web.MethodDescriptor');
const RpcError = goog.require('grpc.web.RpcError'); const RpcError = goog.require('grpc.web.RpcError');
/**
* @constructor
* @struct
* @final
*/
const PromiseCallOptions = function() {};
/**
* An AbortSignal to abort the call.
* @type {AbortSignal|undefined}
*/
PromiseCallOptions.prototype.signal;
/** /**
* This interface represents a grpc-web client * This interface represents a grpc-web client
* @interface * @interface
@ -62,10 +76,11 @@ const AbstractClientBase = class {
* @param {!Object<string, string>} metadata User defined call metadata * @param {!Object<string, string>} metadata User defined call metadata
* @param {!MethodDescriptor<REQUEST, RESPONSE>} * @param {!MethodDescriptor<REQUEST, RESPONSE>}
* methodDescriptor Information of this RPC method * methodDescriptor Information of this RPC method
* @param options Options for the call
* @return {!IThenable<RESPONSE>} * @return {!IThenable<RESPONSE>}
* A promise that resolves to the response message * A promise that resolves to the response message
*/ */
thenableCall(method, requestMessage, metadata, methodDescriptor) {} thenableCall(method, requestMessage, metadata, methodDescriptor, options) {}
/** /**
* @abstract * @abstract
@ -78,21 +93,19 @@ const AbstractClientBase = class {
* @return {!ClientReadableStream<RESPONSE>} The Client Readable Stream * @return {!ClientReadableStream<RESPONSE>} The Client Readable Stream
*/ */
serverStreaming(method, requestMessage, metadata, methodDescriptor) {} serverStreaming(method, requestMessage, metadata, methodDescriptor) {}
/**
* Get the hostname of the current request.
* @static
* @template REQUEST, RESPONSE
* @param {string} method
* @param {!MethodDescriptor<REQUEST,RESPONSE>} methodDescriptor
* @return {string}
*/
static getHostname(method, methodDescriptor) {
// method = hostname + methodDescriptor.name(relative path of this method)
return method.substr(0, method.length - methodDescriptor.name.length);
}
}; };
/**
* Get the hostname of the current request.
* @template REQUEST, RESPONSE
* @param {string} method
* @param {!MethodDescriptor<REQUEST,RESPONSE>} methodDescriptor
* @return {string}
*/
function getHostname(method, methodDescriptor) {
// method = hostname + methodDescriptor.name(relative path of this method)
return method.substr(0, method.length - methodDescriptor.name.length);
}
exports = AbstractClientBase; exports = {AbstractClientBase, PromiseCallOptions, getHostname};

View File

@ -28,7 +28,6 @@ goog.module('grpc.web.GrpcWebClientBase');
goog.module.declareLegacyNamespace(); goog.module.declareLegacyNamespace();
const AbstractClientBase = goog.require('grpc.web.AbstractClientBase');
const ClientOptions = goog.requireType('grpc.web.ClientOptions'); const ClientOptions = goog.requireType('grpc.web.ClientOptions');
const ClientReadableStream = goog.require('grpc.web.ClientReadableStream'); const ClientReadableStream = goog.require('grpc.web.ClientReadableStream');
const ClientUnaryCallImpl = goog.require('grpc.web.ClientUnaryCallImpl'); const ClientUnaryCallImpl = goog.require('grpc.web.ClientUnaryCallImpl');
@ -40,6 +39,7 @@ const RpcError = goog.require('grpc.web.RpcError');
const StatusCode = goog.require('grpc.web.StatusCode'); const StatusCode = goog.require('grpc.web.StatusCode');
const XhrIo = goog.require('goog.net.XhrIo'); const XhrIo = goog.require('goog.net.XhrIo');
const googCrypt = goog.require('goog.crypt.base64'); const googCrypt = goog.require('goog.crypt.base64');
const {AbstractClientBase, PromiseCallOptions, getHostname} = goog.require('grpc.web.AbstractClientBase');
const {Status} = goog.require('grpc.web.Status'); const {Status} = goog.require('grpc.web.Status');
const {StreamInterceptor, UnaryInterceptor} = goog.require('grpc.web.Interceptor'); const {StreamInterceptor, UnaryInterceptor} = goog.require('grpc.web.Interceptor');
const {toObject} = goog.require('goog.collections.maps'); const {toObject} = goog.require('goog.collections.maps');
@ -101,7 +101,7 @@ class GrpcWebClientBase {
* @export * @export
*/ */
rpcCall(method, requestMessage, metadata, methodDescriptor, callback) { rpcCall(method, requestMessage, metadata, methodDescriptor, callback) {
const hostname = AbstractClientBase.getHostname(method, methodDescriptor); const hostname = getHostname(method, methodDescriptor);
const invoker = GrpcWebClientBase.runInterceptors_( const invoker = GrpcWebClientBase.runInterceptors_(
(request) => this.startStream_(request, hostname), (request) => this.startStream_(request, hostname),
this.streamInterceptors_); this.streamInterceptors_);
@ -112,18 +112,35 @@ class GrpcWebClientBase {
} }
/** /**
* @override * @param {string} method The method to invoke
* @export * @param {REQUEST} requestMessage The request proto
* @param {!Object<string, string>} metadata User defined call metadata
* @param {!MethodDescriptor<REQUEST, RESPONSE>} methodDescriptor
* @param {?PromiseCallOptions=} options Options for the call
* @return {!Promise<RESPONSE>}
* @template REQUEST, RESPONSE
*/ */
thenableCall(method, requestMessage, metadata, methodDescriptor) { thenableCall(
const hostname = AbstractClientBase.getHostname(method, methodDescriptor); method, requestMessage, metadata, methodDescriptor, options = {}) {
const hostname = getHostname(method, methodDescriptor);
const signal = options && options.signal;
const initialInvoker = (request) => new Promise((resolve, reject) => { const initialInvoker = (request) => new Promise((resolve, reject) => {
// If the signal is already aborted, immediately reject the promise
// and don't issue the call.
if (signal && signal.aborted) {
const error = new RpcError(StatusCode.CANCELLED, 'Aborted');
error.cause = signal.reason;
reject(error);
return;
}
const stream = this.startStream_(request, hostname); const stream = this.startStream_(request, hostname);
let unaryMetadata; let unaryMetadata;
let unaryStatus; let unaryStatus;
let unaryMsg; let unaryMsg;
GrpcWebClientBase.setCallback_( GrpcWebClientBase.setCallback_(
stream, (error, response, status, metadata, unaryResponseReceived) => { stream,
(error, response, status, metadata, unaryResponseReceived) => {
if (error) { if (error) {
reject(error); reject(error);
} else if (unaryResponseReceived) { } else if (unaryResponseReceived) {
@ -136,7 +153,19 @@ class GrpcWebClientBase {
resolve(request.getMethodDescriptor().createUnaryResponse( resolve(request.getMethodDescriptor().createUnaryResponse(
unaryMsg, unaryMetadata, unaryStatus)); unaryMsg, unaryMetadata, unaryStatus));
} }
}, true); },
true);
// Wire up cancellation from the abort signal, if any.
if (signal) {
signal.addEventListener('abort', () => {
stream.cancel();
const error = new RpcError(StatusCode.CANCELLED, 'Aborted');
error.cause = /** @type {!AbortSignal} */ (signal).reason;
reject(error);
});
}
}); });
const invoker = GrpcWebClientBase.runInterceptors_( const invoker = GrpcWebClientBase.runInterceptors_(
initialInvoker, this.unaryInterceptors_); initialInvoker, this.unaryInterceptors_);
@ -152,12 +181,13 @@ class GrpcWebClientBase {
* @param {!Object<string, string>} metadata User defined call metadata * @param {!Object<string, string>} metadata User defined call metadata
* @param {!MethodDescriptor<REQUEST, RESPONSE>} methodDescriptor Information * @param {!MethodDescriptor<REQUEST, RESPONSE>} methodDescriptor Information
* of this RPC method * of this RPC method
* @param {?PromiseCallOptions=} options Options for the call
* @return {!Promise<RESPONSE>} * @return {!Promise<RESPONSE>}
* @template REQUEST, RESPONSE * @template REQUEST, RESPONSE
*/ */
unaryCall(method, requestMessage, metadata, methodDescriptor) { unaryCall(method, requestMessage, metadata, methodDescriptor, options = {}) {
return /** @type {!Promise<RESPONSE>}*/ ( return /** @type {!Promise<RESPONSE>}*/ (this.thenableCall(
this.thenableCall(method, requestMessage, metadata, methodDescriptor)); method, requestMessage, metadata, methodDescriptor, options));
} }
/** /**
@ -165,7 +195,7 @@ class GrpcWebClientBase {
* @export * @export
*/ */
serverStreaming(method, requestMessage, metadata, methodDescriptor) { serverStreaming(method, requestMessage, metadata, methodDescriptor) {
const hostname = AbstractClientBase.getHostname(method, methodDescriptor); const hostname = getHostname(method, methodDescriptor);
const invoker = GrpcWebClientBase.runInterceptors_( const invoker = GrpcWebClientBase.runInterceptors_(
(request) => this.startStream_(request, hostname), (request) => this.startStream_(request, hostname),
this.streamInterceptors_); this.streamInterceptors_);
@ -279,7 +309,9 @@ class GrpcWebClientBase {
message: 'Incomplete response', message: 'Incomplete response',
}); });
} else if (useUnaryResponse) { } else if (useUnaryResponse) {
callback(null, responseReceived, null, null, /* unaryResponseReceived= */ true); callback(
null, responseReceived, null, null,
/* unaryResponseReceived= */ true);
} else { } else {
callback(null, responseReceived); callback(null, responseReceived);
} }
@ -368,12 +400,9 @@ class GrpcWebClientBase {
* (!Promise<RESPONSE>|!ClientReadableStream<RESPONSE>)} * (!Promise<RESPONSE>|!ClientReadableStream<RESPONSE>)}
*/ */
static runInterceptors_(invoker, interceptors) { static runInterceptors_(invoker, interceptors) {
let curInvoker = invoker; return interceptors.reduce((accumulatedInvoker, interceptor) => {
interceptors.forEach((interceptor) => { return (request) => interceptor.intercept(request, accumulatedInvoker);
const lastInvoker = curInvoker; }, invoker);
curInvoker = (request) => interceptor.intercept(request, lastInvoker);
});
return curInvoker;
} }
} }

View File

@ -19,6 +19,7 @@ goog.module('grpc.web.GrpcWebClientBaseTest');
goog.setTestOnly('grpc.web.GrpcWebClientBaseTest'); goog.setTestOnly('grpc.web.GrpcWebClientBaseTest');
const ClientReadableStream = goog.require('grpc.web.ClientReadableStream'); const ClientReadableStream = goog.require('grpc.web.ClientReadableStream');
const ErrorCode = goog.require('goog.net.ErrorCode');
const GrpcWebClientBase = goog.require('grpc.web.GrpcWebClientBase'); const GrpcWebClientBase = goog.require('grpc.web.GrpcWebClientBase');
const MethodDescriptor = goog.require('grpc.web.MethodDescriptor'); const MethodDescriptor = goog.require('grpc.web.MethodDescriptor');
const ReadyState = goog.require('goog.net.XmlHttp.ReadyState'); const ReadyState = goog.require('goog.net.XmlHttp.ReadyState');
@ -146,6 +147,58 @@ testSuite({
assertElementsEquals(DEFAULT_UNARY_HEADER_VALUES, Object.values(headers)); assertElementsEquals(DEFAULT_UNARY_HEADER_VALUES, Object.values(headers));
}, },
async testCancelledThenableCall() {
const xhr = new XhrIo();
const client = new GrpcWebClientBase(/* options= */ {}, xhr);
const methodDescriptor = createMethodDescriptor((bytes) => {
assertElementsEquals(DEFAULT_RPC_RESPONSE_DATA, [].slice.call(bytes));
return 0;
});
const abortController = new AbortController();
const signal = abortController.signal;
const responsePromise = client.thenableCall(
'url', new MockRequest(), /* metadata= */ {}, methodDescriptor,
{signal});
abortController.abort();
const error = await assertRejects(responsePromise);
assertTrue(error instanceof RpcError);
assertEquals(StatusCode.CANCELLED, /** @type {!RpcError} */ (error).code);
assertEquals('Aborted', /** @type {!RpcError} */ (error).message);
// Default abort reason if none provided.
const cause = /** @type {!RpcError} */ (error).cause;
assertTrue(cause instanceof Error);
assertEquals('AbortError', /** @type {!Error} */ (cause).name);
assertEquals(ErrorCode.ABORT, xhr.getLastErrorCode());
},
async testCancelledThenableCallWithReason() {
const xhr = new XhrIo();
const client = new GrpcWebClientBase(/* options= */ {}, xhr);
const methodDescriptor = createMethodDescriptor((bytes) => {
assertElementsEquals(DEFAULT_RPC_RESPONSE_DATA, [].slice.call(bytes));
return 0;
});
const abortController = new AbortController();
const signal = abortController.signal;
const responsePromise = client.thenableCall(
'url', new MockRequest(), /* metadata= */ {}, methodDescriptor,
{signal});
abortController.abort('cancelling');
const error = await assertRejects(responsePromise);
assertTrue(error instanceof RpcError);
assertEquals(StatusCode.CANCELLED, /** @type {!RpcError} */ (error).code);
assertEquals('Aborted', /** @type {!RpcError} */ (error).message);
// Abort reason forwarded as cause.
const cause = /** @type {!RpcError} */ (error).cause;
assertEquals('cancelling', cause);
assertEquals(ErrorCode.ABORT, xhr.getLastErrorCode());
},
async testDeadline() { async testDeadline() {
const xhr = new XhrIo(); const xhr = new XhrIo();
const client = new GrpcWebClientBase(/* options= */ {}, xhr); const client = new GrpcWebClientBase(/* options= */ {}, xhr);

View File

@ -1,4 +1,4 @@
FROM selenium/standalone-chrome:93.0.4577.63 FROM selenium/standalone-chrome:112.0.5615.165
# Matching the node version used in the node:20.0.0-bullseye image. # Matching the node version used in the node:20.0.0-bullseye image.
ARG NODE_VERSION=20.0.0 ARG NODE_VERSION=20.0.0

View File

@ -7,7 +7,8 @@ declare module "grpc-web" {
method: string, method: string,
request: REQ, request: REQ,
metadata: Metadata, metadata: Metadata,
methodDescriptor: MethodDescriptor<REQ, RESP> methodDescriptor: MethodDescriptor<REQ, RESP>,
options?: PromiseCallOptions
): Promise<RESP>; ): Promise<RESP>;
rpcCall<REQ, RESP> ( rpcCall<REQ, RESP> (
@ -64,8 +65,10 @@ declare module "grpc-web" {
Promise<UnaryResponse<REQ, RESP>>): Promise<UnaryResponse<REQ, RESP>>; Promise<UnaryResponse<REQ, RESP>>): Promise<UnaryResponse<REQ, RESP>>;
} }
export class CallOptions { /** Options for gRPC-Web calls returning a Promise. */
constructor(options: { [index: string]: any; }); export interface PromiseCallOptions {
/** An AbortSignal to abort the call. */
readonly signal?: AbortSignal;
} }
export class MethodDescriptor<REQ, RESP> { export class MethodDescriptor<REQ, RESP> {
@ -82,7 +85,6 @@ declare module "grpc-web" {
getRequestMessage(): REQ; getRequestMessage(): REQ;
getMethodDescriptor(): MethodDescriptor<REQ, RESP>; getMethodDescriptor(): MethodDescriptor<REQ, RESP>;
getMetadata(): Metadata; getMetadata(): Metadata;
getCallOptions(): CallOptions;
} }
export class UnaryResponse<REQ, RESP> { export class UnaryResponse<REQ, RESP> {

View File

@ -44,8 +44,8 @@ trap cleanup EXIT
echo "Using Headless Chrome." echo "Using Headless Chrome."
# Updates Selenium Webdriver. # Updates Selenium Webdriver.
echo "$PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=93.0.4577.63 --gecko=false" echo "$PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=112.0.5615.165 --gecko=false"
$PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=93.0.4577.63 --gecko=false $PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=112.0.5615.165 --gecko=false
# Run the tests using Protractor! (Protractor should run selenium automatically) # Run the tests using Protractor! (Protractor should run selenium automatically)
$PROTRACTOR_BIN_PATH/protractor protractor.conf.js $PROTRACTOR_BIN_PATH/protractor protractor.conf.js