Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
package io.vertx.grpc.client;

import io.vertx.core.MultiMap;
import io.vertx.core.VertxException;
import io.vertx.grpc.common.GrpcStatus;

Expand All @@ -20,11 +21,13 @@ public final class InvalidStatusException extends VertxException {

private final GrpcStatus expected;
private final GrpcStatus actual;
private final MultiMap metadata;

public InvalidStatusException(GrpcStatus expected, GrpcStatus actual) {
public InvalidStatusException(GrpcStatus expected, GrpcStatus actual, MultiMap metadata) {
super("Invalid status: actual:" + actual.name() + ", expected:" + expected.name());
this.expected = expected;
this.actual = actual;
this.metadata = metadata;
}

/**
Expand All @@ -40,4 +43,12 @@ public GrpcStatus expectedStatus() {
public GrpcStatus actualStatus() {
return actual;
}

/**
* @return the server trailers
*/
public MultiMap metadata() {
return metadata;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ public boolean test(Void value) {
}
@Override
public Throwable describe(Void value) {
return new InvalidStatusException(GrpcStatus.OK, status());
MultiMap metadata;
if (httpResponse.trailers().isEmpty()) { // TODO: Check if any payload has been parsed (needs GrpcReadStream modification)
metadata = httpResponse.headers(); // trailersOnly response
} else {
metadata = httpResponse.trailers();
}
return new InvalidStatusException(GrpcStatus.OK, status(), metadata);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ public void testStatus(TestContext should) throws IOException {
should.assertTrue(err instanceof InvalidStatusException);
should.assertEquals(GrpcStatus.OK, ((InvalidStatusException)err).expectedStatus());
should.assertEquals(GrpcStatus.UNAVAILABLE, ((InvalidStatusException)err).actualStatus());
should.assertEquals("error-value", ((InvalidStatusException)err).metadata().get("error-data"));
latch2.complete();
}));
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,10 @@ public void testStatus(TestContext should) throws IOException {
TestServiceGrpc.TestServiceImplBase called = new TestServiceGrpc.TestServiceImplBase() {
@Override
public void unary(Request request, StreamObserver<Reply> responseObserver) {
responseObserver.onError(Status.UNAVAILABLE
.withDescription("~Greeter temporarily unavailable...~").asRuntimeException());
Metadata metadata = new Metadata();
metadata.put(Metadata.Key.of("error-data", Metadata.ASCII_STRING_MARSHALLER), "error-value");
var re = Status.UNAVAILABLE.withDescription("~Greeter temporarily unavailable...~").asRuntimeException(metadata);
responseObserver.onError(re);
}
};
startServer(called);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.vertx.core.Future;
import io.vertx.core.Completable;
import io.vertx.core.Handler;
import io.vertx.core.MultiMap;
import io.vertx.core.net.SocketAddress;
import io.vertx.grpc.client.GrpcClient;
import io.vertx.core.streams.ReadStream;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.vertx.core.Future;
import io.vertx.core.Completable;
import io.vertx.core.Handler;
import io.vertx.core.MultiMap;
import io.vertx.core.net.SocketAddress;
import io.vertx.grpc.client.GrpcClient;
import io.vertx.core.streams.ReadStream;
Expand Down Expand Up @@ -93,7 +94,13 @@ public Future<ReadStream<examples.grpc.Item>> source(examples.grpc.Empty request
req.format(wireFormat);
return req.end(request).compose(v -> req.response().flatMap(resp -> {
if (resp.status() != null && resp.status() != GrpcStatus.OK) {
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status()));
MultiMap metadata;
if (resp.trailers().isEmpty()) { // TODO: Check if any payload has been parsed (needs GrpcReadStream modification)
metadata = resp.headers(); // trailersOnly response
} else {
metadata = resp.trailers();
}
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status(), metadata));
} else {
return Future.succeededFuture(resp);
}
Expand Down Expand Up @@ -125,7 +132,13 @@ public Future<ReadStream<examples.grpc.Item>> pipe(Completable<WriteStream<examp
.compose(req -> {
return req.response().flatMap(resp -> {
if (resp.status() != null && resp.status() != GrpcStatus.OK) {
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status()));
MultiMap metadata;
if (resp.trailers().isEmpty()) { // TODO: Check if any payload has been parsed (needs GrpcReadStream modification)
metadata = resp.headers(); // trailersOnly response
} else {
metadata = resp.trailers();
}
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status(), metadata));
} else {
return Future.succeededFuture(resp);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
package io.vertx.grpc.it;

import com.google.protobuf.ByteString;
import io.grpc.Metadata;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.examples.helloworld.*;
import io.grpc.testing.integration.*;
import io.vertx.core.Completable;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpServer;
import io.vertx.core.net.SocketAddress;
Expand All @@ -27,7 +29,9 @@
import io.vertx.grpc.client.GrpcClient;
import io.vertx.grpc.client.GrpcClientRequest;
import io.vertx.grpc.client.InvalidStatusException;
import io.vertx.grpc.common.GrpcHeaderNames;
import io.vertx.grpc.common.GrpcStatus;
import io.vertx.grpc.common.impl.Utils;
import io.vertx.grpc.server.StatusException;
import io.vertx.grpc.server.GrpcServer;
import io.vertx.grpc.server.Service;
Expand Down Expand Up @@ -740,6 +744,18 @@ public Future<HelloReply> sayHello(HelloRequest request) {
});
}

@Test
public void testServerStatus3(TestContext should) throws Exception {
testServerStatus(should, new GreeterService() {
@Override
public Future<HelloReply> sayHello(HelloRequest request) {
MultiMap errorContext = MultiMap.caseInsensitiveMultiMap();
errorContext.add("error-info", "error-data");
return Future.failedFuture(new StatusException(GrpcStatus.INTERNAL, "error message", errorContext));
}
});
}

private void testServerStatus(TestContext should, GreeterService service) throws Exception {

// Create gRPC Server
Expand All @@ -760,11 +776,25 @@ private void testServerStatus(TestContext should, GreeterService service) throws
.onComplete(should.asyncAssertFailure(reply -> {
if (reply instanceof InvalidStatusException) {
InvalidStatusException ise = (InvalidStatusException) reply;
should.assertEquals(GrpcStatus.NOT_FOUND, ise.actualStatus());
MultiMap metadata = ise.metadata();
if (!metadata.contains("error-info")) { // testServerStatus1, testServerStatus2
should.assertEquals(GrpcStatus.NOT_FOUND, ise.actualStatus());
} else { // testServerStatus3
should.assertEquals(GrpcStatus.INTERNAL, ise.actualStatus());
should.assertEquals( "error-data", metadata.get("error-info"));
should.assertEquals(Utils.utf8PercentEncode("error message"), metadata.get(GrpcHeaderNames.GRPC_MESSAGE));
}
test.complete();
} else if (reply instanceof StatusRuntimeException) {
StatusRuntimeException sre = (StatusRuntimeException) reply;
should.assertEquals(Status.NOT_FOUND, sre.getStatus());
Metadata metadata = sre.getTrailers();
Metadata.Key key = Metadata.Key.of("error-info", Metadata.ASCII_STRING_MARSHALLER);
if (!metadata.containsKey(key)) { // testServerStatus1, testServerStatus2
should.assertEquals(Status.NOT_FOUND, sre.getStatus());
} else { // testServerStatus3
should.assertEquals(Status.INTERNAL, sre.getStatus());
should.assertEquals( "error-data", metadata.get(key));
}
test.complete();
} else {
should.fail();
Expand Down
17 changes: 15 additions & 2 deletions vertx-grpc-protoc-plugin2/src/main/resources/grpc-client.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package {{javaPackageFqn}};
import io.vertx.core.Future;
import io.vertx.core.Completable;
import io.vertx.core.Handler;
import io.vertx.core.MultiMap;
import io.vertx.core.net.SocketAddress;
import io.vertx.grpc.client.GrpcClient;
import io.vertx.core.streams.ReadStream;
Expand Down Expand Up @@ -95,7 +96,13 @@ class {{grpcClientFqn}}Impl implements {{grpcClientFqn}} {
req.format(wireFormat);
return req.end(request).compose(v -> req.response().flatMap(resp -> {
if (resp.status() != null && resp.status() != GrpcStatus.OK) {
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status()));
MultiMap metadata;
if (resp.trailers().isEmpty()) { // TODO: Check if any payload has been parsed (needs GrpcReadStream modification)
metadata = resp.headers(); // trailersOnly response
} else {
metadata = resp.trailers();
}
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status(), metadata));
} else {
return Future.succeededFuture(resp);
}
Expand Down Expand Up @@ -131,7 +138,13 @@ class {{grpcClientFqn}}Impl implements {{grpcClientFqn}} {
.compose(req -> {
return req.response().flatMap(resp -> {
if (resp.status() != null && resp.status() != GrpcStatus.OK) {
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status()));
MultiMap metadata;
if (resp.trailers().isEmpty()) {
metadata = resp.headers(); // trailersOnly response
} else {
metadata = resp.trailers();
}
return Future.failedFuture(new io.vertx.grpc.client.InvalidStatusException(GrpcStatus.OK, resp.status(), metadata));
} else {
return Future.succeededFuture(resp);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.vertx.grpc.server;

import io.vertx.core.MultiMap;
import io.vertx.grpc.common.GrpcStatus;

/**
* Interface for providing detailed error information in gRPC server responses.
* <p>
* Implementing this interface allows exceptions to expose structured gRPC error details,
* including a status a descriptive error message, and optional trailers.
* </p>
* <p>
* This design enables custom exceptions to propagate meaningful and rich error context to gRPC clients
* without coupling to a specific exception class.
* </p>
*/

public interface GrpcErrorInfoProvider {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this interface is needed for implementing the feature ? if we can just use directly StatusException without it, then we should do it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right it's not necessary, but there’s an important reason behind it:

StatusException is final, applications cannot extend it to include additional behavior or metadata relevant to their domain logic. As a result, if we rely solely on StatusException, applications are forced to throw a specific Vert.x class from within their business logic, making the error-handling tightly coupled to the transport layer.

The goal of introducing the interface is to decouple the business logic from the transport mechanism. This way, applications can throw their own domain-specific exceptions (e.g. InvalidUserInputException, PaymentRejectedException, etc.) and simply implement the interface to expose gRPC-compatible error data (status, message, metadata).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally like the idea of users having the ability to write their own exceptions which are decoupled from the transport layer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently there are not tests using GrpcErrorInfoProvider, can you explain how this would be used in practice, it is not yet still clear to me how it can be used

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. Let me give a concrete example to clarify the motivation for the interface.

Imagine a typical application structure where we define a base exception for our domain logic, and specific exceptions extend from it. These exceptions should not extend from StatusException, which is Vert.x/gRPC specific and has nothing to do with our application's business concerns (Furthermore, StatusException is final and we force the application to use it without chance of any subclassing).

Example

// Our base exception for the application
public abstract class AppException extends RuntimeException {
  public AppException(String message) {
    super(message);
  }
}

Then we define a business-specific exception, and we want it to be mappable to a gRPC status, without coupling it to Vert.x types:

// A business logic exception that we want to map to gRPC
public class UserNotFoundException extends AppException implements GrpcStatusException {
  public UserNotFoundException(String userId) {
    super("User not found: " + userId);
  }

  @Override
  public Status getGrpcStatus() {
    return Status.NOT_FOUND.withDescription(getMessage());
  }

  // add metadata
  // add needed logic
}

Note that:

  • UserNotFoundException is part of our domain, and extends AppException.
  • It implements GrpcStatusException to signal to the framework that it knows how to convert itself to a gRPC Status.

This keeps our application cleanly decoupled, while still enabling powerful mapping logic on the framework side.

Without this interface, we are forced to use StatusException directly within our application code.

  1. StatusException is a final class, meaning we cannot extend it to add application-specific data or behavior that might be relevant (e.g. error codes, metadata, logging context, etc.).
  2. It couples our domain logic to Vert.x internals, which breaks separation of concerns.

This pattern is especially useful in microservice-to-microservice calls, where domain exceptions need to be translated to appropriate gRPC statuses but we still want to keep a clean architecture on the application side.

Hope this clarifies the intent!


/**
* Returns the GrpcStatus associated with this error.
*
* @return the gRPC status
*/
GrpcStatus status();

/**
* Returns the gRPC error message to send to the client.
*
* @return the error message as a string
*/
String message();

/**
* Returns optional key-value trailers to include in the response.
* Can be {@code null} or empty.
*
* @return containing error trailers
*/
MultiMap trailers();
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ default Future<Void> send(ReadStream<Resp> body) {
* End the stream with an appropriate status message, when {@code failure} is
*
* <ul>
* <li>{@link StatusException}, set status to {@link StatusException#status()} and status message to {@link StatusException#message()}</li>
* <li>{@link StatusException}, set status to {@link StatusException#status()}, status message to {@link StatusException#message()} and associated metadata to {@link StatusException#trailers()}</li>
* <li>Use any exception implementing {@link GrpcErrorInfoProvider} to propagate meaningful and rich error context to gRPC clients without coupling to a specific exception class.</li>
* <li>{@link UnsupportedOperationException} returns {@link GrpcStatus#UNIMPLEMENTED}</li>
* <li>otherwise returns {@link GrpcStatus#UNKNOWN}</li>
* </ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,46 @@
*/
package io.vertx.grpc.server;

import io.vertx.core.MultiMap;
import io.vertx.core.VertxException;
import io.vertx.grpc.common.GrpcStatus;

/**
* A glorified GOTO forcing a response status.
*/
public final class StatusException extends VertxException {
public final class StatusException extends VertxException implements GrpcErrorInfoProvider {

private final GrpcStatus status;
private final String message;
private final MultiMap trailers;

public StatusException(GrpcStatus status) {
super("Grpc status " + status.name());
this.status = status;
this.message = null;
this(status, null, null);
}

public StatusException(GrpcStatus status, String message) {
this(status, message, null);
}

public StatusException(GrpcStatus status, String message, MultiMap trailers) {
super("Grpc status " + status.name());
this.status = status;
this.message = message;
this.trailers = trailers;
}

/**
* @return the status
*/
@Override
public GrpcStatus status() {
return status;
}

/**
* @return the status message
*/
@Override
public String message() {
return message;
}

@Override
public MultiMap trailers() {
return trailers;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@
import io.vertx.grpc.common.impl.GrpcMessageImpl;
import io.vertx.grpc.common.impl.GrpcWriteStreamBase;
import io.vertx.grpc.common.impl.Utils;
import io.vertx.grpc.server.GrpcErrorInfoProvider;
import io.vertx.grpc.server.GrpcProtocol;
import io.vertx.grpc.server.GrpcServerResponse;
import io.vertx.grpc.server.StatusException;

import java.util.Map;
import java.util.Objects;

Expand Down Expand Up @@ -79,10 +78,17 @@ public void handleTimeout() {
}

public void fail(Throwable failure) {
if (failure instanceof StatusException) {
StatusException se = (StatusException) failure;
this.status = se.status();
this.statusMessage = se.message();
if (failure instanceof GrpcErrorInfoProvider) {
GrpcErrorInfoProvider infoPro = (GrpcErrorInfoProvider) failure;
this.status = infoPro.status();
this.statusMessage = infoPro.message();
MultiMap errorTrailers = infoPro.trailers();
if (errorTrailers != null && !errorTrailers.isEmpty()) {
MultiMap grpcTrailers = trailers();
for (Map.Entry<String, String> header : errorTrailers) {
grpcTrailers.add(header.getKey(), header.getValue());
}
}
} else {
this.status = mapStatus(failure);
}
Expand Down Expand Up @@ -185,8 +191,8 @@ protected Buffer encodeMessage(Buffer message, boolean compressed, boolean trail
}

private static GrpcStatus mapStatus(Throwable t) {
if (t instanceof StatusException) {
return ((StatusException)t).status();
if (t instanceof GrpcErrorInfoProvider) {
return ((GrpcErrorInfoProvider) t).status();
} else if (t instanceof UnsupportedOperationException) {
return GrpcStatus.UNIMPLEMENTED;
} else {
Expand Down
Loading