From 99091db18cc5ba4d75e4410d355d91cc4dd0ce57 Mon Sep 17 00:00:00 2001 From: pbatko-da Date: Wed, 8 Dec 2021 13:09:21 +0100 Subject: [PATCH] [DPP-771][Error codes] Add readme.md and supplement docs with a concrete error handling example. (#11990) CHANGELOG_BEGIN CHANGELOG_END --- docs/scripts/live-preview.sh | 21 ++- docs/source/app-dev/grpc/error-codes.rst | 76 +++++++++++ ledger/error/README.md | 95 ++++++++++++++ .../scala/com/daml/error/ErrorCategory.scala | 1 + .../com/daml/error/samples/Example.scala | 123 ++++++++++++++++++ .../error/samples/SampleClientSideSpec.scala | 15 +++ 6 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 ledger/error/README.md create mode 100644 ledger/error/src/main/scala/com/daml/error/samples/Example.scala create mode 100644 ledger/error/src/test/suite/scala/com/daml/error/samples/SampleClientSideSpec.scala diff --git a/docs/scripts/live-preview.sh b/docs/scripts/live-preview.sh index 290ff571f3..714adfc453 100755 --- a/docs/scripts/live-preview.sh +++ b/docs/scripts/live-preview.sh @@ -54,6 +54,8 @@ TEMPLATES_DIR=$BUILD_DIR/source/_templates mkdir -p $TEMPLATES_DIR tar -zxf $BAZEL_BIN/templates/templates-tarball.tar.gz -C $TEMPLATES_DIR --strip-components=1 +GEN_ERROR_CODES=false + for arg in "$@" do if [ "$arg" = "--pdf" ]; then @@ -62,17 +64,10 @@ do cp -L ../../bazel-bin/docs/DigitalAssetSDK.pdf $BUILD_DIR/gen/_downloads fi if [ "$arg" = "--gen" ]; then - # NOTE: # $BUILD_DIR/source is a symlink into the versioned controlled directory with source .rst files. # When generating files into that directory make sure to remove them before this script ends. - # Error codes and error categories - bazel build //docs:generate-docs-error-codes-inventory-into-rst-file - cp -L ../../bazel-bin/docs/error-codes-inventory.rst $BUILD_DIR/source/app-dev/grpc/error-codes-inventory.rst.inc - bazel build //docs:generate-docs-error-categories-inventory-into-rst-file - cp -L ../../bazel-bin/docs/error-categories-inventory.rst $BUILD_DIR/source/app-dev/grpc/error-categories-inventory.rst.inc - # Hoogle bazel build //compiler/damlc:daml-base-hoogle.txt mkdir -p $BUILD_DIR/gen/hoogle_db @@ -92,9 +87,21 @@ do mkdir -p ../source/daml/stdlib tar xf ../../bazel-bin/compiler/damlc/daml-base-rst.tar.gz \ --strip-components 1 -C ../source/daml/stdlib + + fi + if [ "$arg" = "--gen" ] || [ "$arg" = "--gen-error-codes" ]; then + GEN_ERROR_CODES=true fi done +if [ "$GEN_ERROR_CODES" = "true" ]; then + # Error codes and error categories + bazel build //docs:generate-docs-error-codes-inventory-into-rst-file + cp -L ../../bazel-bin/docs/error-codes-inventory.rst $BUILD_DIR/source/app-dev/grpc/error-codes-inventory.rst.inc + bazel build //docs:generate-docs-error-categories-inventory-into-rst-file + cp -L ../../bazel-bin/docs/error-categories-inventory.rst $BUILD_DIR/source/app-dev/grpc/error-categories-inventory.rst.inc +fi + DATE=$(date +"%Y-%m-%d") echo { \"$DATE\" : \"$DATE\" } > $BUILD_DIR/gen/versions.json diff --git a/docs/source/app-dev/grpc/error-codes.rst b/docs/source/app-dev/grpc/error-codes.rst index 0d6c2a32a5..1e0d2d4af8 100644 --- a/docs/source/app-dev/grpc/error-codes.rst +++ b/docs/source/app-dev/grpc/error-codes.rst @@ -157,6 +157,82 @@ but there is no guarantee given that additional information will be preserved ac +Working with Error Codes +====================================== + +This example shows how a user can extract the relevant error information. + +.. code-block:: scala + + object SampleClientSide { + + import com.google.rpc.ResourceInfo + import com.google.rpc.{ErrorInfo, RequestInfo, RetryInfo} + import io.grpc.StatusRuntimeException + import scala.jdk.CollectionConverters._ + + def example(): Unit = { + try { + DummmyServer.serviceEndpointDummy() + } catch { + case e: StatusRuntimeException => + + // Converting to a status object. + val status = io.grpc.protobuf.StatusProto.fromThrowable(e) + + // Extracting error code id. + assert(status.getCode == 10) + + // Extracting error message, both + // machine oriented part: "MY_ERROR_CODE_ID(2,full-cor):", + // and human oriented part: "A user oriented message". + assert(status.getMessage == "MY_ERROR_CODE_ID(2,full-cor): A user oriented message") + + // Getting all the details + val rawDetails: Seq[com.google.protobuf.Any] = status.getDetailsList.asScala.toSeq + + // Extracting error code id, error category id and optionally additional metadata. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[ErrorInfo]) => + val v = any.unpack(classOf[ErrorInfo]) + assert(v.getReason == "MY_ERROR_CODE_ID") + assert(v.getMetadataMap.asScala.toMap == Map("category" -> "2", "foo" -> "bar")) + }.isDefined + } + + // Extracting full correlation id, if present. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[RequestInfo]) => + val v = any.unpack(classOf[RequestInfo]) + assert(v.getRequestId == "full-correlation-id-123456790") + }.isDefined + } + + // Extracting retry information if the error is retryable. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[RetryInfo]) => + val v = any.unpack(classOf[RetryInfo]) + assert(v.getRetryDelay.getSeconds == 123) + }.isDefined + } + + // Extracting resource if the error pertains to some well defined resource. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[ResourceInfo]) => + val v = any.unpack(classOf[ResourceInfo]) + assert(v.getResourceType == "CONTRACT_ID") + assert(v.getResourceName == "someContractId") + }.isDefined + } + } + } + } + + Error Codes Inventory ********************** diff --git a/ledger/error/README.md b/ledger/error/README.md new file mode 100644 index 0000000000..155c202843 --- /dev/null +++ b/ledger/error/README.md @@ -0,0 +1,95 @@ +# Table of Contents + +1. [Overview](#overview) +1. [Error codes](#error-codes) +1. [Error categories](#error-categories) +1. [Error groups](#error-groups) + +# Overview + +Error codes are typically returned as part of gRPC calls. + +The users mentioned later in this document might be participant operators, application developers or application users. + +# Error codes + +Base class: `com.daml.error.ErrorCode` + +## Error code definition + +Example: +``` + +object SomeErrorGroup extends ErrorGroup() { + + @Explanation("This explanation becomes part of the official documentation.") + @Resolution("This resolution becomes part of the official documentation.") + object MyNewErrorCode extends ErrorCode(id = "", + category = ErrorCategory.InvalidIndependentOfSystemState){ + + case class Reject(reason: String) + (implicit loggingContext: ContextualizedErrorLogger) + extends LoggingTransactionErrorImpl(cause = reason) + + } +} + +``` +In the example above `MyNewErrorCode` is a new error code which has its own unique error code `id` +and belongs to some error `category`. +`Reject` is a supplementary class to create a concrete instance of this error code. + +Things to note: +1. Each error code should be placed within some error group in order to render it in the correct section in the + official Daml documentation. +1. The descriptions from `Exaplantion` and `Resolution` annotations are used to automatically generate sections + of the official Daml documentation. Make sure they are clear and helpful to the users. +1. Error code ids and category assignment are communicated to the user and we consider them part of the public API. +1. You should consider creating a user migration guide for example when you: + - add, remove or change an error code id, + - change the set of error code ids that is being returned from a particular gRPC endpoint, + - change the category an error code belongs to. + +When adding a new error code make sure to add a unit test where you assert on the contents of the error as delivered to the users. + + +## Usage + +Typically you will want to immediately convert an error code into an instance of `io.grpc.StatusRuntimeException`: + +``` +MyNewErrorCode.ErrorCode("Msg").asGrpcError: StatusRuntimeException +``` + +In practice you will also need to provide some additional implicit values. Look for concrete examples in the codebase. + + + +# Error categories + +Base class: `com.daml.error.ErrorCategory`. + +You must not add, remove or change existing error categories unless you have good reasons to do so. +They are a part of the public API. + +Any such change is likely a breaking change especially that we encourage the users +to base their error handling logic on error categories rather than bare gRPC status codes or more fine-grained +error codes. + +Error categories, similar to error codes, use Scala annotations to provide descriptions that are used to automatically +generate sections of the official Daml documentation. Make sure they are clear and helpful to the users. + +NOTE: Currently we have a unique mapping from error categories to gRPC status codes. +This is incidental and might change in the future. + +# Error groups + +Base class: `com.daml.error.ErrorGroup.ErrorGroup`. + +Error groups are NOT part of the public API. +They only influence how we render error codes sections in the official Daml documentation. + + +# Handling errors on the client side + +See `com.daml.error.samples.SampleUserSide`. diff --git a/ledger/error/src/main/scala/com/daml/error/ErrorCategory.scala b/ledger/error/src/main/scala/com/daml/error/ErrorCategory.scala index a0749e6ad3..4d0edc7298 100644 --- a/ledger/error/src/main/scala/com/daml/error/ErrorCategory.scala +++ b/ledger/error/src/main/scala/com/daml/error/ErrorCategory.scala @@ -346,6 +346,7 @@ object ErrorCategory { } +// TODO error codes: `who` is not used? /** Default retryability information * * Every error category has a default retryability classification. diff --git a/ledger/error/src/main/scala/com/daml/error/samples/Example.scala b/ledger/error/src/main/scala/com/daml/error/samples/Example.scala new file mode 100644 index 0000000000..def3e0d603 --- /dev/null +++ b/ledger/error/src/main/scala/com/daml/error/samples/Example.scala @@ -0,0 +1,123 @@ +// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.daml.error.samples + +object DummmyServer { + + import com.daml.error.{ + BaseError, + ContextualizedErrorLogger, + DamlContextualizedErrorLogger, + ErrorCategory, + ErrorCategoryRetry, + ErrorClass, + ErrorCode, + ErrorResource, + } + import com.daml.logging.{ContextualizedLogger, LoggingContext} + + import scala.concurrent.duration.Duration + + object ErrorCodeFoo + extends ErrorCode(id = "MY_ERROR_CODE_ID", ErrorCategory.ContentionOnSharedResources)( + ErrorClass.root() + ) { + + case class Error(message: String) extends BaseError.Impl(cause = message) { + override def loggingContext: ContextualizedErrorLogger = new DamlContextualizedErrorLogger( + ContextualizedLogger.get(getClass), + LoggingContext.newLoggingContext(identity), + Some("full-correlation-id-123456790"), + ) + + override def resources: Seq[(ErrorResource, String)] = Seq( + ErrorResource.ContractId -> "someContractId" + ) + + override def retryable: Option[ErrorCategoryRetry] = Some( + ErrorCategoryRetry("me", Duration("123 s")) + ) + + override def context: Map[String, String] = Map("foo" -> "bar") + } + + } + + def serviceEndpointDummy(): Unit = { + throw ErrorCodeFoo.Error("A user oriented message").asGrpcError + } + +} + +/** This shows how a user can handle error codes. + * In particular it shows how to extract useful information from the signalled exception with minimal library dependencies. + * + * NOTE: This class is given as an example in the official Daml documentation. If you change it here, change it also in the docs. + */ +object SampleClientSide { + + import com.google.rpc.ResourceInfo + import com.google.rpc.{ErrorInfo, RequestInfo, RetryInfo} + import io.grpc.StatusRuntimeException + import scala.jdk.CollectionConverters._ + + def example(): Unit = { + try { + DummmyServer.serviceEndpointDummy() + } catch { + case e: StatusRuntimeException => + // Converting to a status object. + val status = io.grpc.protobuf.StatusProto.fromThrowable(e) + + // Extracting error code id. + assert(status.getCode == 10) + + // Extracting error message, both + // machine oriented part: "MY_ERROR_CODE_ID(2,full-cor):", + // and human oriented part: "A user oriented message". + assert(status.getMessage == "MY_ERROR_CODE_ID(2,full-cor): A user oriented message") + + // Getting all the details + val rawDetails: Seq[com.google.protobuf.Any] = status.getDetailsList.asScala.toSeq + + // Extracting error code id, error category id and optionally additional metadata. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[ErrorInfo]) => + val v = any.unpack(classOf[ErrorInfo]) + assert(v.getReason == "MY_ERROR_CODE_ID") + assert(v.getMetadataMap.asScala.toMap == Map("category" -> "2", "foo" -> "bar")) + }.isDefined + } + + // Extracting full correlation id, if present. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[RequestInfo]) => + val v = any.unpack(classOf[RequestInfo]) + assert(v.getRequestId == "full-correlation-id-123456790") + }.isDefined + } + + // Extracting retry information if the error is retryable. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[RetryInfo]) => + val v = any.unpack(classOf[RetryInfo]) + assert(v.getRetryDelay.getSeconds == 123) + }.isDefined + } + + // Extracting resource if the error pertains to some well defined resource. + assert { + rawDetails.collectFirst { + case any if any.is(classOf[ResourceInfo]) => + val v = any.unpack(classOf[ResourceInfo]) + assert(v.getResourceType == "CONTRACT_ID") + assert(v.getResourceName == "someContractId") + }.isDefined + } + } + } +} diff --git a/ledger/error/src/test/suite/scala/com/daml/error/samples/SampleClientSideSpec.scala b/ledger/error/src/test/suite/scala/com/daml/error/samples/SampleClientSideSpec.scala new file mode 100644 index 0000000000..aaf524a3e4 --- /dev/null +++ b/ledger/error/src/test/suite/scala/com/daml/error/samples/SampleClientSideSpec.scala @@ -0,0 +1,15 @@ +// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.daml.error.samples + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class SampleClientSideSpec extends AnyFlatSpec with Matchers { + + it should "run successfully" in { + SampleClientSide.example() + } + +}