diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba34ff9af..3ce920951 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: GOPROXY: https://proxy.golang.org JDK_VER: ${{ matrix.java }} DAPR_CLI_VER: 1.12.0 - DAPR_RUNTIME_VER: 1.12.4 + DAPR_RUNTIME_VER: 1.13.0-rc.2 DAPR_INSTALL_URL: https://raw.githubusercontent.com/dapr/cli/v1.12.0/install/install.sh DAPR_CLI_REF: DAPR_REF: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d476892c9..d5c3816b4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -38,7 +38,7 @@ jobs: GOPROXY: https://proxy.golang.org JDK_VER: ${{ matrix.java }} DAPR_CLI_VER: 1.12.0 - DAPR_RUNTIME_VER: 1.12.4 + DAPR_RUNTIME_VER: 1.13.0-rc.2 DAPR_INSTALL_URL: https://raw.githubusercontent.com/dapr/cli/v1.12.0/install/install.sh DAPR_CLI_REF: DAPR_REF: diff --git a/daprdocs/content/en/java-sdk-docs/java-client/_index.md b/daprdocs/content/en/java-sdk-docs/java-client/_index.md index 74e183f35..dfe6c2841 100644 --- a/daprdocs/content/en/java-sdk-docs/java-client/_index.md +++ b/daprdocs/content/en/java-sdk-docs/java-client/_index.md @@ -40,7 +40,29 @@ If your Dapr instance is configured to require the `DAPR_API_TOKEN` environment set it in the environment and the client will use it automatically. You can read more about Dapr API token authentication [here](https://docs.dapr.io/operations/security/api-token/). +#### Error Handling +Initially, errors in Dapr followed the Standard gRPC error model. However, to provide more detailed and informative error +messages, in version 1.13 an enhanced error model has been introduced which aligns with the gRPC Richer error model. In +response, the Java SDK extended the DaprException to include the error details that were added in Dapr. + +Example of handling the DaprException and consuming the error details when using the Dapr Java SDK: + +```java +... + try { + client.publishEvent("unknown_pubsub", "mytopic", "mydata").block(); + } catch (DaprException exception) { + System.out.println("Dapr exception's error code: " + exception.getErrorCode()); + System.out.println("Dapr exception's message: " + exception.getMessage()); + // DaprException now contains `getStatusDetails()` to include more details about the error from Dapr runtime. + System.out.println("Dapr exception's reason: " + exception.getStatusDetails().get( + DaprErrorDetails.ErrorDetailType.ERROR_INFO, + "reason", + TypeRef.STRING)); + } +... +``` ## Building blocks diff --git a/examples/src/main/java/io/dapr/examples/exception/Client.java b/examples/src/main/java/io/dapr/examples/exception/Client.java index f757a2793..fb69a5125 100644 --- a/examples/src/main/java/io/dapr/examples/exception/Client.java +++ b/examples/src/main/java/io/dapr/examples/exception/Client.java @@ -15,7 +15,14 @@ import io.dapr.client.DaprClient; import io.dapr.client.DaprClientBuilder; +import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; +import io.dapr.utils.TypeRef; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * 1. Build and install jars: @@ -33,17 +40,17 @@ public class Client { */ public static void main(String[] args) throws Exception { try (DaprClient client = new DaprClientBuilder().build()) { - try { - client.getState("Unknown state store", "myKey", String.class).block(); + client.publishEvent("unknown_pubsub", "mytopic", "mydata").block(); } catch (DaprException exception) { System.out.println("Error code: " + exception.getErrorCode()); System.out.println("Error message: " + exception.getMessage()); - - exception.printStackTrace(); + System.out.println("Reason: " + exception.getStatusDetails().get( + DaprErrorDetails.ErrorDetailType.ERROR_INFO, + "reason", + TypeRef.STRING)); } - - System.out.println("Done"); } + System.out.println("Done"); } } diff --git a/examples/src/main/java/io/dapr/examples/exception/README.md b/examples/src/main/java/io/dapr/examples/exception/README.md index 4ccfd2515..ff1e6182e 100644 --- a/examples/src/main/java/io/dapr/examples/exception/README.md +++ b/examples/src/main/java/io/dapr/examples/exception/README.md @@ -23,8 +23,8 @@ cd java-sdk Then build the Maven project: ```sh -# make sure you are in the `java-sdk` directory. -mvn install +# make sure you are in the `java-sdk` (root) directory. +./mvnw clean install ``` Then get into the examples directory: @@ -32,43 +32,40 @@ Then get into the examples directory: cd examples ``` -### Running the StateClient -This example uses the Java SDK Dapr client in order to perform an invalid operation, causing Dapr runtime to return an error. See the code snippet below: +### Understanding the code + +This example uses the Java SDK Dapr client in order to perform an invalid operation, causing Dapr runtime to return an error. See the code snippet below, from `Client.java`: ```java public class Client { public static void main(String[] args) throws Exception { try (DaprClient client = new DaprClientBuilder().build()) { - try { - client.getState("Unknown state store", "myKey", String.class).block(); + client.publishEvent("unknown_pubsub", "mytopic", "mydata").block(); } catch (DaprException exception) { - System.out.println("Error code: " + exception.getErrorCode()); - System.out.println("Error message: " + exception.getMessage()); - - exception.printStackTrace(); + System.out.println("Dapr exception's error code: " + exception.getErrorCode()); + System.out.println("Dapr exception's message: " + exception.getMessage()); + System.out.println("Dapr exception's reason: " + exception.getStatusDetails().get( + DaprErrorDetails.ErrorDetailType.ERROR_INFO, + "reason", + TypeRef.STRING)); } - - System.out.println("Done"); } + System.out.println("Done"); } } ``` -The code uses the `DaprClient` created by the `DaprClientBuilder`. It tries to get a state from state store, but provides an unknown state store. It causes the Dapr sidecar to return an error, which is converted to a `DaprException` to the application. To be compatible with Project Reactor, `DaprException` extends from `RuntimeException` - making it an unchecked exception. Applications might also get an `IllegalArgumentException` when invoking methods with invalid input parameters that are validated at the client side. - -The Dapr client is also within a try-with-resource block to properly close the client at the end. ### Running the example -Run this example with the following command: - @@ -79,41 +76,30 @@ dapr run --app-id exception-example -- java -jar target/dapr-java-sdk-examples-e -Once running, the OutputBindingExample should print the output as follows: +Once running, the State Client Example should print the output as follows: ```txt -== APP == Error code: INVALID_ARGUMENT - -== APP == Error message: INVALID_ARGUMENT: state store Unknown state store is not found - -== APP == io.dapr.exceptions.DaprException: INVALID_ARGUMENT: state store Unknown state store is not found - -== APP == at io.dapr.exceptions.DaprException.propagate(DaprException.java:168) - -== APP == at io.dapr.client.DaprClientGrpc$2.onError(DaprClientGrpc.java:716) +== APP == Error code: ERR_PUBSUB_NOT_FOUND -== APP == at io.grpc.stub.ClientCalls$StreamObserverToCallListenerAdapter.onClose(ClientCalls.java:478) +== APP == Error message: ERR_PUBSUB_NOT_FOUND: pubsub unknown_pubsub is not found -== APP == at io.grpc.internal.DelayedClientCall$DelayedListener$3.run(DelayedClientCall.java:464) - -== APP == at io.grpc.internal.DelayedClientCall$DelayedListener.delayOrExecute(DelayedClientCall.java:428) - -== APP == at io.grpc.internal.DelayedClientCall$DelayedListener.onClose(DelayedClientCall.java:461) - -== APP == at io.grpc.internal.ClientCallImpl.closeObserver(ClientCallImpl.java:617) - -== APP == at io.grpc.internal.ClientCallImpl.access$300(ClientCallImpl.java:70) - -== APP == at io.grpc.internal.ClientCallImpl$ClientStreamListenerImpl$1StreamClosed.runInternal(ClientCallImpl.java:803) +== APP == Reason: DAPR_PUBSUB_NOT_FOUND +... -== APP == at io.grpc.internal.ClientCallImpl$ClientStreamListenerImpl$1StreamClosed.runInContext(ClientCallImpl.java:782) +``` -== APP == at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) +### Debug -== APP == at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:123) -... +You can further explore all the error details returned in the `DaprException` class. +Before running it in your favorite IDE (like IntelliJ), compile and run the Dapr sidecar first. +1. Pre-req: +```sh +# make sure you are in the `java-sdk` (root) directory. +./mvnw clean install ``` +2. From the examples directory, run: `dapr run --app-id exception-example --dapr-grpc-port=50001 --dapr-http-port=3500` +3. From your IDE click the play button on the client code and put break points where desired. ### Cleanup diff --git a/sdk-tests/src/test/java/io/dapr/it/TestUtils.java b/sdk-tests/src/test/java/io/dapr/it/TestUtils.java index d9d74394f..a9ef0354d 100644 --- a/sdk-tests/src/test/java/io/dapr/it/TestUtils.java +++ b/sdk-tests/src/test/java/io/dapr/it/TestUtils.java @@ -13,7 +13,9 @@ package io.dapr.it; +import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; +import io.dapr.utils.TypeRef; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.function.Executable; @@ -42,6 +44,24 @@ public static void assertThrowsDaprException( Assertions.assertEquals(expectedErrorMessage, daprException.getMessage()); } + public static void assertThrowsDaprExceptionWithReason( + String expectedErrorCode, + String expectedErrorMessage, + String expectedReason, + Executable executable) { + DaprException daprException = Assertions.assertThrows(DaprException.class, executable); + Assertions.assertEquals(expectedErrorCode, daprException.getErrorCode()); + Assertions.assertEquals(expectedErrorMessage, daprException.getMessage()); + Assertions.assertNotNull(daprException.getStatusDetails()); + Assertions.assertEquals( + expectedReason, + daprException.getStatusDetails().get( + DaprErrorDetails.ErrorDetailType.ERROR_INFO, + "reason", + TypeRef.STRING + )); + } + public static void assertThrowsDaprExceptionSubstring( String expectedErrorCode, String expectedErrorMessageSubstring, diff --git a/sdk-tests/src/test/java/io/dapr/it/actors/ActorTimerRecoveryIT.java b/sdk-tests/src/test/java/io/dapr/it/actors/ActorTimerRecoveryIT.java index 5c08adf4c..796aa4e66 100644 --- a/sdk-tests/src/test/java/io/dapr/it/actors/ActorTimerRecoveryIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/actors/ActorTimerRecoveryIT.java @@ -25,15 +25,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; import static io.dapr.it.Retry.callWithRetry; import static io.dapr.it.actors.MyActorTestUtils.fetchMethodCallLogs; -import static io.dapr.it.actors.MyActorTestUtils.validateMethodCalls; import static io.dapr.it.actors.MyActorTestUtils.validateMessageContent; +import static io.dapr.it.actors.MyActorTestUtils.validateMethodCalls; import static org.junit.jupiter.api.Assertions.assertNotEquals; public class ActorTimerRecoveryIT extends BaseIT { @@ -82,21 +81,17 @@ public void timerRecoveryTest() throws Exception { // Restarts app only. runs.left.stop(); - - // Pause a bit to let placements settle. - logger.info("Pausing 12 seconds to let placements settle."); - Thread.sleep(Duration.ofSeconds(12).toMillis()); - + // Cannot sleep between app's stop and start since it can trigger unhealthy actor in runtime and lose timers. + // Timers will survive only if the restart is "quick" and survives the runtime's actor health check. + // Starting in 1.13, sidecar is more sensitive to an app restart and will not keep actors active for "too long". runs.left.start(); - logger.debug("Pausing 10 seconds to allow timer to fire"); - Thread.sleep(10000); final List newLogs = new ArrayList<>(); callWithRetry(() -> { newLogs.clear(); newLogs.addAll(fetchMethodCallLogs(proxy)); validateMethodCalls(newLogs, METHOD_NAME, 3); - }, 5000); + }, 10000); // Check that the restart actually happened by confirming the old logs are not in the new logs. for (MethodEntryTracker oldLog: logs) { diff --git a/sdk-tests/src/test/java/io/dapr/it/pubsub/http/PubSubIT.java b/sdk-tests/src/test/java/io/dapr/it/pubsub/http/PubSubIT.java index 4490e1715..77b917450 100644 --- a/sdk-tests/src/test/java/io/dapr/it/pubsub/http/PubSubIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/pubsub/http/PubSubIT.java @@ -32,19 +32,13 @@ import io.dapr.it.DaprRun; import io.dapr.serializer.DaprObjectSerializer; import io.dapr.utils.TypeRef; -import org.junit.After; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; @@ -56,6 +50,7 @@ import static io.dapr.it.Retry.callWithRetry; import static io.dapr.it.TestUtils.assertThrowsDaprException; +import static io.dapr.it.TestUtils.assertThrowsDaprExceptionWithReason; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -119,14 +114,16 @@ public void publishPubSubNotFound(boolean useGrpc) throws Exception { try (DaprClient client = new DaprClientBuilder().build()) { if (useGrpc) { - assertThrowsDaprException( + assertThrowsDaprExceptionWithReason( "INVALID_ARGUMENT", - "INVALID_ARGUMENT: pubsub unknown pubsub not found", + "INVALID_ARGUMENT: pubsub unknown pubsub is not found", + "DAPR_PUBSUB_NOT_FOUND", () -> client.publishEvent("unknown pubsub", "mytopic", "payload").block()); } else { - assertThrowsDaprException( + assertThrowsDaprExceptionWithReason( "ERR_PUBSUB_NOT_FOUND", - "ERR_PUBSUB_NOT_FOUND: pubsub unknown pubsub not found", + "ERR_PUBSUB_NOT_FOUND: pubsub unknown pubsub is not found", + "DAPR_PUBSUB_NOT_FOUND", () -> client.publishEvent("unknown pubsub", "mytopic", "payload").block()); } } @@ -149,7 +146,7 @@ public void testBulkPublishPubSubNotFound(boolean useGrpc) throws Exception { try (DaprPreviewClient client = new DaprClientBuilder().buildPreviewClient()) { assertThrowsDaprException( "INVALID_ARGUMENT", - "INVALID_ARGUMENT: pubsub unknown pubsub not found", + "INVALID_ARGUMENT: pubsub unknown pubsub is not found", () -> client.publishEvents("unknown pubsub", "mytopic","text/plain", "message").block()); } } diff --git a/sdk/src/main/java/io/dapr/client/DaprHttp.java b/sdk/src/main/java/io/dapr/client/DaprHttp.java index 121c1c264..2102fa84c 100644 --- a/sdk/src/main/java/io/dapr/client/DaprHttp.java +++ b/sdk/src/main/java/io/dapr/client/DaprHttp.java @@ -17,6 +17,7 @@ import io.dapr.client.domain.Metadata; import io.dapr.config.Properties; import io.dapr.exceptions.DaprError; +import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; import io.dapr.utils.Version; import okhttp3.Call; @@ -73,6 +74,11 @@ public class DaprHttp implements AutoCloseable { private static final Set ALLOWED_CONTEXT_IN_HEADERS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("grpc-trace-bin", "traceparent", "tracestate"))); + /** + * Object mapper to parse DaprError with or without details. + */ + private static final ObjectMapper DAPR_ERROR_DETAILS_OBJECT_MAPPER = new ObjectMapper(); + /** * HTTP Methods supported. */ @@ -136,11 +142,6 @@ public int getStatusCode() { */ private static final byte[] EMPTY_BYTES = new byte[0]; - /** - * JSON Object Mapper. - */ - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - /** * Endpoint used to communicate to Dapr's HTTP endpoint. */ @@ -347,12 +348,13 @@ private static DaprError parseDaprError(byte[] json) { } try { - return OBJECT_MAPPER.readValue(json, DaprError.class); + return DAPR_ERROR_DETAILS_OBJECT_MAPPER.readValue(json, DaprError.class); } catch (IOException e) { throw new DaprException("UNKNOWN", new String(json, StandardCharsets.UTF_8)); } } + private static byte[] getBodyBytesOrEmptyArray(okhttp3.Response response) throws IOException { ResponseBody body = response.body(); if (body != null) { diff --git a/sdk/src/main/java/io/dapr/exceptions/DaprError.java b/sdk/src/main/java/io/dapr/exceptions/DaprError.java index 3400d9b57..e6c613203 100644 --- a/sdk/src/main/java/io/dapr/exceptions/DaprError.java +++ b/sdk/src/main/java/io/dapr/exceptions/DaprError.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Dapr Authors + * Copyright 2024 The Dapr Authors * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -14,7 +14,10 @@ package io.dapr.exceptions; import com.fasterxml.jackson.annotation.JsonAutoDetect; -import io.grpc.Status; + +import java.util.Collections; +import java.util.List; +import java.util.Map; /** * Represents an error message from Dapr. @@ -37,6 +40,11 @@ public class DaprError { */ private Integer code; + /** + * Details about the error. + */ + private List> details; + /** * Gets the error code. * @@ -44,7 +52,7 @@ public class DaprError { */ public String getErrorCode() { if ((errorCode == null) && (code != null)) { - return Status.fromCodeValue(code).getCode().name(); + return io.grpc.Status.fromCodeValue(code).getCode().name(); } return errorCode; } @@ -80,4 +88,24 @@ public DaprError setMessage(String message) { return this; } + /** + * Gets the error details. + * + * @return Error details. + */ + public List> getDetails() { + return details; + } + + /** + * Sets the error details. + * + * @param details Error details. + * @return This instance. + */ + public DaprError setDetails(List> details) { + this.details = Collections.unmodifiableList(details); + return this; + } + } diff --git a/sdk/src/main/java/io/dapr/exceptions/DaprErrorDetails.java b/sdk/src/main/java/io/dapr/exceptions/DaprErrorDetails.java new file mode 100644 index 000000000..f78dc31e6 --- /dev/null +++ b/sdk/src/main/java/io/dapr/exceptions/DaprErrorDetails.java @@ -0,0 +1,223 @@ +/* + * Copyright 2024 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.exceptions; + +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.rpc.Status; +import io.dapr.utils.TypeRef; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class DaprErrorDetails { + + static final DaprErrorDetails EMPTY_INSTANCE = new DaprErrorDetails((Status) null); + + private static final Map, ErrorDetailType> SUPPORTED_ERROR_TYPES = + Collections.unmodifiableMap(new HashMap<>() { + { + put(com.google.rpc.ErrorInfo.class, ErrorDetailType.ERROR_INFO); + put(com.google.rpc.RetryInfo.class, ErrorDetailType.RETRY_INFO); + put(com.google.rpc.DebugInfo.class, ErrorDetailType.DEBUG_INFO); + put(com.google.rpc.QuotaFailure.class, ErrorDetailType.QUOTA_FAILURE); + put(com.google.rpc.PreconditionFailure.class, ErrorDetailType.PRECONDITION_FAILURE); + put(com.google.rpc.BadRequest.class, ErrorDetailType.BAD_REQUEST); + put(com.google.rpc.RequestInfo.class, ErrorDetailType.REQUEST_INFO); + put(com.google.rpc.ResourceInfo.class, ErrorDetailType.RESOURCE_INFO); + put(com.google.rpc.Help.class, ErrorDetailType.HELP); + put(com.google.rpc.LocalizedMessage.class, ErrorDetailType.LOCALIZED_MESSAGE); + } + }); + + private static final Map> ERROR_TYPES_FQN_REVERSE_LOOKUP = + SUPPORTED_ERROR_TYPES.keySet().stream().collect(Collectors.toMap( + item -> generateErrorTypeFqn(item), + item -> item + )); + + /** + * Error status details. + */ + private final Map> map; + + public DaprErrorDetails(Status grpcStatus) { + this.map = parse(grpcStatus); + } + + public DaprErrorDetails(List> entries) { + this.map = parse(entries); + } + + /** + * Gets an attribute of an error detail. + * @param errorDetailType Type of the error detail. + * @param errAttribute Attribute of the error detail. + * @param typeRef Type of the value expected to be returned. + * @param Type of the value to be returned. + * @return Value of the attribute or null if not found. + */ + public T get(ErrorDetailType errorDetailType, String errAttribute, TypeRef typeRef) { + Map dictionary = map.get(errorDetailType); + if (dictionary == null) { + return null; + } + + return (T) dictionary.get(errAttribute); + } + + /** + * Parses status details from a gRPC Status. + * + * @param status The gRPC Status to parse details from. + * @return List containing parsed status details. + */ + private static Map> parse(Status status) { + if (status == null || status.getDetailsList() == null) { + return Collections.emptyMap(); + } + + Map> detailsList = new HashMap<>(); + List grpcDetailsList = status.getDetailsList(); + for (Any detail : grpcDetailsList) { + for (Map.Entry, ErrorDetailType> + supportedClazzAndType : SUPPORTED_ERROR_TYPES.entrySet()) { + Class clazz = supportedClazzAndType.getKey(); + ErrorDetailType errorDetailType = supportedClazzAndType.getValue(); + if (detail.is(clazz)) { + detailsList.put(errorDetailType, parseProtoMessage(detail, clazz)); + } + } + } + return Collections.unmodifiableMap(detailsList); + } + + private static Map> parse(List> entries) { + if ((entries == null) || entries.isEmpty()) { + return Collections.emptyMap(); + } + + Map> detailsList = new HashMap<>(); + for (Map entry : entries) { + Object type = entry.getOrDefault("@type", ""); + if (type == null) { + continue; + } + + Class clazz = ERROR_TYPES_FQN_REVERSE_LOOKUP.get(type.toString()); + if (clazz == null) { + continue; + } + + ErrorDetailType errorDetailType = SUPPORTED_ERROR_TYPES.get(clazz); + if (errorDetailType == null) { + continue; + } + + detailsList.put(errorDetailType, entry); + } + return Collections.unmodifiableMap(detailsList); + } + + private static Map parseProtoMessage( + Any detail, Class clazz) { + try { + T message = detail.unpack(clazz); + return messageToMap(message); + } catch (InvalidProtocolBufferException e) { + return Collections.singletonMap(e.getClass().getSimpleName(), e.getMessage()); + } + } + + /** + * Converts a Protocol Buffer (proto) message to a Map. + * + * @param message The proto message to be converted. + * @return A Map representing the fields of the proto message. + */ + private static Map messageToMap(Message message) { + Map result = new HashMap<>(); + Field[] fields = message.getClass().getDeclaredFields(); + + result.put("@type", generateErrorTypeFqn(message.getClass())); + + for (Field field : fields) { + if (field.isSynthetic() || Modifier.isStatic(field.getModifiers())) { + continue; + } + + String normalizedFieldName = field.getName().replaceAll("_$", ""); + try { + field.setAccessible(true); + Object value = field.get(message); + result.put(normalizedFieldName, value); + } catch (IllegalAccessException e) { + // no-op, just ignore this attribute. + } + } + + return Collections.unmodifiableMap(result); + } + + private static String generateErrorTypeFqn(Class clazz) { + String className = clazz.getName(); + + // trim the 'com.' to match the kit error details returned to users + return "type.googleapis.com/" + (className.startsWith("com.") ? className.substring(4) : className); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DaprErrorDetails that = (DaprErrorDetails) o; + return Objects.equals(map, that.map); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hash(map); + } + + public enum ErrorDetailType { + ERROR_INFO, + RETRY_INFO, + DEBUG_INFO, + QUOTA_FAILURE, + PRECONDITION_FAILURE, + BAD_REQUEST, + REQUEST_INFO, + RESOURCE_INFO, + HELP, + LOCALIZED_MESSAGE, + } + +} \ No newline at end of file diff --git a/sdk/src/main/java/io/dapr/exceptions/DaprException.java b/sdk/src/main/java/io/dapr/exceptions/DaprException.java index 0771e1fac..f87eb79e7 100644 --- a/sdk/src/main/java/io/dapr/exceptions/DaprException.java +++ b/sdk/src/main/java/io/dapr/exceptions/DaprException.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Dapr Authors + * Copyright 2024 The Dapr Authors * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -18,6 +18,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; /** @@ -30,13 +32,18 @@ public class DaprException extends RuntimeException { */ private String errorCode; + /** + * The status details for the error. + */ + private DaprErrorDetails errorDetails; + /** * New exception from a server-side generated error code and message. * * @param daprError Server-side error. */ public DaprException(DaprError daprError) { - this(daprError.getErrorCode(), daprError.getMessage()); + this(daprError.getErrorCode(), daprError.getMessage(), daprError.getDetails()); } /** @@ -66,8 +73,31 @@ public DaprException(Throwable exception) { * @param message Client-side error message. */ public DaprException(String errorCode, String message) { + this(errorCode, message, DaprErrorDetails.EMPTY_INSTANCE); + } + + /** + * New Exception from a client-side generated error code and message. + * + * @param errorCode Client-side error code. + * @param message Client-side error message. + * @param errorDetails Details of the error from runtime. + */ + public DaprException(String errorCode, String message, List> errorDetails) { + this(errorCode, message, new DaprErrorDetails(errorDetails)); + } + + /** + * New Exception from a client-side generated error code and message. + * + * @param errorCode Client-side error code. + * @param message Client-side error message. + * @param errorDetails Details of the error from runtime. + */ + public DaprException(String errorCode, String message, DaprErrorDetails errorDetails) { super(String.format("%s: %s", errorCode, message)); this.errorCode = errorCode; + this.errorDetails = errorDetails; } /** @@ -84,6 +114,22 @@ public DaprException(String errorCode, String message, Throwable cause) { this.errorCode = errorCode; } + /** + * New exception from a server-side generated error code and message. + * @param errorCode Client-side error code. + * @param message Client-side error message. + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A {@code null} value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @param errorDetails the status details for the error. + */ + public DaprException(String errorCode, String message, Throwable cause, DaprErrorDetails errorDetails) { + super(String.format("%s: %s", errorCode, emptyIfNull(message)), cause); + this.errorCode = errorCode; + this.errorDetails = errorDetails == null ? DaprErrorDetails.EMPTY_INSTANCE : errorDetails; + } + /** * Returns the exception's error code. * @@ -93,6 +139,10 @@ public String getErrorCode() { return this.errorCode; } + public DaprErrorDetails getStatusDetails() { + return this.errorDetails; + } + /** * Wraps an exception into DaprException (if not already DaprException). * @@ -189,10 +239,15 @@ public static RuntimeException propagate(Throwable exception) { while (e != null) { if (e instanceof StatusRuntimeException) { StatusRuntimeException statusRuntimeException = (StatusRuntimeException) e; + com.google.rpc.Status status = io.grpc.protobuf.StatusProto.fromThrowable(statusRuntimeException); + + DaprErrorDetails errorDetails = new DaprErrorDetails(status); + return new DaprException( statusRuntimeException.getStatus().getCode().toString(), statusRuntimeException.getStatus().getDescription(), - exception); + exception, + errorDetails); } e = e.getCause(); diff --git a/sdk/src/test/java/io/dapr/client/DaprClientGrpcTest.java b/sdk/src/test/java/io/dapr/client/DaprClientGrpcTest.java index bff92e405..be2e40ec6 100644 --- a/sdk/src/test/java/io/dapr/client/DaprClientGrpcTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprClientGrpcTest.java @@ -37,6 +37,7 @@ import io.dapr.v1.DaprProtos; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.grpc.protobuf.StatusProto; import io.grpc.stub.StreamObserver; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -2507,7 +2508,17 @@ public String toString() { } } - private static StatusRuntimeException newStatusRuntimeException(String status, String message) { - return new StatusRuntimeException(Status.fromCode(Status.Code.valueOf(status)).withDescription(message)); + public static StatusRuntimeException newStatusRuntimeException(String statusCode, String message) { + return new StatusRuntimeException(Status.fromCode(Status.Code.valueOf(statusCode)).withDescription(message)); + } + + public static StatusRuntimeException newStatusRuntimeException(String statusCode, String message, com.google.rpc.Status statusDetails) { + com.google.rpc.Status status = com.google.rpc.Status.newBuilder() + .setCode(Status.Code.valueOf(statusCode).value()) + .setMessage(message) + .addAllDetails(statusDetails.getDetailsList()) + .build(); + + return StatusProto.toStatusRuntimeException(status); } } diff --git a/sdk/src/test/java/io/dapr/client/DaprExceptionTest.java b/sdk/src/test/java/io/dapr/client/DaprExceptionTest.java new file mode 100644 index 000000000..7310480d8 --- /dev/null +++ b/sdk/src/test/java/io/dapr/client/DaprExceptionTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2024 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client; + +import com.google.protobuf.Any; +import com.google.rpc.ErrorInfo; +import com.google.rpc.ResourceInfo; +import io.dapr.exceptions.DaprErrorDetails; +import io.dapr.serializer.DefaultObjectSerializer; +import io.dapr.v1.DaprGrpc; +import io.dapr.v1.DaprProtos; +import io.grpc.StatusRuntimeException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; + +import java.io.IOException; + +import static io.dapr.client.DaprClientGrpcTest.newStatusRuntimeException; +import static io.dapr.utils.TestUtils.assertThrowsDaprException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DaprExceptionTest { + private GrpcChannelFacade channel; + private DaprGrpc.DaprStub daprStub; + private DaprClient client; + + @BeforeEach + public void setup() throws IOException { + channel = mock(GrpcChannelFacade.class); + daprStub = mock(DaprGrpc.DaprStub.class); + when(daprStub.withInterceptors(any())).thenReturn(daprStub); + DaprClient grpcClient = new DaprClientGrpc( + channel, daprStub, new DefaultObjectSerializer(), new DefaultObjectSerializer()); + client = new DaprClientProxy(grpcClient); + doNothing().when(channel).close(); + } + + @AfterEach + public void tearDown() throws Exception { + client.close(); + verify(channel).close(); + } + + @Test + public void daprExceptionWithMultipleDetailsThrownTest() { + ErrorInfo errorInfo = ErrorInfo.newBuilder() + .setDomain("dapr.io") + .setReason("fake") + .build(); + + ResourceInfo resourceInfo = ResourceInfo.newBuilder() + .setResourceName("") + .setResourceType("pubsub") + .setDescription("pubsub name is empty") + .build(); + + com.google.rpc.Status status = com.google.rpc.Status.newBuilder() + .setCode(io.grpc.Status.Code.INVALID_ARGUMENT.value()) + .setMessage("bad bad argument") + .addDetails(Any.pack(errorInfo)) + .addDetails(Any.pack(resourceInfo)) + .build(); + + doAnswer((Answer) invocation -> { + throw newStatusRuntimeException("INVALID_ARGUMENT", "bad bad argument", status); + }).when(daprStub).publishEvent(any(DaprProtos.PublishEventRequest.class), any()); + + DaprErrorDetails expectedStatusDetails = new DaprErrorDetails(status); + + assertThrowsDaprException( + StatusRuntimeException.class, + "INVALID_ARGUMENT", + "INVALID_ARGUMENT: bad bad argument", + expectedStatusDetails, + () -> client.publishEvent("pubsubname","topic", "object").block()); + } + + @Test + public void daprExceptionWithOneDetailThrownTest() { + ErrorInfo errorInfo = ErrorInfo.newBuilder() + .setDomain("dapr.io") + .setReason("DAPR_STATE_NOT_FOUND") + .build(); + + com.google.rpc.Status status = com.google.rpc.Status.newBuilder() + .setCode(io.grpc.Status.Code.INVALID_ARGUMENT.value()) + .setMessage("bad bad argument") + .addDetails(Any.pack(errorInfo)) + .build(); + + doAnswer((Answer) invocation -> { + throw newStatusRuntimeException("INVALID_ARGUMENT", "bad bad argument", status); + }).when(daprStub).getState(any(DaprProtos.GetStateRequest.class), any()); + + DaprErrorDetails expectedStatusDetails = new DaprErrorDetails(status); + + assertThrowsDaprException( + StatusRuntimeException.class, + "INVALID_ARGUMENT", + "INVALID_ARGUMENT: bad bad argument", + expectedStatusDetails, + () -> client.getState("Unknown state store", "myKey", String.class).block()); + } +} \ No newline at end of file diff --git a/sdk/src/test/java/io/dapr/client/DaprHttpTest.java b/sdk/src/test/java/io/dapr/client/DaprHttpTest.java index a0ca430aa..ae121e455 100644 --- a/sdk/src/test/java/io/dapr/client/DaprHttpTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprHttpTest.java @@ -13,7 +13,9 @@ package io.dapr.client; import io.dapr.config.Properties; +import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; +import io.dapr.utils.TypeRef; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import reactor.test.StepVerifier; @@ -186,7 +188,7 @@ public void invokePostMethodRuntime() throws IOException { DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, okHttpClient); Mono mono = daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, null, Context.empty()); - StepVerifier.create(mono).expectError(RuntimeException.class); + StepVerifier.create(mono).expectError(RuntimeException.class).verify(); } @Test @@ -197,7 +199,7 @@ public void invokePostDaprError() throws IOException { "{\"errorCode\":null,\"message\":null}")); DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, okHttpClient); Mono mono = daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, null, Context.empty()); - StepVerifier.create(mono).expectError(RuntimeException.class); + StepVerifier.create(mono).expectError(RuntimeException.class).verify(); } @Test @@ -208,7 +210,36 @@ public void invokePostMethodUnknownError() throws IOException { "{\"errorCode\":\"null\",\"message\":\"null\"}")); DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, okHttpClient); Mono mono = daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, null, Context.empty()); - StepVerifier.create(mono).expectError(RuntimeException.class); + StepVerifier.create(mono).expectError(RuntimeException.class).verify(); + } + + @Test + public void validateExceptionParsing() { + final String payload = "{" + + "\"errorCode\":\"ERR_PUBSUB_NOT_FOUND\"," + + "\"message\":\"pubsub abc is not found\"," + + "\"details\":[" + + "{" + + "\"@type\":\"type.googleapis.com/google.rpc.ErrorInfo\"," + + "\"domain\":\"dapr.io\"," + + "\"metadata\":{}," + + "\"reason\":\"DAPR_PUBSUB_NOT_FOUND\"" + + "}]}"; + mockInterceptor.addRule() + .post("http://127.0.0.1:3500/v1.0/pubsub/publish") + .respond(500, ResponseBody.create(MediaType.parse("application/json"), + payload)); + DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, okHttpClient); + Mono mono = daprHttp.invokeApi("POST", "v1.0/pubsub/publish".split("/"), null, null, Context.empty()); + StepVerifier.create(mono).expectErrorMatches(e -> { + assertEquals(DaprException.class, e.getClass()); + DaprException daprException = (DaprException)e; + assertEquals("ERR_PUBSUB_NOT_FOUND", daprException.getErrorCode()); + assertEquals("DAPR_PUBSUB_NOT_FOUND", + daprException.getStatusDetails() + .get(DaprErrorDetails.ErrorDetailType.ERROR_INFO, "reason", TypeRef.STRING)); + return true; + }).verify(); } /** diff --git a/sdk/src/test/java/io/dapr/utils/TestUtils.java b/sdk/src/test/java/io/dapr/utils/TestUtils.java index a9c701241..a53449d1a 100644 --- a/sdk/src/test/java/io/dapr/utils/TestUtils.java +++ b/sdk/src/test/java/io/dapr/utils/TestUtils.java @@ -13,12 +13,14 @@ package io.dapr.utils; +import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.function.Executable; import java.io.IOException; import java.net.ServerSocket; +import java.util.Map; public final class TestUtils { @@ -58,6 +60,20 @@ public static void assertThrowsDaprException( Assertions.assertEquals(expectedErrorMessage, daprException.getMessage()); } + public static void assertThrowsDaprException( + Class expectedType, + String expectedErrorCode, + String expectedErrorMessage, + DaprErrorDetails expectedStatusDetails, + Executable executable) { + DaprException daprException = Assertions.assertThrows(DaprException.class, executable); + Assertions.assertNotNull(daprException.getCause()); + Assertions.assertEquals(expectedType, daprException.getCause().getClass()); + Assertions.assertEquals(expectedErrorCode, daprException.getErrorCode()); + Assertions.assertEquals(expectedErrorMessage, daprException.getMessage()); + Assertions.assertEquals(expectedStatusDetails, daprException.getStatusDetails()); + } + public static int findFreePort() throws IOException { try (ServerSocket socket = new ServerSocket(0)) { socket.setReuseAddress(true);