diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLongRunningOperation.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLongRunningOperation.java index 09208861babd..f3a4a528a76f 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLongRunningOperation.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLongRunningOperation.java @@ -20,13 +20,19 @@ import com.google.api.gax.longrunning.OperationFuture; import com.google.api.gax.retrying.RetrySettings; +import com.google.api.gax.rpc.ApiException; +import com.google.protobuf.Any; import com.google.protobuf.Timestamp; +import com.google.rpc.Code; +import com.google.rpc.Status; import com.google.showcase.v1beta1.EchoClient; +import com.google.showcase.v1beta1.PoetryError; import com.google.showcase.v1beta1.WaitMetadata; import com.google.showcase.v1beta1.WaitRequest; import com.google.showcase.v1beta1.WaitResponse; import com.google.showcase.v1beta1.it.util.TestClientInitializer; import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.threeten.bp.Duration; @@ -193,4 +199,35 @@ void testHttpJson_LROUnsuccessfulResponse_exceedsTotalTimeout_throwsDeadlineExce TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS); } } + + @Test + void testGRPC_LROErrorResponse_propagatesErrorDetails() throws Exception { + EchoClient grpcClient = TestClientInitializer.createGrpcEchoClient(); + try { + PoetryError poetryError = + PoetryError.newBuilder().setPoem("Roses are red, violets are blue").build(); + Status status = + Status.newBuilder() + .setCode(Code.ALREADY_EXISTS_VALUE) + .setMessage("The resource already exists") + .addDetails(Any.pack(poetryError)) + .build(); + WaitRequest waitRequest = WaitRequest.newBuilder().setError(status).build(); + OperationFuture operationFuture = + grpcClient.waitOperationCallable().futureCall(waitRequest); + ExecutionException exception = assertThrows(ExecutionException.class, operationFuture::get); + assertThat(exception.getCause()).isInstanceOf(ApiException.class); + ApiException apiException = (ApiException) exception.getCause(); + + // Verify that error details are successfully propagated + assertThat(apiException.getErrorDetails()).isNotNull(); + PoetryError unpackedError = apiException.getErrorDetails().getMessage(PoetryError.class); + assertThat(unpackedError).isNotNull(); + assertThat(unpackedError.getPoem()).isEqualTo("Roses are red, violets are blue"); + } finally { + grpcClient.close(); + grpcClient.awaitTermination( + TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS); + } + } } diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcOperationSnapshot.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcOperationSnapshot.java index 9f271e57f0b8..58f773696d32 100644 --- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcOperationSnapshot.java +++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcOperationSnapshot.java @@ -30,10 +30,12 @@ package com.google.api.gax.grpc; import com.google.api.gax.longrunning.OperationSnapshot; +import com.google.api.gax.rpc.ErrorDetails; import com.google.api.gax.rpc.StatusCode; import com.google.longrunning.Operation; import io.grpc.Status; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Implementation of OperationSnapshot based on gRPC. @@ -79,6 +81,16 @@ public String getErrorMessage() { return operation.getError().getMessage(); } + @Override + public @Nullable ErrorDetails getErrorDetails() { + if (operation.hasError() && operation.getError().getDetailsCount() > 0) { + return ErrorDetails.builder() + .setRawErrorMessages(operation.getError().getDetailsList()) + .build(); + } + return null; + } + public static GrpcOperationSnapshot create(Operation operation) { return new GrpcOperationSnapshot(operation); } diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/ProtoOperationTransformers.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/ProtoOperationTransformers.java index 425842b17499..dfb4105b4755 100644 --- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/ProtoOperationTransformers.java +++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/ProtoOperationTransformers.java @@ -63,7 +63,8 @@ public ResponseT apply(OperationSnapshot operationSnapshot) { + operationSnapshot.getErrorMessage(), null, operationSnapshot.getErrorCode(), - false); + false, + operationSnapshot.getErrorDetails()); } if (!(operationSnapshot.getResponse() instanceof Any)) { diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ProtoOperationTransformersTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ProtoOperationTransformersTest.java index 19122ff653c0..0ec1c3e14f0e 100644 --- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ProtoOperationTransformersTest.java +++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ProtoOperationTransformersTest.java @@ -110,4 +110,25 @@ void testAnyMetadataTransformer_mismatchedTypes() { assertThrows(UnknownException.class, () -> transformer.apply(operationSnapshot)); Truth.assertThat(exception).hasMessageThat().contains("encountered a problem unpacking it"); } + + @Test + void testAnyResponseTransformer_exceptionWithErrorDetails() { + ResponseTransformer transformer = ResponseTransformer.create(Money.class); + Money inputMoney = Money.newBuilder().setCurrencyCode("USD").build(); + Color poetryError = + Color.newBuilder().setRed(1.0f).build(); // Use Color as a mock details payload + Status status = + Status.newBuilder() + .setCode(Code.UNAVAILABLE.value()) + .addDetails(Any.pack(poetryError)) + .build(); + OperationSnapshot operationSnapshot = + GrpcOperationSnapshot.create( + Operation.newBuilder().setResponse(Any.pack(inputMoney)).setError(status).build()); + + UnavailableException exception = + assertThrows(UnavailableException.class, () -> transformer.apply(operationSnapshot)); + Truth.assertThat(exception.getErrorDetails()).isNotNull(); + Truth.assertThat(exception.getErrorDetails().getMessage(Color.class)).isEqualTo(poetryError); + } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshot.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshot.java index d709b680a9b6..c700cec9f595 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshot.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshot.java @@ -31,10 +31,12 @@ import com.google.api.core.InternalApi; import com.google.api.gax.longrunning.OperationSnapshot; +import com.google.api.gax.rpc.ErrorDetails; import com.google.api.gax.rpc.StatusCode; import com.google.api.gax.rpc.StatusCode.Code; import com.google.longrunning.Operation; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Implementation of OperationSnapshot based on REST transport. @@ -50,6 +52,7 @@ public class HttpJsonOperationSnapshot implements OperationSnapshot { private final Object response; private final StatusCode errorCode; private final String errorMessage; + private final @Nullable ErrorDetails errorDetails; private HttpJsonOperationSnapshot( String name, @@ -57,13 +60,15 @@ private HttpJsonOperationSnapshot( boolean done, Object response, StatusCode errorCode, - String errorMessage) { + String errorMessage, + @Nullable ErrorDetails errorDetails) { this.name = name; this.metadata = metadata; this.done = done; this.response = response; this.errorCode = errorCode; this.errorMessage = errorMessage; + this.errorDetails = errorDetails; } /** {@inheritDoc} */ @@ -102,6 +107,11 @@ public String getErrorMessage() { return this.errorMessage; } + @Override + public @Nullable ErrorDetails getErrorDetails() { + return this.errorDetails; + } + public static HttpJsonOperationSnapshot create(Operation operation) { return newBuilder().setOperation(operation).build(); } @@ -117,6 +127,18 @@ public static class Builder { private Object response; private StatusCode errorCode; private String errorMessage; + private @Nullable ErrorDetails errorDetails; + + /** + * Sets the LRO error details. + * + * @param errorDetails the LRO error details + * @return the builder instance + */ + public Builder setErrorDetails(final @Nullable ErrorDetails errorDetails) { + this.errorDetails = errorDetails; + return this; + } public Builder setName(String name) { this.name = name; @@ -153,11 +175,20 @@ private Builder setOperation(Operation operation) { this.errorCode = HttpJsonStatusCode.of(com.google.rpc.Code.forNumber(operation.getError().getCode())); this.errorMessage = operation.getError().getMessage(); + if (operation.hasError() && operation.getError().getDetailsCount() > 0) { + this.errorDetails = + ErrorDetails.builder() + .setRawErrorMessages(operation.getError().getDetailsList()) + .build(); + } else { + this.errorDetails = null; + } return this; } public HttpJsonOperationSnapshot build() { - return new HttpJsonOperationSnapshot(name, metadata, done, response, errorCode, errorMessage); + return new HttpJsonOperationSnapshot( + name, metadata, done, response, errorCode, errorMessage, errorDetails); } } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoOperationTransformers.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoOperationTransformers.java index d5092cbad9ec..cc9200b87763 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoOperationTransformers.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoOperationTransformers.java @@ -63,7 +63,8 @@ public ResponseT apply(OperationSnapshot operationSnapshot) { + operationSnapshot.getErrorMessage(), null, operationSnapshot.getErrorCode(), - false); + false, + operationSnapshot.getErrorDetails()); } if (!(operationSnapshot.getResponse() instanceof Any)) { diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshotTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshotTest.java index 2dda22e61905..99e3f672bdd0 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshotTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshotTest.java @@ -33,8 +33,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.api.gax.rpc.ErrorDetails; import com.google.api.gax.rpc.StatusCode.Code; +import com.google.protobuf.Any; +import com.google.protobuf.Empty; import java.util.ArrayList; +import java.util.Collections; import org.junit.jupiter.api.Test; class HttpJsonOperationSnapshotTest { @@ -86,4 +90,22 @@ void newBuilderTestNotDone() { assertEquals(HttpJsonStatusCode.of(Code.OK), testOperationSnapshot.getErrorCode()); assertFalse(testOperationSnapshot.isDone()); } + + @Test + void newBuilderTestWithErrorDetails() { + ErrorDetails errorDetails = + ErrorDetails.builder() + .setRawErrorMessages(Collections.singletonList(Any.pack(Empty.getDefaultInstance()))) + .build(); + HttpJsonOperationSnapshot testOperationSnapshot = + HttpJsonOperationSnapshot.newBuilder() + .setName("snapshot-details") + .setMetadata("Dallas") + .setDone(true) + .setError(400, "Bad Request") + .setErrorDetails(errorDetails) + .build(); + + assertEquals(errorDetails, testOperationSnapshot.getErrorDetails()); + } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoOperationTransformersTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoOperationTransformersTest.java index 8220e5e3d4f9..4306bddcba4e 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoOperationTransformersTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoOperationTransformersTest.java @@ -142,4 +142,25 @@ void testAnyMetadataTransformer_mismatchedTypes() { assertThrows(UnknownException.class, () -> transformer.apply(operationSnapshot)); Truth.assertThat(exception).hasMessageThat().contains("encountered a problem unpacking it"); } + + @Test + void testAnyResponseTransformer_exceptionWithErrorDetails() { + ResponseTransformer transformer = ResponseTransformer.create(Money.class); + Money inputMoney = Money.newBuilder().setCurrencyCode("USD").build(); + Color poetryError = + Color.newBuilder().setRed(1.0f).build(); // Use Color as a mock details payload + Status status = + Status.newBuilder() + .setCode(Code.UNAVAILABLE.getNumber()) + .addDetails(Any.pack(poetryError)) + .build(); + OperationSnapshot operationSnapshot = + HttpJsonOperationSnapshot.create( + Operation.newBuilder().setResponse(Any.pack(inputMoney)).setError(status).build()); + + UnavailableException exception = + assertThrows(UnavailableException.class, () -> transformer.apply(operationSnapshot)); + Truth.assertThat(exception.getErrorDetails()).isNotNull(); + Truth.assertThat(exception.getErrorDetails().getMessage(Color.class)).isEqualTo(poetryError); + } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/longrunning/OperationSnapshot.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/longrunning/OperationSnapshot.java index 92f7aa04cbf3..fe20877f9381 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/longrunning/OperationSnapshot.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/longrunning/OperationSnapshot.java @@ -29,8 +29,10 @@ */ package com.google.api.gax.longrunning; +import com.google.api.gax.rpc.ErrorDetails; import com.google.api.gax.rpc.StatusCode; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * A snapshot of a long-running operation. @@ -67,4 +69,14 @@ public interface OperationSnapshot { * or if it succeeded, returns null. */ String getErrorMessage(); + + /** + * If the operation is done and it failed, returns the ErrorDetails; if the operation is not done + * or if it succeeded, returns null. + * + * @return the error details if the operation failed, null otherwise + */ + default @Nullable ErrorDetails getErrorDetails() { + return null; + } }