DRY gRPC tests, refactor error mapping

This commit is contained in:
Sergey Skrobotov
2023-09-08 16:08:59 -07:00
parent 29ca544c95
commit 977243ebfd
18 changed files with 637 additions and 525 deletions

View File

@@ -120,6 +120,7 @@ import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor;
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
import org.whispersystems.textsecuregcm.grpc.GrpcServerManagedWrapper;
import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.KeysGrpcService;
@@ -644,8 +645,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new BasicCredentialAuthenticationInterceptor(new BaseAccountAuthenticator(accountsManager));
final ServerBuilder<?> grpcServer = ServerBuilder.forPort(config.getGrpcPort())
// TODO: specialize metrics with user-agent platform
.intercept(new MetricCollectingServerInterceptor(Metrics.globalRegistry))
.addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor))
.addService(new KeysAnonymousGrpcService(accountsManager, keys))
.addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
@@ -657,13 +656,16 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.addFilter("RemoteDeprecationFilter", remoteDeprecationFilter)
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
grpcServer.intercept(new AcceptLanguageInterceptor());
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
// http://grpc.github.io/grpc-java/javadoc/io/grpc/ServerBuilder.html#intercept-io.grpc.ServerInterceptor-
grpcServer.intercept(remoteDeprecationFilter);
grpcServer.intercept(new UserAgentInterceptor());
grpcServer
// TODO: specialize metrics with user-agent platform
.intercept(new MetricCollectingServerInterceptor(Metrics.globalRegistry))
.intercept(new ErrorMappingInterceptor())
.intercept(new AcceptLanguageInterceptor())
.intercept(remoteDeprecationFilter)
.intercept(new UserAgentInterceptor());
environment.lifecycle().manage(new GrpcServerManagedWrapper(grpcServer.build()));

View File

@@ -1,14 +1,30 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import io.grpc.Metadata;
import io.grpc.Status;
import java.time.Duration;
import java.util.Optional;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.grpc.ConvertibleToGrpcStatus;
public class RateLimitExceededException extends Exception {
public class RateLimitExceededException extends Exception implements ConvertibleToGrpcStatus {
public static final Metadata.Key<Duration> RETRY_AFTER_DURATION_KEY =
Metadata.Key.of("retry-after", new Metadata.AsciiMarshaller<>() {
@Override
public String toAsciiString(final Duration value) {
return value.toString();
}
@Override
public Duration parseAsciiString(final String serialized) {
return Duration.parse(serialized);
}
});
@Nullable
private final Duration retryDuration;
@@ -33,4 +49,19 @@ public class RateLimitExceededException extends Exception {
public boolean isLegacy() {
return legacy;
}
@Override
public Status grpcStatus() {
return Status.RESOURCE_EXHAUSTED;
}
@Override
public Optional<Metadata> grpcMetadata() {
return getRetryDuration()
.map(duration -> {
final Metadata metadata = new Metadata();
metadata.put(RETRY_AFTER_DURATION_KEY, duration);
return metadata;
});
}
}

View File

@@ -24,11 +24,6 @@ public class CallingGrpcService extends ReactorCallingGrpc.CallingImplBase {
this.rateLimiters = rateLimiters;
}
@Override
protected Throwable onErrorMap(final Throwable throwable) {
return RateLimitUtil.mapRateLimitExceededException(throwable);
}
@Override
public Mono<GetTurnCredentialsResponse> getTurnCredentials(final GetTurnCredentialsRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Metadata;
import io.grpc.Status;
import java.util.Optional;
/**
* Interface to be imlemented by our custom exceptions that are consistently mapped to a gRPC status.
*/
public interface ConvertibleToGrpcStatus {
Status grpcStatus();
Optional<Metadata> grpcMetadata();
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
/**
* This interceptor observes responses from the service and if the response status is {@link Status#UNKNOWN}
* and there is a non-null cause which is an instance of {@link ConvertibleToGrpcStatus},
* then status and metadata to be returned to the client is resolved from that object.
* </p>
* This eliminates the need of having each service to override {@code `onErrorMap()`} method for commonly used exceptions.
*/
public class ErrorMappingInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
final ServerCall<ReqT, RespT> call,
final Metadata headers,
final ServerCallHandler<ReqT, RespT> next) {
return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void close(final Status status, final Metadata trailers) {
// The idea is to only apply the automatic conversion logic in the cases
// when there was no explicit decision by the service to provide a status.
// I.e. if at this point we see anything but the `UNKNOWN`,
// that means that some logic in the service made this decision already
// and automatic conversion may conflict with it.
if (status.getCode().equals(Status.Code.UNKNOWN)
&& status.getCause() instanceof ConvertibleToGrpcStatus convertibleToGrpcStatus) {
super.close(
convertibleToGrpcStatus.grpcStatus(),
convertibleToGrpcStatus.grpcMetadata().orElseGet(Metadata::new)
);
} else {
super.close(status, trailers);
}
}
}, headers);
}
}

View File

@@ -74,11 +74,6 @@ public class KeysGrpcService extends ReactorKeysGrpc.KeysImplBase {
this.rateLimiters = rateLimiters;
}
@Override
protected Throwable onErrorMap(final Throwable throwable) {
return RateLimitUtil.mapRateLimitExceededException(throwable);
}
@Override
public Mono<GetPreKeyCountResponse> getPreKeyCount(final GetPreKeyCountRequest request) {
return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)

View File

@@ -100,11 +100,6 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
this.bucket = bucket;
}
@Override
protected Throwable onErrorMap(final Throwable throwable) {
return RateLimitUtil.mapRateLimitExceededException(throwable);
}
@Override
public Mono<SetProfileResponse> setProfile(final SetProfileRequest request) {
validateRequest(request);

View File

@@ -1,45 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Metadata;
import io.grpc.Status;
import io.grpc.StatusException;
import java.time.Duration;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
public class RateLimitUtil {
public static final Metadata.Key<Duration> RETRY_AFTER_DURATION_KEY =
Metadata.Key.of("retry-after", new Metadata.AsciiMarshaller<>() {
@Override
public String toAsciiString(final Duration value) {
return value.toString();
}
@Override
public Duration parseAsciiString(final String serialized) {
return Duration.parse(serialized);
}
});
public static Throwable mapRateLimitExceededException(final Throwable throwable) {
if (throwable instanceof RateLimitExceededException rateLimitExceededException) {
@Nullable final Metadata trailers = rateLimitExceededException.getRetryDuration()
.map(duration -> {
final Metadata metadata = new Metadata();
metadata.put(RETRY_AFTER_DURATION_KEY, duration);
return metadata;
}).orElse(null);
return new StatusException(Status.RESOURCE_EXHAUSTED, trailers);
}
return throwable;
}
}