Use enriched gRPC status errors

This commit is contained in:
Ravi Khadiwala
2025-12-22 11:56:35 -06:00
committed by ravi-signal
parent 77eaec0150
commit a1b1d051f5
29 changed files with 989 additions and 318 deletions

View File

@@ -6,8 +6,8 @@
package org.whispersystems.textsecuregcm.auth.grpc;
import io.grpc.Context;
import io.grpc.Status;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.storage.Device;
/**
@@ -18,13 +18,11 @@ public class AuthenticationUtil {
static final Context.Key<AuthenticatedDevice> CONTEXT_AUTHENTICATED_DEVICE = Context.key("authenticated-device");
/**
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
* no authenticated account/device is available.
* Returns the account/device authenticated in the current gRPC context. Should only be called from a service run with
* the {@link RequireAuthenticationInterceptor}.
*
* @return the account/device identifier authenticated in the current gRPC context
*
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
* could be retrieved from the current gRPC context
* @throws IllegalStateException if no authenticated account/device could be retrieved from the current gRPC context
*/
public static AuthenticatedDevice requireAuthenticatedDevice() {
@Nullable final AuthenticatedDevice authenticatedDevice = CONTEXT_AUTHENTICATED_DEVICE.get();
@@ -33,27 +31,25 @@ public class AuthenticationUtil {
return authenticatedDevice;
}
throw Status.UNAUTHENTICATED.asRuntimeException();
throw new IllegalStateException(
"Configuration issue: service expects an authenticated device, but none was found. Request should have failed from an interceptor");
}
/**
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
* no authenticated account/device is available or "permission denied" if the authenticated device is not the primary
* device for the account.
* Returns the account/device authenticated in the current gRPC context or "invalid argument" if the authenticated
* device is not the primary device for the account.
*
* @return the account/device identifier authenticated in the current gRPC context
*
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
* could be retrieved from the current gRPC context or a status of {@code PERMISSION_DENIED} if the authenticated
* device is not the primary device for the authenticated account
* @throws io.grpc.StatusRuntimeException with a status of {@code INVALID_ARGUMENT} if the authenticated device is not
* the primary device for the authenticated account
* @throws IllegalStateException if no authenticated account/device could be retrieved from the current gRPC
* context
*/
public static AuthenticatedDevice requireAuthenticatedPrimaryDevice() {
final AuthenticatedDevice authenticatedDevice = requireAuthenticatedDevice();
if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
throw Status.PERMISSION_DENIED.asRuntimeException();
throw GrpcExceptions.badAuthentication("RPC requires a primary device");
}
return authenticatedDevice;
}
}

View File

@@ -9,6 +9,8 @@ import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
/**
* A "prohibit authentication" interceptor ensures that requests to endpoints that should be invoked anonymously do not
@@ -22,8 +24,8 @@ public class ProhibitAuthenticationInterceptor implements ServerInterceptor {
final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
final String authHeaderString = headers.get(Metadata.Key.of(RequireAuthenticationInterceptor.AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
if (authHeaderString != null) {
call.close(Status.UNAUTHENTICATED.withDescription("authorization header forbidden"), new Metadata());
return new ServerCall.Listener<>() {};
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.badAuthentication("The service forbids requests with an authentication header"));
}
return next.startCall(call, headers);
}

View File

@@ -14,6 +14,7 @@ import io.grpc.ServerInterceptor;
import io.grpc.Status;
import java.util.Optional;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
@@ -40,21 +41,21 @@ public class RequireAuthenticationInterceptor implements ServerInterceptor {
Metadata.Key.of(AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
if (authHeaderString == null) {
return ServerInterceptorUtil.closeWithStatus(call,
Status.UNAUTHENTICATED.withDescription("missing authorization header"));
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.invalidCredentials("missing authorization header"));
}
final Optional<BasicCredentials> basicCredentials = HeaderUtils.basicCredentialsFromAuthHeader(authHeaderString);
if (basicCredentials.isEmpty()) {
return ServerInterceptorUtil.closeWithStatus(call,
Status.UNAUTHENTICATED.withDescription("malformed authorization header"));
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.invalidCredentials("malformed authorization header"));
}
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> authenticated =
authenticator.authenticate(basicCredentials.get());
if (authenticated.isEmpty()) {
return ServerInterceptorUtil.closeWithStatus(call,
Status.UNAUTHENTICATED.withDescription("invalid credentials"));
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.invalidCredentials("invalid credentials"));
}
final AuthenticatedDevice authenticatedDevice = new AuthenticatedDevice(

View File

@@ -4,28 +4,15 @@
*/
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 io.grpc.StatusRuntimeException;
import org.whispersystems.textsecuregcm.grpc.ConvertibleToGrpcStatus;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
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;
@@ -44,17 +31,7 @@ public class RateLimitExceededException extends Exception implements Convertible
}
@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;
});
public StatusRuntimeException toStatusRuntimeException() {
return GrpcExceptions.rateLimitExceeded(retryDuration);
}
}

View File

@@ -27,8 +27,9 @@ import java.util.Set;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.grpc.RequestAttributesUtil;
import org.whispersystems.textsecuregcm.grpc.StatusConstants;
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
@@ -91,8 +92,7 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
}).orElse(null);
if (shouldBlock(userAgent)) {
call.close(StatusConstants.UPGRADE_NEEDED_STATUS, new Metadata());
return new ServerCall.Listener<>() {};
return ServerInterceptorUtil.closeWithStatusException(call, GrpcExceptions.upgradeRequired());
} else {
return next.startCall(call, headers);
}

View File

@@ -6,7 +6,6 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import org.signal.chat.account.CheckAccountExistenceRequest;
import org.signal.chat.account.CheckAccountExistenceResponse;
import org.signal.chat.account.LookupUsernameHashRequest;
@@ -14,6 +13,7 @@ import org.signal.chat.account.LookupUsernameHashResponse;
import org.signal.chat.account.LookupUsernameLinkRequest;
import org.signal.chat.account.LookupUsernameLinkResponse;
import org.signal.chat.account.ReactorAccountsAnonymousGrpc;
import org.signal.chat.errors.NotFound;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
@@ -51,18 +51,18 @@ public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.A
@Override
public Mono<LookupUsernameHashResponse> lookupUsernameHash(final LookupUsernameHashRequest request) {
if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("Illegal username hash length; expected %d bytes, but got %d bytes",
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hash",
String.format("Illegal username hash length; expected %d bytes, but got %d bytes",
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()));
}
return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLookupLimiter())
.then(Mono.fromFuture(() -> accountsManager.getByUsernameHash(request.getUsernameHash().toByteArray())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.NOT_FOUND::asRuntimeException))
.map(account -> LookupUsernameHashResponse.newBuilder()
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.build());
.map(maybeAccount -> maybeAccount
.map(account -> LookupUsernameHashResponse.newBuilder()
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.build())
.orElseGet(() -> LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build()));
}
@Override
@@ -72,19 +72,16 @@ public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.A
try {
linkHandle = UUIDUtil.fromByteString(request.getUsernameLinkHandle());
} catch (final IllegalArgumentException e) {
throw Status.INVALID_ARGUMENT
.withDescription("Could not interpret link handle as UUID")
.withCause(e)
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_link_handle", "Could not interpret link handle as UUID");
}
return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLinkLookupLimiter())
.then(Mono.fromFuture(() -> accountsManager.getByUsernameLinkHandle(linkHandle)))
.map(maybeAccount -> maybeAccount
.flatMap(Account::getEncryptedUsername)
.orElseThrow(Status.NOT_FOUND::asRuntimeException))
.map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
.build());
.map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
.build())
.orElseGet(() -> LookupUsernameLinkResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build()));
}
}

View File

@@ -6,7 +6,6 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
@@ -26,8 +25,6 @@ import org.signal.chat.account.DeleteUsernameLinkResponse;
import org.signal.chat.account.GetAccountIdentityRequest;
import org.signal.chat.account.GetAccountIdentityResponse;
import org.signal.chat.account.ReactorAccountsGrpc;
import org.signal.chat.account.ReserveUsernameHashError;
import org.signal.chat.account.ReserveUsernameHashErrorType;
import org.signal.chat.account.ReserveUsernameHashRequest;
import org.signal.chat.account.ReserveUsernameHashResponse;
import org.signal.chat.account.SetDiscoverableByPhoneNumberRequest;
@@ -38,7 +35,9 @@ import org.signal.chat.account.SetRegistrationRecoveryPasswordRequest;
import org.signal.chat.account.SetRegistrationRecoveryPasswordResponse;
import org.signal.chat.account.SetUsernameLinkRequest;
import org.signal.chat.account.SetUsernameLinkResponse;
import org.signal.chat.account.UsernameNotAvailable;
import org.signal.chat.common.AccountIdentifiers;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
@@ -50,6 +49,7 @@ import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
@@ -78,10 +78,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@Override
public Mono<GetAccountIdentityResponse> getAccountIdentity(final GetAccountIdentityRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount()
.map(account -> {
final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder()
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
@@ -99,10 +96,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@Override
public Mono<DeleteAccountResponse> deleteAccount(final DeleteAccountRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.delete(account, AccountsManager.DeletionReason.USER_REQUEST)))
.thenReturn(DeleteAccountResponse.newBuilder().build());
}
@@ -112,11 +106,10 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
if (request.getRegistrationLock().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Registration lock secret must not be empty").asRuntimeException();
throw GrpcExceptions.fieldViolation("registration_lock", "Registration lock secret must not be empty");
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(authenticatedDevice)
.flatMap(account -> {
// In the previous REST-based API, clients would send hex strings directly. For backward compatibility, we
// convert the registration lock secret to a lowercase hex string before turning it into a salted hash.
@@ -131,10 +124,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@Override
public Mono<ClearRegistrationLockResponse> clearRegistrationLock(final ClearRegistrationLockRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
a -> a.setRegistrationLock(null, null))))
.map(ignored -> ClearRegistrationLockResponse.newBuilder().build());
@@ -145,42 +135,34 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (request.getUsernameHashesCount() == 0) {
throw Status.INVALID_ARGUMENT
.withDescription("List of username hashes must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hashes", "List of username hashes must not be empty");
}
if (request.getUsernameHashesCount() > AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("List of username hashes may have at most %d elements, but actually had %d",
AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH, request.getUsernameHashesCount()))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hashes",
String.format("List of username hashes may have at most %d elements, but actually had %d",
AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH, request.getUsernameHashesCount()));
}
final List<byte[]> usernameHashes = new ArrayList<>(request.getUsernameHashesCount());
for (final ByteString usernameHash : request.getUsernameHashesList()) {
if (usernameHash.size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("Username hash length must be %d bytes, but was actually %d",
AccountController.USERNAME_HASH_LENGTH, usernameHash.size()))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hashes",
String.format("Username hash length must be %d bytes, but was actually %d",
AccountController.USERNAME_HASH_LENGTH, usernameHash.size()));
}
usernameHashes.add(usernameHash.toByteArray());
}
return rateLimiters.getUsernameReserveLimiter().validateReactive(authenticatedDevice.accountIdentifier())
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.then(getAccount())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.reserveUsernameHash(account, usernameHashes)))
.map(reservation -> ReserveUsernameHashResponse.newBuilder()
.setUsernameHash(ByteString.copyFrom(reservation.reservedUsernameHash()))
.build())
.onErrorReturn(UsernameHashNotAvailableException.class, ReserveUsernameHashResponse.newBuilder()
.setError(ReserveUsernameHashError.newBuilder()
.setErrorType(ReserveUsernameHashErrorType.RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE)
.build())
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
.build());
}
@@ -189,61 +171,57 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (request.getUsernameHash().isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Username hash must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hash", "Username hash must not be empty");
}
if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("Username hash length must be %d bytes, but was actually %d",
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hash",
String.format("Username hash length must be %d bytes, but was actually %d",
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()));
}
if (request.getZkProof().isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Zero-knowledge proof must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("zk_proof", "Zero-knowledge proof must not be empty");
}
if (request.getUsernameCiphertext().isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Username ciphertext must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_ciphertext", "Username ciphertext must not be empty");
}
if (request.getUsernameCiphertext().size() > AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("Username hash length must at most %d bytes, but was actually %d",
AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH, request.getUsernameCiphertext().size()))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_ciphertext",
String.format("Username ciphertext length must at most %d bytes, but was actually %d",
AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH, request.getUsernameCiphertext().size()));
}
try {
usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray());
} catch (final BaseUsernameException e) {
throw Status.INVALID_ARGUMENT.withDescription("Could not verify proof").asRuntimeException();
throw GrpcExceptions.constraintViolation("Could not verify proof");
}
return rateLimiters.getUsernameSetLimiter().validateReactive(authenticatedDevice.accountIdentifier())
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.then(getAccount())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.confirmReservedUsernameHash(account, request.getUsernameHash().toByteArray(), request.getUsernameCiphertext().toByteArray())))
.map(updatedAccount -> ConfirmUsernameHashResponse.newBuilder()
.setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow()))
.setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle()))
.setConfirmedUsernameHash(ConfirmUsernameHashResponse.ConfirmedUsernameHash.newBuilder()
.setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow()))
.setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle()))
.build())
.build())
.onErrorMap(UsernameReservationNotFoundException.class, throwable -> Status.FAILED_PRECONDITION.asRuntimeException())
.onErrorMap(UsernameHashNotAvailableException.class, throwable -> Status.NOT_FOUND.asRuntimeException());
.onErrorResume(UsernameReservationNotFoundException.class, _ -> Mono.just(ConfirmUsernameHashResponse
.newBuilder()
.setReservationNotFound(FailedPrecondition.getDefaultInstance())
.build()))
.onErrorResume(UsernameHashNotAvailableException.class, _ -> Mono.just(ConfirmUsernameHashResponse
.newBuilder()
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
.build()));
}
@Override
public Mono<DeleteUsernameHashResponse> deleteUsernameHash(final DeleteUsernameHashRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> accountsManager.clearUsernameHash(account)))
.thenReturn(DeleteUsernameHashResponse.newBuilder().build());
}
@@ -253,19 +231,16 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (request.getUsernameCiphertext().isEmpty() || request.getUsernameCiphertext().size() > EncryptedUsername.MAX_SIZE) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("Username ciphertext must not be empty and must be shorter than %d bytes", EncryptedUsername.MAX_SIZE))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_ciphertext",
String.format("Username ciphertext must not be empty and must be shorter than %d bytes", EncryptedUsername.MAX_SIZE));
}
return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier())
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.then(getAccount())
.flatMap(account -> {
final SetUsernameLinkResponse.Builder responseBuilder = SetUsernameLinkResponse.newBuilder();
if (account.getUsernameHash().isEmpty()) {
return Mono.error(Status.FAILED_PRECONDITION
.withDescription("Account does not have a username hash")
.asRuntimeException());
return Mono.just(responseBuilder.setNoUsernameSet(FailedPrecondition.getDefaultInstance()).build());
}
final UUID linkHandle;
@@ -276,37 +251,28 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
}
return Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(linkHandle, request.getUsernameCiphertext().toByteArray())))
.thenReturn(linkHandle);
})
.map(linkHandle -> SetUsernameLinkResponse.newBuilder()
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
.build());
.thenReturn(responseBuilder.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle)).build());
});
}
@Override
public Mono<DeleteUsernameLinkResponse> deleteUsernameLink(final DeleteUsernameLinkRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier())
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.then(getAccount())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(null, null))))
.thenReturn(DeleteUsernameLinkResponse.newBuilder().build());
}
@Override
public Mono<ConfigureUnidentifiedAccessResponse> configureUnidentifiedAccess(final ConfigureUnidentifiedAccessRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (!request.getAllowUnrestrictedUnidentifiedAccess() && request.getUnidentifiedAccessKey().size() != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("Unidentified access key must be %d bytes, but was actually %d",
UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size()))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("unidentified_access_key",
String.format("Unidentified access key must be %d bytes, but was actually %d",
UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size()));
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> {
a.setUnrestrictedUnidentifiedAccess(request.getAllowUnrestrictedUnidentifiedAccess());
a.setUnidentifiedAccessKey(request.getAllowUnrestrictedUnidentifiedAccess() ? null : request.getUnidentifiedAccessKey().toByteArray());
@@ -316,10 +282,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@Override
public Mono<SetDiscoverableByPhoneNumberResponse> setDiscoverableByPhoneNumber(final SetDiscoverableByPhoneNumberRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
a -> a.setDiscoverableByPhoneNumber(request.getDiscoverableByPhoneNumber()))))
.thenReturn(SetDiscoverableByPhoneNumberResponse.newBuilder().build());
@@ -327,17 +290,22 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@Override
public Mono<SetRegistrationRecoveryPasswordResponse> setRegistrationRecoveryPassword(final SetRegistrationRecoveryPasswordRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (request.getRegistrationRecoveryPassword().isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Registration recovery password must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("registration_recovery_password", "Registration recovery password must not be empty");
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> registrationRecoveryPasswordsManager.store(account.getIdentifier(IdentityType.PNI), request.getRegistrationRecoveryPassword().toByteArray())))
.thenReturn(SetRegistrationRecoveryPasswordResponse.newBuilder().build());
}
private Mono<Account> getAccount() {
return getAccount(AuthenticationUtil.requireAuthenticatedDevice());
}
private Mono<Account> getAccount(AuthenticatedDevice authenticatedDevice) {
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials")));
}
}

View File

@@ -7,14 +7,12 @@ package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Metadata;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.util.Optional;
/**
* Interface to be implemented by our custom exceptions that are consistently mapped to a gRPC status.
*/
public interface ConvertibleToGrpcStatus {
Status grpcStatus();
Optional<Metadata> grpcMetadata();
StatusRuntimeException toStatusRuntimeException();
}

View File

@@ -11,6 +11,11 @@ import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UncheckedIOException;
/**
* This interceptor observes responses from the service and if the response status is {@link Status#UNKNOWN}
@@ -21,6 +26,8 @@ import io.grpc.Status;
*/
public class ErrorMappingInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ErrorMappingInterceptor.class);
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
final ServerCall<ReqT, RespT> call,
@@ -34,15 +41,33 @@ public class ErrorMappingInterceptor implements ServerInterceptor {
// 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 {
if (!status.getCode().equals(Status.Code.UNKNOWN)) {
super.close(status, trailers);
return;
}
final StatusRuntimeException statusException = switch (status.getCause()) {
case ConvertibleToGrpcStatus e -> e.toStatusRuntimeException();
case UncheckedIOException e -> {
log.warn("RPC {} encountered UncheckedIOException", call.getMethodDescriptor().getFullMethodName(), e.getCause());
yield GrpcExceptions.unavailable(e.getCause().getMessage());
}
case IOException e -> {
log.warn("RPC {} encountered IOException", call.getMethodDescriptor().getFullMethodName(), e);
yield GrpcExceptions.unavailable(e.getMessage());
}
case null -> {
log.error("RPC {} finished with status UNKNOWN: {}",
call.getMethodDescriptor().getFullMethodName(), status.getDescription());
yield GrpcExceptions.unavailable(status.getDescription());
}
default -> {
log.error("RPC {} finished with status UNKNOWN",
call.getMethodDescriptor().getFullMethodName(), status.getCause());
yield GrpcExceptions.unavailable(status.getCause().getMessage());
}
};
super.close(statusException.getStatus(), statusException.getTrailers());
}
}, headers);
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.Any;
import com.google.rpc.BadRequest;
import com.google.rpc.ErrorInfo;
import com.google.rpc.RetryInfo;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
import java.time.Duration;
import javax.annotation.Nullable;
public class GrpcExceptions {
public static final String DOMAIN = "grpc.chat.signal.org";
private static final Any ERROR_INFO_CONSTRAINT_VIOLATED = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("CONSTRAINT_VIOLATED")
.build());
private static final Any ERROR_INFO_RESOURCE_EXHAUSTED = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("RESOURCE_EXHAUSTED")
.build());
private static final Any ERROR_INFO_INVALID_CREDENTIALS = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("INVALID_CREDENTIALS")
.build());
private static final Any ERROR_INFO_BAD_AUTHENTICATION = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("BAD_AUTHENTICATION")
.build());
private static final com.google.rpc.Status UPGRADE_REQUIRED = com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage("Upgrade required")
.addDetails(Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("UPGRADE_REQUIRED")
.build()))
.build();
private GrpcExceptions() {
}
/// The client version provided in the User-Agent is no longer supported. The client must upgrade to use the service.
///
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException upgradeRequired() {
return StatusProto.toStatusRuntimeException(UPGRADE_REQUIRED);
}
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
/// or out of date client. Additional information about the violating field will be included in the metadata.
///
/// @param fieldName The name of the field that violated a service constraint
/// @param message Additional context about the constraint violation
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException fieldViolation(final String fieldName, @Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
.addDetails(Any.pack(BadRequest.newBuilder()
.addFieldViolations(BadRequest.FieldViolation.newBuilder()
.setField(fieldName)
.setDescription(messageOrDefault(message, Status.Code.INVALID_ARGUMENT)))
.build()))
.build());
}
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
/// or out of date client.
///
/// @param message Additional context about the constraint violation
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException constraintViolation(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
.build());
}
/// The request has incorrectly set authentication credentials for the RPC. This represents a client bug where the
/// authorization header is not correct for the RPC. For example,
///
/// - The RPC was for an anonymous service, but included an Authentication header in the RPC metadata
/// - The RPC should only be made by the primary device, but the request had linked device credentials
///
/// @param message indicating why the credentials were set incorrectly
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException badAuthentication(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_BAD_AUTHENTICATION)
.build());
}
/// The account credentials provided in the authorization header are no longer valid.
///
/// @param message indicating why the credentials were invalid
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException invalidCredentials(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.UNAUTHENTICATED.value())
.setMessage(messageOrDefault(message, Status.Code.UNAUTHENTICATED))
.addDetails(ERROR_INFO_INVALID_CREDENTIALS)
.build());
}
/// A server-side resource was exhausted. The details field may include a RetryInfo message that includes the amount
/// of time in seconds the client should wait before retrying the request.
///
/// If a RetryInfo is present, the client must wait the indicated time before retrying the request. If absent, the
/// client should retry with an exponential backoff.
///
/// @param retryDuration If present, the duration the client should wait before retrying the request
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException rateLimitExceeded(@Nullable final Duration retryDuration) {
final com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()
.setCode(Status.Code.RESOURCE_EXHAUSTED.value())
.addDetails(ERROR_INFO_RESOURCE_EXHAUSTED);
if (retryDuration != null) {
builder.addDetails(Any.pack(RetryInfo.newBuilder()
.setRetryDelay(com.google.protobuf.Duration.newBuilder()
.setSeconds(retryDuration.getSeconds())
.setNanos(retryDuration.getNano()))
.build()));
}
return StatusProto.toStatusRuntimeException(builder.build());
}
/// There was an internal error processing the RPC. The client should retry the request with exponential backoff.
///
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException unavailable(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.UNAVAILABLE.value())
.setMessage(messageOrDefault(message, Status.Code.UNAVAILABLE))
.addDetails(Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("UNAVAILABLE")
.build()))
.build());
}
private static String messageOrDefault(@Nullable final String message, Status.Code code) {
return message == null ? code.toString() : message;
}
}

View File

@@ -6,26 +6,50 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.common.annotations.VisibleForTesting;
import io.grpc.*;
import com.google.protobuf.Descriptors;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.Message;
import com.google.rpc.ErrorInfo;
import io.grpc.ForwardingServerCall;
import io.grpc.ForwardingServerCallListener;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import io.grpc.protobuf.StatusProto;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
public class MetricServerInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(MetricServerInterceptor.class);
private static final String TAG_SERVICE_NAME = "grpcService";
private static final String TAG_METHOD_NAME = "method";
private static final String TAG_METHOD_TYPE = "methodType";
private static final String TAG_STATUS_CODE = "statusCode";
private static final String TAG_REASON = "reason";
@VisibleForTesting
static final String DEFAULT_SUCCESS_REASON = "success";
@VisibleForTesting
static final String DEFAULT_ERROR_REASON = "n/a";
@VisibleForTesting
static final String REQUEST_MESSAGE_COUNTER_NAME = MetricsUtil.name(MetricServerInterceptor.class, "requestMessage");
@@ -77,6 +101,7 @@ public class MetricServerInterceptor implements ServerInterceptor {
private final Counter responseMessageCounter;
private final Tags tags;
private @Nullable String reason = null;
MetricServerCall(final ServerCall<ReqT, RespT> delegate, final Tags tags) {
super(delegate);
@@ -86,15 +111,59 @@ public class MetricServerInterceptor implements ServerInterceptor {
@Override
public void close(final Status status, final Metadata responseHeaders) {
meterRegistry.counter(RPC_COUNTER_NAME, tags.and(TAG_STATUS_CODE, status.getCode().name())).increment();
if (!status.isOk()) {
reason = errorInfo(StatusProto.fromStatusAndTrailers(status, responseHeaders))
.map(ErrorInfo::getReason)
.orElse(DEFAULT_ERROR_REASON);
}
Tags responseTags = tags.and(Tag.of(TAG_STATUS_CODE, status.getCode().name()));
if (reason != null) {
responseTags = responseTags.and(TAG_REASON, reason);
}
meterRegistry.counter(RPC_COUNTER_NAME, responseTags).increment();
super.close(status, responseHeaders);
}
@Override
public void sendMessage(final RespT responseMessage) {
this.responseMessageCounter.increment();
// Extract the annotated reason (if any) from the message
final String messageReason = MetricServerCall.reason(responseMessage);
// If there are multiple messages sent on this RPC (server-side streaming), just use the most recent reason
this.reason = messageReason == null ? DEFAULT_SUCCESS_REASON : messageReason;
super.sendMessage(responseMessage);
}
@Nullable
private static String reason(final Object obj) {
if (!(obj instanceof Message msg)) {
return null;
}
// iterate through all fields on the message
for (Map.Entry<Descriptors.FieldDescriptor, Object> field : msg.getAllFields().entrySet()) {
// iterate through all options on the field
for (Map.Entry<Descriptors.FieldDescriptor, Object> option : field.getKey().getOptions().getAllFields().entrySet()) {
if (option.getKey().getFullName().equals("org.signal.chat.tag.reason")) {
if (!(option.getValue() instanceof String s)) {
log.error("Invalid value for option tag.reason {}", option.getValue());
continue;
}
// return the first tag we see
return s;
}
}
// No reason on this field. Recursively check subfields of this field for a reason
final String subReason = reason(field.getValue());
if (subReason != null) {
return subReason;
}
}
// No field or subfield contained an annotated reason
return null;
}
}
/**
@@ -131,4 +200,17 @@ public class MetricServerInterceptor implements ServerInterceptor {
super.onCancel();
}
}
private static Optional<ErrorInfo> errorInfo(final com.google.rpc.Status statusProto) {
return statusProto.getDetailsList().stream()
.filter(any -> any.is(ErrorInfo.class))
.map(errorInfo -> {
try {
return errorInfo.unpack(ErrorInfo.class);
} catch (final InvalidProtocolBufferException e) {
throw new UncheckedIOException(e);
}
})
.findFirst();
}
}

View File

@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
public class ServerInterceptorUtil {
@@ -36,4 +37,23 @@ public class ServerInterceptorUtil {
//noinspection unchecked
return NO_OP_LISTENER;
}
/**
* Closes the given server call with the status and metadata from the provided exception, returning a no-op listener.
*
* @param call the server call to close
* @param exception the {@link StatusRuntimeException} with which to close the call
*
* @return a no-op server call listener
*
* @param <ReqT> the type of request object handled by the server call
* @param <RespT> the type of response object returned by the server call
*/
public static <ReqT, RespT> ServerCall.Listener<ReqT> closeWithStatusException(final ServerCall<ReqT, RespT> call, final StatusRuntimeException exception) {
call.close(exception.getStatus(), exception.getTrailers());
//noinspection unchecked
return NO_OP_LISTENER;
}
}

View File

@@ -1,12 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Status;
public abstract class StatusConstants {
public static final Status UPGRADE_NEEDED_STATUS = Status.INVALID_ARGUMENT.withDescription("signal-upgrade-required");
}