diff --git a/grpc-protocol/src/main/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClient.java b/grpc-protocol/src/main/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClient.java
index a9267728fcc..74eae7c80bd 100644
--- a/grpc-protocol/src/main/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClient.java
+++ b/grpc-protocol/src/main/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClient.java
@@ -17,6 +17,7 @@
package com.linecorp.armeria.client.grpc.protocol;
import java.nio.charset.StandardCharsets;
+import java.util.Base64;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -161,7 +162,14 @@ private static void checkGrpcStatus(@Nullable String grpcStatus, HttpHeaders hea
if (grpcMessage != null) {
grpcMessage = StatusMessageEscaper.unescape(grpcMessage);
}
- throw new ArmeriaStatusException(Integer.parseInt(grpcStatus), grpcMessage);
+ final String grpcDetails = headers.get(GrpcHeaderNames.GRPC_STATUS_DETAILS_BIN);
+ final byte[] details;
+ if (grpcDetails != null) {
+ details = Base64.getDecoder().decode(grpcDetails);
+ } else {
+ details = null;
+ }
+ throw new ArmeriaStatusException(Integer.parseInt(grpcStatus), grpcMessage, details);
}
}
diff --git a/grpc-protocol/src/main/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaStatusException.java b/grpc-protocol/src/main/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaStatusException.java
index b13f0e99b13..457e7c9fdbd 100644
--- a/grpc-protocol/src/main/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaStatusException.java
+++ b/grpc-protocol/src/main/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaStatusException.java
@@ -29,20 +29,44 @@ public final class ArmeriaStatusException extends RuntimeException {
private final int code;
+ @Nullable
+ private final byte[] grpcStatusDetailsBin;
+
/**
* Constructs an {@link ArmeriaStatusException} for the given gRPC status code and message.
*/
public ArmeriaStatusException(int code, @Nullable String message) {
- super(message);
- this.code = code;
+ this(code, message, null, null);
+ }
+
+ /**
+ * Constructs an {@link ArmeriaStatusException} for the given gRPC status code, message
+ * and grpcStatusDetailsBin. {@code grpcStatusDetailsBin} may be formatted as
+ * {@code com.google.rpc.Status} to follow the
+ * unofficial specification.
+ */
+ public ArmeriaStatusException(int code, @Nullable String message, @Nullable byte[] grpcStatusDetailsBin) {
+ this(code, message, grpcStatusDetailsBin, null);
}
/**
* Constructs an {@link ArmeriaStatusException} for the given gRPC status code, message and cause.
*/
public ArmeriaStatusException(int code, @Nullable String message, @Nullable Throwable cause) {
+ this(code, message, null, cause);
+ }
+
+ /**
+ * Constructs an {@link ArmeriaStatusException} for the given gRPC status code, message,
+ * grpcStatusDetailsBin and cause. {@code grpcStatusDetailsBin} may be formatted as
+ * {@code com.google.rpc.Status} to follow the
+ * unofficial specification.
+ */
+ public ArmeriaStatusException(int code, @Nullable String message, @Nullable byte[] grpcStatusDetailsBin,
+ @Nullable Throwable cause) {
super(message, cause);
this.code = code;
+ this.grpcStatusDetailsBin = grpcStatusDetailsBin;
}
/**
@@ -51,4 +75,12 @@ public ArmeriaStatusException(int code, @Nullable String message, @Nullable Thro
public int getCode() {
return code;
}
+
+ /**
+ * Returns the gRPC details binary for this {@link ArmeriaStatusException}.
+ */
+ @Nullable
+ public byte[] getGrpcStatusDetailsBin() {
+ return grpcStatusDetailsBin;
+ }
}
diff --git a/grpc-protocol/src/main/java/com/linecorp/armeria/internal/common/grpc/protocol/GrpcTrailersUtil.java b/grpc-protocol/src/main/java/com/linecorp/armeria/internal/common/grpc/protocol/GrpcTrailersUtil.java
index 7ceb928c96b..d5aabb4c552 100644
--- a/grpc-protocol/src/main/java/com/linecorp/armeria/internal/common/grpc/protocol/GrpcTrailersUtil.java
+++ b/grpc-protocol/src/main/java/com/linecorp/armeria/internal/common/grpc/protocol/GrpcTrailersUtil.java
@@ -18,6 +18,7 @@
import static io.netty.util.AsciiString.c2b;
+import java.util.Base64;
import java.util.Map;
import com.linecorp.armeria.common.HttpHeaders;
@@ -43,12 +44,17 @@ public final class GrpcTrailersUtil {
* as {@code true}.
*/
public static void addStatusMessageToTrailers(
- HttpHeadersBuilder trailersBuilder, int code, @Nullable String message) {
+ HttpHeadersBuilder trailersBuilder, int code, @Nullable String message,
+ @Nullable byte[] details) {
trailersBuilder.endOfStream(true);
trailersBuilder.add(GrpcHeaderNames.GRPC_STATUS, StringUtil.toString(code));
if (message != null) {
trailersBuilder.add(GrpcHeaderNames.GRPC_MESSAGE, StatusMessageEscaper.escape(message));
}
+ if (details != null && details.length > 0) {
+ final String encodedDetails = Base64.getEncoder().encodeToString(details);
+ trailersBuilder.add(GrpcHeaderNames.GRPC_STATUS_DETAILS_BIN, encodedDetails);
+ }
}
/**
diff --git a/grpc-protocol/src/main/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnsafeUnaryGrpcService.java b/grpc-protocol/src/main/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnsafeUnaryGrpcService.java
index 12cb30f8805..f57380f8983 100644
--- a/grpc-protocol/src/main/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnsafeUnaryGrpcService.java
+++ b/grpc-protocol/src/main/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnsafeUnaryGrpcService.java
@@ -133,7 +133,8 @@ protected final HttpResponse doPost(ServiceRequestContext ctx, HttpRequest req)
if (cause == null) {
try {
final HttpHeadersBuilder trailersBuilder = HttpHeaders.builder();
- GrpcTrailersUtil.addStatusMessageToTrailers(trailersBuilder, StatusCodes.OK, null);
+ GrpcTrailersUtil.addStatusMessageToTrailers(trailersBuilder, StatusCodes.OK,
+ null, null);
final HttpHeaders trailers = trailersBuilder.build();
GrpcWebTrailers.set(ctx, trailers);
final ArmeriaMessageFramer framer = new ArmeriaMessageFramer(
@@ -162,10 +163,11 @@ protected final HttpResponse doPost(ServiceRequestContext ctx, HttpRequest req)
if (cause instanceof ArmeriaStatusException) {
final ArmeriaStatusException statusException = (ArmeriaStatusException) cause;
GrpcTrailersUtil.addStatusMessageToTrailers(
- trailersBuilder, statusException.getCode(), statusException.getMessage());
+ trailersBuilder, statusException.getCode(), statusException.getMessage(),
+ statusException.getGrpcStatusDetailsBin());
} else {
GrpcTrailersUtil.addStatusMessageToTrailers(
- trailersBuilder, StatusCodes.INTERNAL, cause.getMessage());
+ trailersBuilder, StatusCodes.INTERNAL, cause.getMessage(), null);
}
final ResponseHeaders trailers = trailersBuilder.build();
GrpcWebTrailers.set(ctx, trailers);
diff --git a/grpc/src/main/java/com/linecorp/armeria/internal/server/grpc/AbstractServerCall.java b/grpc/src/main/java/com/linecorp/armeria/internal/server/grpc/AbstractServerCall.java
index 885e4c5d3dd..07432f78909 100644
--- a/grpc/src/main/java/com/linecorp/armeria/internal/server/grpc/AbstractServerCall.java
+++ b/grpc/src/main/java/com/linecorp/armeria/internal/server/grpc/AbstractServerCall.java
@@ -562,7 +562,7 @@ public static HttpHeaders statusToTrailers(
.build();
}
GrpcTrailersUtil.addStatusMessageToTrailers(
- trailersBuilder, status.getCode().value(), status.getDescription());
+ trailersBuilder, status.getCode().value(), status.getDescription(), null);
if (ctx.config().verboseResponses() && status.getCause() != null) {
final ThrowableProto proto = GrpcStatus.serializeThrowable(status.getCause());
diff --git a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcWebTextTest.java b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcWebTextTest.java
index cbcafcd8eb4..5578d908227 100644
--- a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcWebTextTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcWebTextTest.java
@@ -142,7 +142,7 @@ private static void writeEncodedMessageAcrossFrames(
private static void writeTrailers(ServiceRequestContext ctx, HttpResponseWriter streaming) {
final HttpHeadersBuilder trailersBuilder = HttpHeaders.builder();
- GrpcTrailersUtil.addStatusMessageToTrailers(trailersBuilder, StatusCodes.OK, null);
+ GrpcTrailersUtil.addStatusMessageToTrailers(trailersBuilder, StatusCodes.OK, null, null);
final ByteBuf serializedTrailers =
GrpcTrailersUtil.serializeTrailersAsMessage(ctx.alloc(), trailersBuilder.build());
final HttpData httpdataTrailers = HttpData.wrap(
diff --git a/grpc/src/test/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClientTest.java b/grpc/src/test/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClientTest.java
index b6b89c84ba7..c097566b582 100644
--- a/grpc/src/test/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClientTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/client/grpc/protocol/UnaryGrpcClientTest.java
@@ -20,6 +20,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletionException;
import java.util.stream.Stream;
@@ -81,8 +82,8 @@ private static SimpleRequest buildRequest(String payload) {
.build();
}
- private static String getUri(SerializationFormat serializationFormat) {
- return String.format("%s+%s", serializationFormat, server.httpUri());
+ private static URI getUri(SerializationFormat serializationFormat) {
+ return server.httpUri(serializationFormat);
}
@ParameterizedTest
diff --git a/grpc/src/test/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaMessageFramerTest.java b/grpc/src/test/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaMessageFramerTest.java
index b0a1f248271..cfa0042aa8a 100644
--- a/grpc/src/test/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaMessageFramerTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/common/grpc/protocol/ArmeriaMessageFramerTest.java
@@ -209,7 +209,7 @@ void tooLargeCompressed() {
private static ByteBuf serializedTrailers() {
final ResponseHeadersBuilder trailersBuilder = ResponseHeaders.builder(200).contentType(
GrpcSerializationFormats.PROTO.mediaType());
- GrpcTrailersUtil.addStatusMessageToTrailers(trailersBuilder, StatusCodes.OK, null);
+ GrpcTrailersUtil.addStatusMessageToTrailers(trailersBuilder, StatusCodes.OK, null, null);
return serializeTrailersAsMessage(ByteBufAllocator.DEFAULT, trailersBuilder.build());
}
}
diff --git a/grpc/src/test/java/com/linecorp/armeria/internal/common/grpc/MetadataUtilTest.java b/grpc/src/test/java/com/linecorp/armeria/internal/common/grpc/MetadataUtilTest.java
index 5f64213a1b8..c7725577897 100644
--- a/grpc/src/test/java/com/linecorp/armeria/internal/common/grpc/MetadataUtilTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/internal/common/grpc/MetadataUtilTest.java
@@ -60,7 +60,9 @@ void fillHeadersTest() {
.add(HttpHeaderNames.STATUS, HttpStatus.OK.codeAsText())
.add(HttpHeaderNames.CONTENT_TYPE, "application/grpc+proto")
.add(GrpcHeaderNames.GRPC_STATUS, "3")
- .add(GrpcHeaderNames.GRPC_MESSAGE, "test_grpc_message");
+ .add(GrpcHeaderNames.GRPC_MESSAGE, "test_grpc_message")
+ .add(GrpcHeaderNames.GRPC_STATUS_DETAILS_BIN,
+ Base64.getEncoder().encodeToString("test_grpc_details".getBytes()));
final Metadata metadata = new Metadata();
// be copied into HttpHeaderBuilder trailers
@@ -81,6 +83,8 @@ void fillHeadersTest() {
assertThat(trailers.getAll(HttpHeaderNames.CONTENT_TYPE)).containsExactly("application/grpc+proto");
assertThat(trailers.getAll(GrpcHeaderNames.GRPC_STATUS)).containsExactly("3");
assertThat(trailers.getAll(GrpcHeaderNames.GRPC_MESSAGE)).containsOnly("test_grpc_message");
+ assertThat(Base64.getDecoder().decode(trailers.get(GrpcHeaderNames.GRPC_STATUS_DETAILS_BIN)))
+ .containsExactly("test_grpc_details".getBytes());
assertThat(trailers.getAll(GrpcHeaderNames.ARMERIA_GRPC_THROWABLEPROTO_BIN)).isEmpty();
}
diff --git a/grpc/src/test/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnaryGrpcServiceTest.java b/grpc/src/test/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnaryGrpcServiceTest.java
index e73611ea784..1de3ddcc5fe 100644
--- a/grpc/src/test/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnaryGrpcServiceTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/server/grpc/protocol/AbstractUnaryGrpcServiceTest.java
@@ -20,6 +20,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.UncheckedIOException;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.stream.Stream;
@@ -34,8 +35,12 @@
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
+import com.linecorp.armeria.client.ClientRequestContext;
+import com.linecorp.armeria.client.ClientRequestContextCaptor;
+import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.client.WebClient;
import com.linecorp.armeria.client.grpc.GrpcClients;
+import com.linecorp.armeria.client.grpc.protocol.UnaryGrpcClient;
import com.linecorp.armeria.common.AggregatedHttpResponse;
import com.linecorp.armeria.common.HttpHeaderNames;
import com.linecorp.armeria.common.HttpHeaders;
@@ -100,7 +105,9 @@ protected CompletionStage handleMessage(ServiceRequestContext ctx, byte[
// For statusExceptionUpstream() and statusExceptionDownstream()
assertThat(request).isEqualTo(EXCEPTION_REQUEST_MESSAGE);
final Messages.EchoStatus resStatus = request.getResponseStatus();
- throw new ArmeriaStatusException(resStatus.getCode(), resStatus.getMessage());
+ throw new ArmeriaStatusException(resStatus.getCode(),
+ resStatus.getMessage(),
+ "TestDetails".getBytes());
} else {
// For normalUpstream() and normalDownstream()
assertThat(request).isEqualTo(REQUEST_MESSAGE);
@@ -200,6 +207,32 @@ void unsupportedMediaType() {
HttpStatus.UNSUPPORTED_MEDIA_TYPE.codeAsText());
}
+ @Test
+ void exceptionWithDetails() throws Exception {
+ final UnaryGrpcClient client = Clients.newClient(
+ server.httpUri(UnaryGrpcSerializationFormats.PROTO),
+ UnaryGrpcClient.class);
+
+ try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
+ assertThatThrownBy(
+ () -> client.execute("/armeria.grpc.testing.TestService/UnaryCall",
+ EXCEPTION_REQUEST_MESSAGE.toByteArray()).join())
+ .isInstanceOf(CompletionException.class)
+ .hasMessageContaining("not for your eyes")
+ .hasCauseInstanceOf(ArmeriaStatusException.class)
+ .cause()
+ .satisfies(ex -> {
+ final ArmeriaStatusException cause = (ArmeriaStatusException) ex;
+ assertThat(cause.getGrpcStatusDetailsBin()).isNotEmpty();
+ assertThat(cause.getGrpcStatusDetailsBin()).isEqualTo("TestDetails".getBytes());
+ });
+ final ClientRequestContext ctx = captor.get();
+ final HttpHeaders trailers = GrpcWebTrailers.get(ctx);
+ assertThat(trailers).isNotNull();
+ assertThat(trailers.getInt(GrpcHeaderNames.GRPC_STATUS)).isEqualTo(StatusCodes.PERMISSION_DENIED);
+ }
+ }
+
@ParameterizedTest
@ArgumentsSource(UnaryGrpcSerializationFormatArgumentsProvider.class)
void invalidPayload(SerializationFormat serializationFormat) throws Exception {