Skip to content

Commit

Permalink
fix conflicts
Browse files Browse the repository at this point in the history
  • Loading branch information
kengu authored and abmallick committed Oct 28, 2021
1 parent ceddf89 commit 5596042
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 38 deletions.
23 changes: 6 additions & 17 deletions lib/src/client/call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -343,23 +343,12 @@ class ClientCall<Q, R> implements Response {
_stream!.terminate();
}

/// If there's an error status then process it as a response error
void _checkForErrorStatus(Map<String, String> metadata) {
final status = metadata['grpc-status'];
final statusCode = int.parse(status ?? '0');

if (statusCode != 0) {
final messageMetadata = metadata['grpc-message'];
final message =
messageMetadata == null ? null : Uri.decodeFull(messageMetadata);

final statusDetails = metadata[_statusDetailsHeader];
_responseError(GrpcError.custom(
statusCode,
message,
statusDetails == null
? const <GeneratedMessage>[]
: decodeStatusDetails(statusDetails)));

/// If there's an error status then process it as a response error.
void _checkForErrorStatus(Map<String, String> trailers) {
final error = grpcErrorDetailsFromTrailers(trailers);
if (error != null) {
_responseError(error);
}
}

Expand Down
200 changes: 181 additions & 19 deletions lib/src/shared/status.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,62 +126,73 @@ class GrpcError implements Exception {
final int code;
final String? message;
final Object? rawResponse;
final Map<String, String>? trailers;
final List<GeneratedMessage>? 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
/// operation has completed successfully. For example, a successful response
/// 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
/// some resource (use [resourceExhausted] instead for those errors).
/// [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
Expand All @@ -198,15 +209,17 @@ 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.
///
/// 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.
Expand All @@ -222,16 +235,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
Expand All @@ -240,16 +256,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;
Expand All @@ -265,7 +284,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`.
Expand Down Expand Up @@ -309,3 +329,145 @@ GeneratedMessage parseErrorDetailsFromAny(Any any) {
return any;
}
}

/// Validate HTTP status and Content-Type which arrived with the response:
/// reject reponses with non-ok (200) status or unsupported Content-Type.
///
/// Note that grpc-status arrives in trailers and will be handled by
/// [ClientCall._onResponseData].
///
/// gRPC over HTTP2 protocol specification mandates the following:
///
/// Implementations should expect broken deployments to send non-200 HTTP
/// status codes in responses as well as a variety of non-GRPC content-types
/// and to omit Status & Status-Message. Implementations must synthesize a
/// Status & Status-Message to propagate to the application layer when this
/// occurs.
///
void validateHttpStatusAndContentType(
int? httpStatus, Map<String, String> headers,
{Object? rawResponse}) {
if (httpStatus == null) {
throw GrpcError.unknown(
'HTTP response status is unknown', null, rawResponse);
}

if (httpStatus == 0) {
throw GrpcError.unknown(
'HTTP request completed without a status (potential CORS issue)',
null,
rawResponse);
}

final status = StatusCode.fromHttpStatus(httpStatus);
if (status != StatusCode.ok) {
// [httpStatus] itself already indicates an error. Check if we also
// received grpc-status/message (i.e. this is a Trailers-Only response)
// 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 = 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,
error?.trailers ?? toCustomTrailers(headers),
);
}
throw error;
}

final contentType = headers['content-type'];
if (contentType == null) {
throw GrpcError.unknown('missing content-type header', null, rawResponse);
}

// Check if content-type header indicates a supported format.
if (!_validContentTypePrefix.any(contentType.startsWith)) {
throw GrpcError.unknown(
'unsupported content-type (${contentType})', null, rawResponse);
}
}

GrpcError? grpcErrorDetailsFromTrailers(Map<String, String> trailers) {
final status = trailers['grpc-status'];
final statusCode = status != null ? int.parse(status) : StatusCode.unknown;

if (statusCode != StatusCode.ok) {
final message = _tryDecodeStatusMessage(trailers['grpc-message']);
final statusDetails = trailers[_statusDetailsHeader];
return GrpcError.custom(
statusCode,
message,
statusDetails == null
? const <GeneratedMessage>[]
: decodeStatusDetails(statusDetails),
null,
toCustomTrailers(trailers),
);
}

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';

/// All accepted content-type header's prefix. We are being more permissive
/// then gRPC and gRPC-Web specifications because some of the services
/// return slightly different content-types.
const _validContentTypePrefix = [
'application/grpc',
'application/json+protobuf',
'application/x-protobuf'
];

/// Given a string of base64url data, attempt to parse a Status object from it.
/// Once parsed, it will then map each detail item and attempt to parse it into
/// its respective GeneratedMessage type, returning the list of parsed detail items
/// as a `List<GeneratedMessage>`.
///
/// Prior to creating the Status object we pad the data to ensure its length is
/// an even multiple of 4, which is a requirement in Dart when decoding base64url data.
///
/// If any errors are thrown during decoding/parsing, it will return an empty list.
@visibleForTesting
List<GeneratedMessage> decodeStatusDetails(String data) {
try {
final parsedStatus = Status.fromBuffer(
base64Url.decode(data.padRight((data.length + 3) & ~3, '=')));
return parsedStatus.details.map(parseErrorDetailsFromAny).toList();
} catch (e) {
return <GeneratedMessage>[];
}
}

/// Decode percent encoded status message contained in 'grpc-message' trailer.
String? _tryDecodeStatusMessage(String? statusMessage) {
if (statusMessage == null) {
return statusMessage;
}

try {
return Uri.decodeFull(statusMessage);
} catch (_) {
// gRPC over HTTP2 protocol specification mandates:
//
// When decoding invalid values, implementations MUST NOT error or throw
// away the message. At worst, the implementation can abort decoding the
// status message altogether such that the user would received the raw
// percent-encoded form.
//
return statusMessage;
}
}
30 changes: 30 additions & 0 deletions test/client_tests/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -526,4 +526,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 = <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],
);
});
}
Loading

0 comments on commit 5596042

Please sign in to comment.