Add custom trailers to GrpcError (#493)

This commit is contained in:
Kenneth Gulbrandsøy 2021-08-10 11:53:09 +02:00 committed by GitHub
parent 4775078b8c
commit acd2e93a25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 113 additions and 34 deletions

View File

@ -339,7 +339,7 @@ class ClientCall<Q, R> implements Response {
/// If there's an error status then process it as a response error. /// If there's an error status then process it as a response error.
void _checkForErrorStatus(Map<String, String> trailers) { void _checkForErrorStatus(Map<String, String> trailers) {
final error = grpcErrorFromTrailers(trailers); final error = grpcErrorDetailsFromTrailers(trailers);
if (error != null) { if (error != null) {
_responseError(error); _responseError(error);
} }

View File

@ -155,32 +155,38 @@ class GrpcError implements Exception {
final int code; final int code;
final String? message; final String? message;
final Object? rawResponse; final Object? rawResponse;
final Map<String, String>? trailers;
final List<GeneratedMessage>? details; final List<GeneratedMessage>? details;
/// Custom error code. /// 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. /// The operation completed successfully.
GrpcError.ok([this.message, this.details, this.rawResponse]) GrpcError.ok([this.message, this.details, this.rawResponse])
: code = StatusCode.ok; : trailers = const {},
code = StatusCode.ok;
/// The operation was cancelled (typically by the caller). /// The operation was cancelled (typically by the caller).
GrpcError.cancelled([this.message, this.details, this.rawResponse]) 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 /// 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 /// 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 /// 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. /// do not return enough error information may be converted to this error.
GrpcError.unknown([this.message, this.details, this.rawResponse]) 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 /// Client specified an invalid argument. Note that this differs from
/// [failedPrecondition]. [invalidArgument] indicates arguments that are /// [failedPrecondition]. [invalidArgument] indicates arguments that are
/// problematic regardless of the state of the system (e.g., a malformed file /// problematic regardless of the state of the system (e.g., a malformed file
/// name). /// name).
GrpcError.invalidArgument([this.message, this.details, this.rawResponse]) 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 /// Deadline expired before operation could complete. For operations that
/// change the state of the system, this error may be returned even if the /// 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 /// from a server could have been delayed long enough for the deadline to
/// expire. /// expire.
GrpcError.deadlineExceeded([this.message, this.details, this.rawResponse]) 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. /// Some requested entity (e.g., file or directory) was not found.
GrpcError.notFound([this.message, this.details, this.rawResponse]) 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 /// Some entity that we attempted to create (e.g., file or directory) already
/// exists. /// exists.
GrpcError.alreadyExists([this.message, this.details, this.rawResponse]) 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. /// The caller does not have permission to execute the specified operation.
/// [permissionDenied] must not be used for rejections caused by exhausting /// [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 /// [permissionDenied] must not be used if the caller cannot be identified
/// (use [unauthenticated] instead for those errors). /// (use [unauthenticated] instead for those errors).
GrpcError.permissionDenied([this.message, this.details, this.rawResponse]) 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 /// Some resource has been exhausted, perhaps a per-user quota, or perhaps the
/// entire file system is out of space. /// entire file system is out of space.
GrpcError.resourceExhausted([this.message, this.details, this.rawResponse]) 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 /// 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 /// 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 /// returned since the client should not retry unless they have first
/// fixed up the directory by deleting files from it. /// fixed up the directory by deleting files from it.
GrpcError.failedPrecondition([this.message, this.details, this.rawResponse]) 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 /// The operation was aborted, typically due to a concurrency issue like
/// sequencer check failures, transaction aborts, etc. /// sequencer check failures, transaction aborts, etc.
@ -235,7 +247,8 @@ class GrpcError implements Exception {
/// See litmus test above for deciding between [failedPrecondition], /// See litmus test above for deciding between [failedPrecondition],
/// [aborted], and [unavailable]. /// [aborted], and [unavailable].
GrpcError.aborted([this.message, this.details, this.rawResponse]) 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 /// Operation was attempted past the valid range. E.g., seeking or reading
/// past end of file. /// 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 /// 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. /// easily look for an [outOfRange] error to detect when they are done.
GrpcError.outOfRange([this.message, this.details, this.rawResponse]) 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. /// Operation is not implemented or not supported/enabled in this service.
GrpcError.unimplemented([this.message, this.details, this.rawResponse]) 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 /// Internal errors. Means some invariants expected by underlying system has
/// been broken. If you see one of these errors, something is very broken. /// been broken. If you see one of these errors, something is very broken.
// TODO(sigurdm): This should probably not be an [Exception]. // 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; : code = StatusCode.internal;
/// The service is currently unavailable. This is a most likely a transient /// 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], /// See litmus test above for deciding between [failedPrecondition],
/// [aborted], and [unavailable]. /// [aborted], and [unavailable].
GrpcError.unavailable([this.message, this.details, this.rawResponse]) GrpcError.unavailable([this.message, this.details, this.rawResponse])
: code = StatusCode.unavailable; : trailers = const {},
code = StatusCode.unavailable;
/// Unrecoverable data loss or corruption. /// Unrecoverable data loss or corruption.
GrpcError.dataLoss([this.message, this.details, this.rawResponse]) 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 /// The request does not have valid authentication credentials for the
/// operation. /// operation.
GrpcError.unauthenticated([this.message, this.details, this.rawResponse]) GrpcError.unauthenticated([this.message, this.details, this.rawResponse])
: code = StatusCode.unauthenticated; : trailers = const {},
code = StatusCode.unauthenticated;
/// Given a status code, return the name /// Given a status code, return the name
String get codeName => (Code.valueOf(code) ?? Code.UNKNOWN).name; String get codeName => (Code.valueOf(code) ?? Code.UNKNOWN).name;
@ -294,7 +313,8 @@ class GrpcError implements Exception {
@override @override
String toString() => 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`. /// 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 // and use this information to report a better error to the application
// layer. However prefer to use status code derived from HTTP status // layer. However prefer to use status code derived from HTTP status
// if grpc-status itself does not provide an informative error. // 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) { if (error == null || error.code == StatusCode.unknown) {
throw GrpcError.custom( throw GrpcError.custom(
status, status,
error?.message ?? error?.message ??
'HTTP connection completed with ${httpStatus} instead of 200', 'HTTP connection completed with ${httpStatus} instead of 200',
error?.details, error?.details,
rawResponse); rawResponse,
error?.trailers ?? toCustomTrailers(headers),
);
} }
throw error; throw error;
} }
@ -399,7 +421,7 @@ void validateHttpStatusAndContentType(
} }
} }
GrpcError? grpcErrorFromTrailers(Map<String, String> trailers) { GrpcError? grpcErrorDetailsFromTrailers(Map<String, String> trailers) {
final status = trailers['grpc-status']; final status = trailers['grpc-status'];
final statusCode = status != null ? int.parse(status) : StatusCode.unknown; final statusCode = status != null ? int.parse(status) : StatusCode.unknown;
@ -407,16 +429,27 @@ GrpcError? grpcErrorFromTrailers(Map<String, String> trailers) {
final message = _tryDecodeStatusMessage(trailers['grpc-message']); final message = _tryDecodeStatusMessage(trailers['grpc-message']);
final statusDetails = trailers[_statusDetailsHeader]; final statusDetails = trailers[_statusDetailsHeader];
return GrpcError.custom( return GrpcError.custom(
statusCode, statusCode,
message, message,
statusDetails == null statusDetails == null
? const <GeneratedMessage>[] ? const <GeneratedMessage>[]
: decodeStatusDetails(statusDetails)); : decodeStatusDetails(statusDetails),
null,
toCustomTrailers(trailers),
);
} }
return null; return null;
} }
Map<String, String> toCustomTrailers(Map<String, String> trailers) {
return Map.from(trailers)
..remove(':status')
..remove('content-type')
..remove('grpc-status')
..remove('grpc-message');
}
const _statusDetailsHeader = 'grpc-status-details-bin'; const _statusDetailsHeader = 'grpc-status-details-bin';
/// All accepted content-type header's prefix. We are being more permissive /// All accepted content-type header's prefix. We are being more permissive

View File

@ -604,4 +604,34 @@ void main() {
serverHandlers: [handleRequest], 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 = <String, String>{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],
);
});
} }

View File

@ -307,13 +307,24 @@ abstract class _Harness {
await clientSubscription.cancel(); await clientSubscription.cancel();
} }
Future<void> expectThrows(Future? future, dynamic exception) async { Future<void> expectThrows(
Future? future,
dynamic exception, {
Map<String, String>? expectedCustomTrailers,
}) async {
try { try {
await future; await future;
fail('Did not throw'); fail('Did not throw');
} catch (e, st) { } catch (e, st) {
expect(e, exception); expect(e, exception);
expect(st, isNot(equals(StackTrace.current))); 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, String? expectedPath,
Duration? expectedTimeout, Duration? expectedTimeout,
Map<String, String>? expectedCustomHeaders, Map<String, String>? expectedCustomHeaders,
Map<String, String>? expectedCustomTrailers,
List<MessageHandler> serverHandlers = const [], List<MessageHandler> serverHandlers = const [],
bool expectDone = true}) async { bool expectDone = true}) async {
return runTest( return runTest(
clientCall: expectThrows(clientCall, expectedException), clientCall: expectThrows(
clientCall,
expectedException,
expectedCustomTrailers: expectedCustomTrailers,
),
expectedPath: expectedPath, expectedPath: expectedPath,
expectedTimeout: expectedTimeout, expectedTimeout: expectedTimeout,
expectedCustomHeaders: expectedCustomHeaders, expectedCustomHeaders: expectedCustomHeaders,