Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

converting server errors to proper client errors #11184

Merged
merged 29 commits into from
Oct 19, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
314026b
a model for trapping client errors in Scala bindings shim and reporti…
S11001001 Oct 8, 2021
a9c4e00
clean up some nesting with an alias
S11001001 Oct 8, 2021
f9062b7
filter out client-side command service errors
S11001001 Oct 12, 2021
dfbdcb0
fix flattening error propagation of CommandService errors in endpoints
S11001001 Oct 12, 2021
b115e4a
remove todo
S11001001 Oct 12, 2021
f10f0a3
Daml evaluation triggers INVALID_ARGUMENT; handle this for creates/ex…
S11001001 Oct 13, 2021
ec16d19
clean up lookupResult
S11001001 Oct 13, 2021
02bb076
remove stripLeft utility; it is unused
S11001001 Oct 13, 2021
afbbe64
proper error propagation for /parties endpoint
S11001001 Oct 13, 2021
ddd199a
Merge commit 'b7389885e8ae2e97c34b8cdbc7dc7b11d9330c64' into 11154-fe…
S11001001 Oct 13, 2021
24388a6
map grpc status codes to HTTP error codes
S11001001 Oct 14, 2021
4291e39
add a case to pass-through gRPC errors in Endpoints errors
S11001001 Oct 14, 2021
828ffe8
handle gRPC status in all explicit top-level catches
S11001001 Oct 14, 2021
0589aff
pass through gRPC errors in CommandService as well
S11001001 Oct 15, 2021
9f89ef3
treat a gRPC status anywhere in the causal chain as indicating partic…
S11001001 Oct 15, 2021
c6978ff
propagate ContractsService errors without assuming they will always b…
S11001001 Oct 15, 2021
b5f7f4b
filter ServerErrors' contents when rendering errorful streams
S11001001 Oct 19, 2021
845a948
log errors from websocket output instead of rendering full messages
S11001001 Oct 19, 2021
4f7a9d6
hide message in ServerError case
S11001001 Oct 19, 2021
2715a4f
remove Aborted
S11001001 Oct 19, 2021
3d1a543
Merge commit '46f6877ee5688ec9bd3ff3229d7827af2e865eff' into 11154-fe…
S11001001 Oct 19, 2021
facbb44
transfer with bad contract ID now returns 409
S11001001 Oct 19, 2021
68ad766
mention new error codes
S11001001 Oct 19, 2021
e98e86a
add changelog
S11001001 Oct 19, 2021
ac5c643
remove unused Show and liftErr utility
S11001001 Oct 19, 2021
e5b0e0e
adapt daml-script to new error codes
S11001001 Oct 19, 2021
7115456
adapt typescript tests to new error codes
S11001001 Oct 19, 2021
56aba1c
adapt json-api failure tests to new error codes
S11001001 Oct 19, 2021
eb6387c
Merge commit '9b00a1aab5ac1f4b9058739a91ca02597d84dda5' into 11154-fe…
S11001001 Oct 19, 2021
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
10 changes: 7 additions & 3 deletions docs/source/json-api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -209,23 +209,27 @@ HTTP Status Codes
The **JSON API** reports errors using standard HTTP status codes. It divides HTTP status codes into 3 groups indicating:

1. success (200)
2. failure due to a client-side problem (400, 401, 404)
3. failure due to a server-side problem (500)
2. failure due to a client-side problem (400, 401, 403, 404, 409, 429)
3. failure due to a server-side problem (500, 503)

The **JSON API** can return one of the following HTTP status codes:

- 200 - OK
- 400 - Bad Request (Client Error)
- 401 - Unauthorized, authentication required
- 403 - Forbidden, insufficient permissions
- 404 - Not Found
- 409 - Conflict, contract ID or key missing or duplicated
- 429 - Too Many Requests, ledger server has hit configured limit of in-flight commands
- 500 - Internal Server Error
- 503 - Service Unavailable, ledger server is not running yet or has been shut down

If a client's HTTP GET or POST request reaches an API endpoint, the corresponding response will always contain a JSON object with a ``status`` field, either an ``errors`` or ``result`` field and an optional ``warnings``:

.. code-block:: none

{
"status": <400 | 401 | 404 | 500>,
"status": <400 | 401 | 403 | 404 | 409 | 429 | 500 | 503>,
"errors": <JSON array of strings>, | "result": <JSON object or array>,
["warnings": <JSON object> ]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1138,8 +1138,8 @@ abstract class AbstractHttpServiceIntegrationTest
val exerciseJson: JsValue = encodeExercise(encoder)(iouExerciseTransferCommand(contractId))
postJsonRequest(uri.withPath(Uri.Path("/v1/exercise")), exerciseJson)
.flatMap { case (status, output) =>
status shouldBe StatusCodes.InternalServerError
assertStatus(output, StatusCodes.InternalServerError)
status shouldBe StatusCodes.Conflict
assertStatus(output, StatusCodes.Conflict)
expectedOneErrorMessage(output) should include(
s"Contract could not be found with id ContractId($contractIdString)"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.daml.http.util.FutureUtil._
import com.daml.http.util.IdentifierConverters.refApiIdentifier
import com.daml.http.util.Logging.{InstanceUUID, RequestID}
import com.daml.http.util.{Commands, Transactions}
import LedgerClientJwt.Grpc
import com.daml.jwt.domain.Jwt
import com.daml.ledger.api.refinements.{ApiTypes => lar}
import com.daml.ledger.api.{v1 => lav1}
Expand All @@ -30,7 +31,7 @@ import scalaz.std.scalaFuture._
import scalaz.syntax.show._
import scalaz.syntax.std.option._
import scalaz.syntax.traverse._
import scalaz.{-\/, EitherT, Show, \/, \/-}
import scalaz.{-\/, EitherT, \/, \/-}

import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
Expand Down Expand Up @@ -130,16 +131,30 @@ class CommandService(
et.run
}

private def logResult[A](op: Symbol, fa: Future[A])(implicit
private def logResult[A](
op: Symbol,
fa: Grpc.EFuture[Grpc.Category.SubmitError, A],
)(implicit
lc: LoggingContextOf[InstanceUUID with RequestID]
): ET[A] = {
val opName = op.name
EitherT {
fa.transformWith {
case Failure(e) =>
logger.error(s"$opName failure", e)
Future.successful(-\/(Error(None, e.toString)))
case Success(a) =>
Future.successful(-\/(e match {
case Grpc.StatusEnvelope(status) => GrpcError(status)
case _ => InternalError(None, e.toString)
}))
case Success(-\/(e)) =>
logger.error(s"$opName failure: ${e.e}: ${e.message}")
import Grpc.Category._
val tagged = e.e match {
case PermissionDenied => -\/(PermissionDenied)
case InvalidArgument => \/-(InvalidArgument)
}
Future.successful(-\/(ClientError(tagged, e.message)))
case Success(\/-(a)) =>
logger.debug(s"$opName success: $a")
Future.successful(\/-(a))
}
Expand Down Expand Up @@ -218,7 +233,7 @@ class CommandService(
case Seq(x) => \/-(x)
case xs @ _ =>
-\/(
Error(
InternalError(
Some(Symbol("exactlyOneActiveContract")),
s"Expected exactly one active contract, got: $xs",
)
Expand All @@ -230,7 +245,10 @@ class CommandService(
): Error \/ ImmArraySeq[ActiveContract[lav1.value.Value]] =
response.transaction
.toRightDisjunction(
Error(Some(Symbol("activeContracts")), s"Received response without transaction: $response")
InternalError(
Some(Symbol("activeContracts")),
s"Received response without transaction: $response",
)
)
.flatMap(activeContracts)

Expand All @@ -240,15 +258,18 @@ class CommandService(
Transactions
.allCreatedEvents(tx)
.traverse(ActiveContract.fromLedgerApi(_))
.leftMap(e => Error(Some(Symbol("activeContracts")), e.shows))
.leftMap(e => InternalError(Some(Symbol("activeContracts")), e.shows))
}

private def contracts(
response: lav1.command_service.SubmitAndWaitForTransactionTreeResponse
): Error \/ List[Contract[lav1.value.Value]] =
response.transaction
.toRightDisjunction(
Error(Some(Symbol("contracts")), s"Received response without transaction: $response")
InternalError(
Some(Symbol("contracts")),
s"Received response without transaction: $response",
)
)
.flatMap(contracts)

Expand All @@ -257,7 +278,7 @@ class CommandService(
): Error \/ List[Contract[lav1.value.Value]] =
Contract
.fromTransactionTree(tx)
.leftMap(e => Error(Some(Symbol("contracts")), e.shows))
.leftMap(e => InternalError(Some(Symbol("contracts")), e.shows))
.map(_.toList)

private def exerciseResult(
Expand All @@ -270,7 +291,7 @@ class CommandService(
} yield exResult

result.toRightDisjunction(
Error(
InternalError(
Some(Symbol("choiceArgument")),
s"Cannot get exerciseResult from the first ExercisedEvent of gRPC response: ${a.toString}",
)
Expand All @@ -287,16 +308,13 @@ class CommandService(
}

object CommandService {
final case class Error(id: Option[Symbol], message: String)

object Error {
implicit val errorShow: Show[Error] = Show shows {
case Error(None, message) =>
s"CommandService Error, $message"
case Error(Some(id), message) =>
s"CommandService Error, $id: $message"
}
}
sealed abstract class Error extends Product with Serializable
final case class ClientError(
id: Grpc.Category.PermissionDenied \/ Grpc.Category.InvalidArgument,
message: String,
) extends Error
final case class GrpcError(status: io.grpc.Status) extends Error
final case class InternalError(id: Option[Symbol], message: String) extends Error

private type ET[A] = EitherT[Future, Error, A]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,8 @@ class ContractsService(

private def lookupResult(
errorOrAc: Option[Error \/ domain.ActiveContract[LfValue]]
): Future[Option[domain.ActiveContract[LfValue]]] = {
errorOrAc.cata(x => toFuture(x).map(Some(_)), Future.successful(None))
}
): Future[Option[domain.ActiveContract[LfValue]]] =
errorOrAc traverse (toFuture(_))

private def isContractId(k: domain.ContractId)(a: domain.ActiveContract[LfValue]): Boolean =
(a.contractId: domain.ContractId) == k
Expand Down Expand Up @@ -405,7 +404,7 @@ class ContractsService(
queryParams: InMemoryQuery,
)(implicit
lc: LoggingContextOf[InstanceUUID]
): Source[Error \/ domain.ActiveContract[LfValue], NotUsed] = {
): Source[InternalError \/ domain.ActiveContract[LfValue], NotUsed] = {

logger.debug(
s"Searching in memory, parties: $parties, templateIds: $templateIds, queryParms: $queryParams"
Expand All @@ -423,7 +422,7 @@ class ContractsService(
val (errors, converted) = step.toInsertDelete.partitionMapPreservingIds { apiEvent =>
domain.ActiveContract
.fromLedgerApi(apiEvent)
.leftMap(e => Error(Symbol("searchInMemory"), e.shows))
.leftMap(e => InternalError(Symbol("searchInMemory"), e.shows))
.flatMap(apiAcToLfAc): Error \/ Ac
}
val convertedInserts = converted.inserts filter { ac =>
Expand Down Expand Up @@ -529,7 +528,7 @@ class ContractsService(
ac: domain.ActiveContract[ApiValue]
): Error \/ domain.ActiveContract[LfValue] =
ac.traverse(ApiValueToLfValueConverter.apiValueToLfValue)
.leftMap(e => Error(Symbol("apiAcToLfAc"), e.shows))
.leftMap(e => InternalError(Symbol("apiAcToLfAc"), e.shows))

private[http] def valuePredicate(
templateId: domain.TemplateId.RequiredPkg,
Expand All @@ -539,7 +538,7 @@ class ContractsService(

private def lfValueToJsValue(a: LfValue): Error \/ JsValue =
\/.attempt(LfValueCodec.apiValueToJsValue(a))(e =>
Error(Symbol("lfValueToJsValue"), e.description)
InternalError(Symbol("lfValueToJsValue"), e.description)
)

private[http] def resolveTemplateIds[Tid <: domain.TemplateId.OptionalPkg](
Expand Down Expand Up @@ -644,7 +643,9 @@ object ContractsService {
): Source[Error \/ domain.ActiveContract[LfV], NotUsed]
}

case class Error(id: Symbol, message: String)
final case class Error(id: Symbol, message: String)
Copy link
Contributor

Choose a reason for hiding this comment

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

can we just pick oneError/ InternalError given any issues here ( ContractsService ) should always be mapped as server error, can you explain the notion for the additional type ? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right now we only have one error constructor so I would rather it not be a sum type. However, that isn't guaranteed, i.e.

any issues here ( ContractsService ) should always be mapped as server error

is not an invariant, it is a coincidence of the current implementation, so since I went to the trouble of determining that particular expressions were constructing internal errors, I should go ahead and document that fact (with actual typed expressions, not comments, as is my wont).

So should we need to split the type and add a client error data ctor, the existing invocations already self-document that they don't use the new client error ctor.

private type InternalError = Error
private[http] val InternalError: Error.type = Error

object Error {
implicit val errorShow: Show[Error] = Show shows { e =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,10 @@ import com.daml.http.util.{ProtobufByteStrings, toLedgerId}
import com.daml.jwt.domain.Jwt
import com.daml.ledger.api.{v1 => lav1}
import com.daml.logging.LoggingContextOf.withEnrichedLoggingContext
import com.daml.scalautil.ExceptionOps._
import scalaz.std.scalaFuture._
import scalaz.syntax.std.option._
import scalaz.syntax.show._
import scalaz.syntax.traverse._
import scalaz.{-\/, EitherT, NonEmptyList, Show, Traverse, \/, \/-}
import scalaz.{-\/, EitherT, NonEmptyList, Traverse, \/, \/-}
import spray.json._

import scala.concurrent.duration.FiniteDuration
Expand Down Expand Up @@ -454,7 +452,8 @@ class Endpoints(
def allParties(req: HttpRequest)(implicit
lc: LoggingContextOf[InstanceUUID with RequestID]
): ET[domain.SyncResponse[List[domain.PartyDetails]]] =
proxyWithoutCommand((jwt, _) => partiesService.allParties(jwt))(req).map(domain.OkResponse(_))
proxyWithoutCommand((jwt, _) => partiesService.allParties(jwt))(req)
.flatMap(pd => either(pd map (domain.OkResponse(_))))

def parties(req: HttpRequest)(implicit
lc: LoggingContextOf[InstanceUUID with RequestID]
Expand Down Expand Up @@ -522,31 +521,24 @@ class Endpoints(
} yield domain.OkResponse(())

private def handleFutureEitherFailure[A, B](fa: Future[A \/ B])(implicit
A: IntoServerError[A],
A: IntoEndpointsError[A],
lc: LoggingContextOf[InstanceUUID with RequestID],
): Future[Error \/ B] =
fa.map(_ leftMap A.run).recover { case NonFatal(e) =>
logger.error("Future failed", e)
-\/(ServerError(e.description))
}
fa.map(_ leftMap A.run)
.recover(logException("Future") andThen Error.fromThrowable andThen (-\/(_)))

private def handleFutureFailure[E >: ServerError, A](fa: Future[A])(implicit
private def handleFutureFailure[A](fa: Future[A])(implicit
lc: LoggingContextOf[InstanceUUID with RequestID]
): Future[E \/ A] =
fa.map(a => \/-(a)).recover { case NonFatal(e) =>
logger.error("Future failed", e)
-\/(ServerError(e.description))
}
): Future[Error \/ A] =
fa.map(a => \/-(a)).recover(logException("Future") andThen Error.fromThrowable andThen (-\/(_)))

private def handleSourceFailure[E: Show, A](implicit
lc: LoggingContextOf[InstanceUUID with RequestID]
private def handleSourceFailure[E, A](implicit
E: IntoEndpointsError[E],
lc: LoggingContextOf[InstanceUUID with RequestID],
): Flow[E \/ A, Error \/ A, NotUsed] =
Flow
.fromFunction((_: E \/ A).liftErr[Error](ServerError))
.recover { case NonFatal(e) =>
logger.error("Source failed", e)
-\/(ServerError(e.description))
}
.fromFunction((_: E \/ A).leftMap(E.run))
.recover(logException("Source") andThen Error.fromThrowable andThen (-\/(_)))

private def httpResponse(
output: Future[Error \/ SearchResult[Error \/ JsValue]]
Expand All @@ -556,17 +548,22 @@ class Endpoints(
case -\/(e) => httpResponseError(e)
case \/-(searchResult) => httpResponse(searchResult)
}
.recover { case NonFatal(e) =>
httpResponseError(ServerError(e.description))
}
.recover(Error.fromThrowable andThen (httpResponseError(_)))

private[this] def logException(fromWhat: String)(implicit
lc: LoggingContextOf[InstanceUUID with RequestID]
): Throwable PartialFunction Throwable = { case NonFatal(e) =>
logger.error(s"$fromWhat failed", e)
e
}

private def httpResponse(searchResult: SearchResult[Error \/ JsValue]): HttpResponse = {
import json.JsonProtocol._

val response: Source[ByteString, NotUsed] = searchResult match {
case domain.OkResponse(result, warnings, _) =>
val warningsJsVal: Option[JsValue] = warnings.map(SprayJson.encodeUnsafe(_))
ResponseFormats.resultJsObject(result, warningsJsVal)
ResponseFormats.resultJsObject(result via filterStreamErrors, warningsJsVal)
case error: domain.ErrorResponse =>
val jsVal: JsValue = SprayJson.encodeUnsafe(error)
Source.single(ByteString(jsVal.compactPrint))
Expand All @@ -579,6 +576,12 @@ class Endpoints(
)
}

private[this] def filterStreamErrors[E, A]: Flow[Error \/ A, Error \/ A, NotUsed] =
Flow[Error \/ A].map {
case -\/(ServerError(_)) => -\/(ServerError("internal server error"))
case o => o
}

private def httpResponse[A: JsonWriter](
result: ET[domain.SyncResponse[A]]
)(implicit metrics: Metrics): Future[HttpResponse] = {
Expand All @@ -598,9 +601,7 @@ class Endpoints(
status = status,
)
}
.recover { case NonFatal(e) =>
httpResponseError(ServerError(e.description))
},
.recover(Error.fromThrowable andThen (httpResponseError(_))),
)
}

Expand Down Expand Up @@ -747,12 +748,27 @@ object Endpoints {

private type LfValue = lf.value.Value

private final class IntoServerError[-A](val run: A => Error) extends AnyVal
private object IntoServerError extends IntoServerErrorLow {
implicit val id: IntoServerError[Error] = new IntoServerError(identity)
}
private sealed abstract class IntoServerErrorLow {
implicit def shown[A: Show]: IntoServerError[A] = new IntoServerError(a => ServerError(a.shows))
private final class IntoEndpointsError[-A](val run: A => Error) extends AnyVal
private object IntoEndpointsError {
import LedgerClientJwt.Grpc.Category

implicit val id: IntoEndpointsError[Error] = new IntoEndpointsError(identity)

implicit val fromCommands: IntoEndpointsError[CommandService.Error] = new IntoEndpointsError({
case CommandService.InternalError(id, message) =>
ServerError(s"command service error, ${id.cata(sym => s"${sym.name}: ", "")}$message")
case CommandService.GrpcError(status) =>
ParticipantServerError(status.getCode, Option(status.getDescription))
case CommandService.ClientError(-\/(Category.PermissionDenied), message) =>
Unauthorized(message)
case CommandService.ClientError(\/-(Category.InvalidArgument), message) =>
InvalidUserInput(message)
})

implicit val fromContracts: IntoEndpointsError[ContractsService.Error] =
new IntoEndpointsError({ case ContractsService.InternalError(id, msg) =>
ServerError(s"contracts service error, ${id.name}: $msg")
})
}

private def lfValueToJsValue(a: LfValue): Error \/ JsValue =
Expand Down
Loading