From acd2e93a2525b4337ab6c983dc976c5d3ac0d546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20Gulbrands=C3=B8y?= Date: Tue, 10 Aug 2021 11:53:09 +0200 Subject: [PATCH] Add custom trailers to GrpcError (#493) --- lib/src/client/call.dart | 2 +- lib/src/shared/status.dart | 95 ++++++++++++++++++++---------- test/client_tests/client_test.dart | 30 ++++++++++ test/src/client_utils.dart | 20 ++++++- 4 files changed, 113 insertions(+), 34 deletions(-) diff --git a/lib/src/client/call.dart b/lib/src/client/call.dart index aaae604..f89f68d 100644 --- a/lib/src/client/call.dart +++ b/lib/src/client/call.dart @@ -339,7 +339,7 @@ class ClientCall implements Response { /// If there's an error status then process it as a response error. void _checkForErrorStatus(Map trailers) { - final error = grpcErrorFromTrailers(trailers); + final error = grpcErrorDetailsFromTrailers(trailers); if (error != null) { _responseError(error); } diff --git a/lib/src/shared/status.dart b/lib/src/shared/status.dart index 1fe753f..fecfc31 100644 --- a/lib/src/shared/status.dart +++ b/lib/src/shared/status.dart @@ -155,32 +155,38 @@ class GrpcError implements Exception { final int code; final String? message; final Object? rawResponse; + final Map? trailers; final List? details; /// Custom error code. - GrpcError.custom(this.code, [this.message, this.details, this.rawResponse]); + GrpcError.custom(this.code, + [this.message, this.details, this.rawResponse, this.trailers = const {}]); /// The operation completed successfully. GrpcError.ok([this.message, this.details, this.rawResponse]) - : code = StatusCode.ok; + : trailers = const {}, + code = StatusCode.ok; /// The operation was cancelled (typically by the caller). GrpcError.cancelled([this.message, this.details, this.rawResponse]) - : code = StatusCode.cancelled; + : trailers = const {}, + code = StatusCode.cancelled; /// Unknown error. An example of where this error may be returned is if a /// Status value received from another address space belongs to an error-space /// that is not known in this address space. Also errors raised by APIs that /// do not return enough error information may be converted to this error. GrpcError.unknown([this.message, this.details, this.rawResponse]) - : code = StatusCode.unknown; + : trailers = const {}, + code = StatusCode.unknown; /// Client specified an invalid argument. Note that this differs from /// [failedPrecondition]. [invalidArgument] indicates arguments that are /// problematic regardless of the state of the system (e.g., a malformed file /// name). GrpcError.invalidArgument([this.message, this.details, this.rawResponse]) - : code = StatusCode.invalidArgument; + : trailers = const {}, + code = StatusCode.invalidArgument; /// Deadline expired before operation could complete. For operations that /// change the state of the system, this error may be returned even if the @@ -188,16 +194,19 @@ class GrpcError implements Exception { /// from a server could have been delayed long enough for the deadline to /// expire. GrpcError.deadlineExceeded([this.message, this.details, this.rawResponse]) - : code = StatusCode.deadlineExceeded; + : trailers = const {}, + code = StatusCode.deadlineExceeded; /// Some requested entity (e.g., file or directory) was not found. GrpcError.notFound([this.message, this.details, this.rawResponse]) - : code = StatusCode.notFound; + : trailers = const {}, + code = StatusCode.notFound; /// Some entity that we attempted to create (e.g., file or directory) already /// exists. GrpcError.alreadyExists([this.message, this.details, this.rawResponse]) - : code = StatusCode.alreadyExists; + : trailers = const {}, + code = StatusCode.alreadyExists; /// The caller does not have permission to execute the specified operation. /// [permissionDenied] must not be used for rejections caused by exhausting @@ -205,12 +214,14 @@ class GrpcError implements Exception { /// [permissionDenied] must not be used if the caller cannot be identified /// (use [unauthenticated] instead for those errors). GrpcError.permissionDenied([this.message, this.details, this.rawResponse]) - : code = StatusCode.permissionDenied; + : trailers = const {}, + code = StatusCode.permissionDenied; /// Some resource has been exhausted, perhaps a per-user quota, or perhaps the /// entire file system is out of space. GrpcError.resourceExhausted([this.message, this.details, this.rawResponse]) - : code = StatusCode.resourceExhausted; + : trailers = const {}, + code = StatusCode.resourceExhausted; /// Operation was rejected because the system is not in a state required for /// the operation's execution. For example, directory to be deleted may be @@ -227,7 +238,8 @@ class GrpcError implements Exception { /// returned since the client should not retry unless they have first /// fixed up the directory by deleting files from it. GrpcError.failedPrecondition([this.message, this.details, this.rawResponse]) - : code = StatusCode.failedPrecondition; + : trailers = const {}, + code = StatusCode.failedPrecondition; /// The operation was aborted, typically due to a concurrency issue like /// sequencer check failures, transaction aborts, etc. @@ -235,7 +247,8 @@ class GrpcError implements Exception { /// See litmus test above for deciding between [failedPrecondition], /// [aborted], and [unavailable]. GrpcError.aborted([this.message, this.details, this.rawResponse]) - : code = StatusCode.aborted; + : trailers = const {}, + code = StatusCode.aborted; /// Operation was attempted past the valid range. E.g., seeking or reading /// past end of file. @@ -251,16 +264,19 @@ class GrpcError implements Exception { /// when it applies so that callers who are iterating through a space can /// easily look for an [outOfRange] error to detect when they are done. GrpcError.outOfRange([this.message, this.details, this.rawResponse]) - : code = StatusCode.outOfRange; + : trailers = const {}, + code = StatusCode.outOfRange; /// Operation is not implemented or not supported/enabled in this service. GrpcError.unimplemented([this.message, this.details, this.rawResponse]) - : code = StatusCode.unimplemented; + : trailers = const {}, + code = StatusCode.unimplemented; /// Internal errors. Means some invariants expected by underlying system has /// been broken. If you see one of these errors, something is very broken. // TODO(sigurdm): This should probably not be an [Exception]. - GrpcError.internal([this.message, this.details, this.rawResponse]) + GrpcError.internal( + [this.message, this.details, this.rawResponse, this.trailers]) : code = StatusCode.internal; /// The service is currently unavailable. This is a most likely a transient @@ -269,16 +285,19 @@ class GrpcError implements Exception { /// See litmus test above for deciding between [failedPrecondition], /// [aborted], and [unavailable]. GrpcError.unavailable([this.message, this.details, this.rawResponse]) - : code = StatusCode.unavailable; + : trailers = const {}, + code = StatusCode.unavailable; /// Unrecoverable data loss or corruption. GrpcError.dataLoss([this.message, this.details, this.rawResponse]) - : code = StatusCode.dataLoss; + : trailers = const {}, + code = StatusCode.dataLoss; /// The request does not have valid authentication credentials for the /// operation. GrpcError.unauthenticated([this.message, this.details, this.rawResponse]) - : code = StatusCode.unauthenticated; + : trailers = const {}, + code = StatusCode.unauthenticated; /// Given a status code, return the name String get codeName => (Code.valueOf(code) ?? Code.UNKNOWN).name; @@ -294,7 +313,8 @@ class GrpcError implements Exception { @override String toString() => - 'gRPC Error (code: $code, codeName: $codeName, message: $message, details: $details, rawResponse: $rawResponse)'; + 'gRPC Error (code: $code, codeName: $codeName, message: $message, ' + 'details: $details, rawResponse: $rawResponse, trailers: $trailers)'; } /// Parse error details `Any` object into the right kind of `GeneratedMessage`. @@ -375,14 +395,16 @@ void validateHttpStatusAndContentType( // and use this information to report a better error to the application // layer. However prefer to use status code derived from HTTP status // if grpc-status itself does not provide an informative error. - final error = grpcErrorFromTrailers(headers); + final error = grpcErrorDetailsFromTrailers(headers); if (error == null || error.code == StatusCode.unknown) { throw GrpcError.custom( - status, - error?.message ?? - 'HTTP connection completed with ${httpStatus} instead of 200', - error?.details, - rawResponse); + status, + error?.message ?? + 'HTTP connection completed with ${httpStatus} instead of 200', + error?.details, + rawResponse, + error?.trailers ?? toCustomTrailers(headers), + ); } throw error; } @@ -399,7 +421,7 @@ void validateHttpStatusAndContentType( } } -GrpcError? grpcErrorFromTrailers(Map trailers) { +GrpcError? grpcErrorDetailsFromTrailers(Map trailers) { final status = trailers['grpc-status']; final statusCode = status != null ? int.parse(status) : StatusCode.unknown; @@ -407,16 +429,27 @@ GrpcError? grpcErrorFromTrailers(Map trailers) { final message = _tryDecodeStatusMessage(trailers['grpc-message']); final statusDetails = trailers[_statusDetailsHeader]; return GrpcError.custom( - statusCode, - message, - statusDetails == null - ? const [] - : decodeStatusDetails(statusDetails)); + statusCode, + message, + statusDetails == null + ? const [] + : decodeStatusDetails(statusDetails), + null, + toCustomTrailers(trailers), + ); } return null; } +Map toCustomTrailers(Map trailers) { + return Map.from(trailers) + ..remove(':status') + ..remove('content-type') + ..remove('grpc-status') + ..remove('grpc-message'); +} + const _statusDetailsHeader = 'grpc-status-details-bin'; /// All accepted content-type header's prefix. We are being more permissive diff --git a/test/client_tests/client_test.dart b/test/client_tests/client_test.dart index adacd69..94bb078 100644 --- a/test/client_tests/client_test.dart +++ b/test/client_tests/client_test.dart @@ -604,4 +604,34 @@ void main() { serverHandlers: [handleRequest], ); }); + + test('Call should throw with custom trailers', () async { + final code = StatusCode.invalidArgument; + final message = 'some custom message'; + final customKey = 'some-custom-key'; + final customVal = 'some custom value'; + final customTrailers = {customKey: customVal}; + void handleRequest(_) { + harness.toClient.add(HeadersStreamMessage([ + Header.ascii(':status', '200'), + Header.ascii('content-type', 'application/grpc'), + Header.ascii('grpc-status', code.toString()), + Header.ascii('grpc-message', message), + Header.ascii(customKey, customVal), + ], endStream: true)); + harness.toClient.close(); + } + + await harness.runFailureTest( + clientCall: harness.client.unary(dummyValue), + expectedException: GrpcError.custom( + code, + message, + [], + customTrailers, + ), + expectedCustomTrailers: customTrailers, + serverHandlers: [handleRequest], + ); + }); } diff --git a/test/src/client_utils.dart b/test/src/client_utils.dart index 04e8164..fd23173 100644 --- a/test/src/client_utils.dart +++ b/test/src/client_utils.dart @@ -307,13 +307,24 @@ abstract class _Harness { await clientSubscription.cancel(); } - Future expectThrows(Future? future, dynamic exception) async { + Future expectThrows( + Future? future, + dynamic exception, { + Map? expectedCustomTrailers, + }) async { try { await future; fail('Did not throw'); } catch (e, st) { expect(e, exception); expect(st, isNot(equals(StackTrace.current))); + if (expectedCustomTrailers != null) { + if (e is GrpcError) { + expect(e.trailers, expectedCustomTrailers); + } else { + fail('$e is not a GrpcError'); + } + } } } @@ -323,10 +334,15 @@ abstract class _Harness { String? expectedPath, Duration? expectedTimeout, Map? expectedCustomHeaders, + Map? expectedCustomTrailers, List serverHandlers = const [], bool expectDone = true}) async { return runTest( - clientCall: expectThrows(clientCall, expectedException), + clientCall: expectThrows( + clientCall, + expectedException, + expectedCustomTrailers: expectedCustomTrailers, + ), expectedPath: expectedPath, expectedTimeout: expectedTimeout, expectedCustomHeaders: expectedCustomHeaders,