timostamm / protobuf-ts

Protobuf and RPC for TypeScript
Apache License 2.0
1.1k stars 129 forks source link

Can't retry request after awaiting response #635

Closed memoalv closed 8 months ago

memoalv commented 8 months ago

I'm trying to retry a request if I detect an error in the response. The server returns this specific error as a successful response with a specific error code inside the message. I know its not ideal but its what we have now.

This is the code I have now:

export default (): RpcInterceptor => ({
  interceptUnary(
    next: NextUnaryFn,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    method: MethodInfo<any, any>,
    input: object,
    options: RpcOptions,
  ): UnaryCall {
    let result = next(method, input, options);

    let defHeader = new Deferred<RpcMetadata>();
    let defMessage = new Deferred<object>();
    let defStatus = new Deferred<RpcStatus>();
    let defTrailer = new Deferred<RpcMetadata>();

    void (async () => {
      try {
        await result;

        defHeader.resolve(result.headers);
        defMessage.resolve(result.response);
        defStatus.resolve(result.status);
        defTrailer.resolve(result.trailers);

        const response = await defMessage.promise;
        if (isAuthError(response)) {
          console.warn(`[${method.name}] auth error, unauthenticating & retrying...`);

          // manually reset the auth status of the call
          result = next(
            method,
            {
              ...input,
              authStatus: {},
            },
            options,
          );

          await result;

          defHeader = new Deferred<RpcMetadata>();
          defMessage = new Deferred<object>();
          defStatus = new Deferred<RpcStatus>();
          defTrailer = new Deferred<RpcMetadata>();

          defHeader.resolve(result.headers);
          defMessage.resolve(result.response);
          defStatus.resolve(result.status);
          defTrailer.resolve(result.trailers);
        }
      } catch (err) {
        console.log(err);
        defHeader.rejectPending(err);
        defMessage.rejectPending(err);
        defStatus.rejectPending(err);
        defTrailer.rejectPending(err);
      }
    })();

    return new UnaryCall(
      method,
      options.meta ?? {},
      input,
      defHeader.promise,
      defMessage.promise,
      defStatus.promise,
      defTrailer.promise,
    );
  },
});

I can definitely detect the error in the response and trigger the second RPC call but its response is ignored. It always returns whatever the first RPC call returns.

I've been going through other issues where interceptor examples are posted (that's how I've gotten this far) but still can't do what I need. Any help is appreciated.

jcready commented 8 months ago

The first problem is that you shouldn't reassign the def[Whatever] variables since we're already returning references to their initial values (which is why you're seeing it only ever returning the first response). The second problem is that you're not throwing an error if the second attempt fails for the same isAuthError(response) logic. Here's an attempt to fix your interceptor:

export default (): RpcInterceptor => ({
  interceptUnary(
    next: NextUnaryFn,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    method: MethodInfo<any, any>,
    input: object,
    options: RpcOptions,
  ): UnaryCall {
    let defHeader = new Deferred<RpcMetadata>();
    let defMessage = new Deferred<object>();
    let defStatus = new Deferred<RpcStatus>();
    let defTrailer = new Deferred<RpcMetadata>();

    void (async () => {
      try {
        const first_try = await next(method, input, options);

        // If the response doesn't have an auth error then resolve and exit.
        if (!isAuthError(first_try.response)) {
          defHeader.resolve(first_try.headers);
          defMessage.resolve(first_try.response);
          defStatus.resolve(first_try.status);
          defTrailer.resolve(first_try.trailers);
          return;
        }

        // Otherwise try again with a reset auth status
        console.warn(`[${method.name}] auth error, unauthenticating & retrying...`);

        const second_try = await next(
          method,
          {
            ...input,
            authStatus: {},
          },
          options,
        );

        // If the second attempt's response also has an auth error then throw a custom RpcError.
        if (isAuthError(second_try.response)) {
          const error = new RpcError(
            'The request does not have valid authentication credentials for the operation',
            'UNAUTHENTICATED',
            {...second_try.headers, ...second_try.trailers}
          );
          error.methodName = method.name;
          error.serviceName = method.service.typeName;
          throw error;
        }

        defHeader.resolve(second_try.headers);
        defMessage.resolve(second_try.response);
        defStatus.resolve(second_try.status);
        defTrailer.resolve(second_try.trailers);
      } catch (err) {
        defHeader.rejectPending(err);
        defMessage.rejectPending(err);
        defStatus.rejectPending(err);
        defTrailer.rejectPending(err);
      }
    })();

    return new UnaryCall(
      method,
      options.meta ?? {},
      input,
      defHeader.promise,
      defMessage.promise,
      defStatus.promise,
      defTrailer.promise,
    );
  },
});
memoalv commented 8 months ago

Worked like a charm. Thank you very much for the pointers. I couldn't wrap my head around the def[Whatever] variables.