grpc-js: Propagate connectivity error information to request errors

This commit is contained in:
Michael Lumish 2023-10-16 16:52:41 -07:00
parent 065ac2fef6
commit 3a9f4d2aa6
7 changed files with 47 additions and 24 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@grpc/grpc-js", "name": "@grpc/grpc-js",
"version": "1.9.5", "version": "1.9.6",
"description": "gRPC Library for Node - pure JS implementation", "description": "gRPC Library for Node - pure JS implementation",
"homepage": "https://grpc.io/", "homepage": "https://grpc.io/",
"repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js", "repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js",

View File

@ -153,9 +153,11 @@ export class PickFirstLoadBalancer implements LoadBalancer {
private subchannelStateListener: ConnectivityStateListener = ( private subchannelStateListener: ConnectivityStateListener = (
subchannel, subchannel,
previousState, previousState,
newState newState,
keepaliveTime,
errorMessage
) => { ) => {
this.onSubchannelStateUpdate(subchannel, previousState, newState); this.onSubchannelStateUpdate(subchannel, previousState, newState, errorMessage);
}; };
/** /**
* Timer reference for the timer tracking when to start * Timer reference for the timer tracking when to start
@ -172,6 +174,12 @@ export class PickFirstLoadBalancer implements LoadBalancer {
*/ */
private stickyTransientFailureMode = false; private stickyTransientFailureMode = false;
/**
* The most recent error reported by any subchannel as it transitioned to
* TRANSIENT_FAILURE.
*/
private lastError: string | null = null;
/** /**
* Load balancer that attempts to connect to each backend in the address list * Load balancer that attempts to connect to each backend in the address list
* in order, and picks the first one that connects, using it for every * in order, and picks the first one that connects, using it for every
@ -200,7 +208,7 @@ export class PickFirstLoadBalancer implements LoadBalancer {
if (this.stickyTransientFailureMode) { if (this.stickyTransientFailureMode) {
this.updateState( this.updateState(
ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.TRANSIENT_FAILURE,
new UnavailablePicker() new UnavailablePicker({details: `No connection established. Last error: ${this.lastError}`})
); );
} else { } else {
this.updateState(ConnectivityState.CONNECTING, new QueuePicker(this)); this.updateState(ConnectivityState.CONNECTING, new QueuePicker(this));
@ -241,7 +249,8 @@ export class PickFirstLoadBalancer implements LoadBalancer {
private onSubchannelStateUpdate( private onSubchannelStateUpdate(
subchannel: SubchannelInterface, subchannel: SubchannelInterface,
previousState: ConnectivityState, previousState: ConnectivityState,
newState: ConnectivityState newState: ConnectivityState,
errorMessage?: string
) { ) {
if (this.currentPick?.realSubchannelEquals(subchannel)) { if (this.currentPick?.realSubchannelEquals(subchannel)) {
if (newState !== ConnectivityState.READY) { if (newState !== ConnectivityState.READY) {
@ -258,6 +267,9 @@ export class PickFirstLoadBalancer implements LoadBalancer {
} }
if (newState === ConnectivityState.TRANSIENT_FAILURE) { if (newState === ConnectivityState.TRANSIENT_FAILURE) {
child.hasReportedTransientFailure = true; child.hasReportedTransientFailure = true;
if (errorMessage) {
this.lastError = errorMessage;
}
this.maybeEnterStickyTransientFailureMode(); this.maybeEnterStickyTransientFailureMode();
if (index === this.currentSubchannelIndex) { if (index === this.currentSubchannelIndex) {
this.startNextSubchannelConnecting(index + 1); this.startNextSubchannelConnecting(index + 1);

View File

@ -105,18 +105,24 @@ export class RoundRobinLoadBalancer implements LoadBalancer {
private currentReadyPicker: RoundRobinPicker | null = null; private currentReadyPicker: RoundRobinPicker | null = null;
private lastError: string | null = null;
constructor(private readonly channelControlHelper: ChannelControlHelper) { constructor(private readonly channelControlHelper: ChannelControlHelper) {
this.subchannelStateListener = ( this.subchannelStateListener = (
subchannel: SubchannelInterface, subchannel: SubchannelInterface,
previousState: ConnectivityState, previousState: ConnectivityState,
newState: ConnectivityState newState: ConnectivityState,
keepaliveTime: number,
errorMessage?: string
) => { ) => {
this.calculateAndUpdateState(); this.calculateAndUpdateState();
if ( if (
newState === ConnectivityState.TRANSIENT_FAILURE || newState === ConnectivityState.TRANSIENT_FAILURE ||
newState === ConnectivityState.IDLE newState === ConnectivityState.IDLE
) { ) {
if (errorMessage) {
this.lastError = errorMessage;
}
this.channelControlHelper.requestReresolution(); this.channelControlHelper.requestReresolution();
subchannel.startConnecting(); subchannel.startConnecting();
} }
@ -157,7 +163,7 @@ export class RoundRobinLoadBalancer implements LoadBalancer {
) { ) {
this.updateState( this.updateState(
ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.TRANSIENT_FAILURE,
new UnavailablePicker() new UnavailablePicker({details: `No connection established. Last error: ${this.lastError}`})
); );
} else { } else {
this.updateState(ConnectivityState.IDLE, new QueuePicker(this)); this.updateState(ConnectivityState.IDLE, new QueuePicker(this));

View File

@ -97,16 +97,13 @@ export interface Picker {
*/ */
export class UnavailablePicker implements Picker { export class UnavailablePicker implements Picker {
private status: StatusObject; private status: StatusObject;
constructor(status?: StatusObject) { constructor(status?: Partial<StatusObject>) {
if (status !== undefined) { this.status = {
this.status = status; code: Status.UNAVAILABLE,
} else { details: 'No connection established',
this.status = { metadata: new Metadata(),
code: Status.UNAVAILABLE, ...status,
details: 'No connection established', };
metadata: new Metadata(),
};
}
} }
pick(pickArgs: PickArgs): TransientFailurePickResult { pick(pickArgs: PickArgs): TransientFailurePickResult {
return { return {

View File

@ -23,7 +23,8 @@ export type ConnectivityStateListener = (
subchannel: SubchannelInterface, subchannel: SubchannelInterface,
previousState: ConnectivityState, previousState: ConnectivityState,
newState: ConnectivityState, newState: ConnectivityState,
keepaliveTime: number keepaliveTime: number,
errorMessage?: string
) => void; ) => void;
/** /**

View File

@ -250,7 +250,8 @@ export class Subchannel {
error => { error => {
this.transitionToState( this.transitionToState(
[ConnectivityState.CONNECTING], [ConnectivityState.CONNECTING],
ConnectivityState.TRANSIENT_FAILURE ConnectivityState.TRANSIENT_FAILURE,
`${error}`
); );
} }
); );
@ -265,7 +266,8 @@ export class Subchannel {
*/ */
private transitionToState( private transitionToState(
oldStates: ConnectivityState[], oldStates: ConnectivityState[],
newState: ConnectivityState newState: ConnectivityState,
errorMessage?: string
): boolean { ): boolean {
if (oldStates.indexOf(this.connectivityState) === -1) { if (oldStates.indexOf(this.connectivityState) === -1) {
return false; return false;
@ -318,7 +320,7 @@ export class Subchannel {
throw new Error(`Invalid state: unknown ConnectivityState ${newState}`); throw new Error(`Invalid state: unknown ConnectivityState ${newState}`);
} }
for (const listener of this.stateListeners) { for (const listener of this.stateListeners) {
listener(this, previousState, newState, this.keepaliveTime); listener(this, previousState, newState, this.keepaliveTime, errorMessage);
} }
return true; return true;
} }

View File

@ -741,6 +741,7 @@ export class Http2SubchannelConnector implements SubchannelConnector {
connectionOptions connectionOptions
); );
this.session = session; this.session = session;
let errorMessage = 'Failed to connect';
session.unref(); session.unref();
session.once('connect', () => { session.once('connect', () => {
session.removeAllListeners(); session.removeAllListeners();
@ -749,10 +750,14 @@ export class Http2SubchannelConnector implements SubchannelConnector {
}); });
session.once('close', () => { session.once('close', () => {
this.session = null; this.session = null;
reject(); // Leave time for error event to happen before rejecting
setImmediate(() => {
reject(`${errorMessage} (${new Date().toISOString()})`);
});
}); });
session.once('error', error => { session.once('error', error => {
this.trace('connection failed with error ' + (error as Error).message); errorMessage = (error as Error).message;
this.trace('connection failed with error ' + errorMessage);
}); });
}); });
} }