What is the pattern for sending more details about errors to the client using gRPC?
For example, suppose I have a form for registering a user, that sends a message
message RegisterUser {
string email = 1;
string password = 2;
}
where the email has to be properly formatted and unique, and the password must be at least 8 characters long.
If I was writing a JSON API, I'd return a 400 error with the following body:
{
"errors": [{
"field": "email",
"message": "Email does not have proper format."
}, {
"field": "password",
"message": "Password must be at least 8 characters."
}],
}
and the client could provide nice error messages to the user (i.e. by highlighting the password field and specifically telling the user that there's something wrong with their input to it).
With gRPC is there a way to do something similar? It seems that in most client languages, an error results in an exception being thrown, with no way to grab the response.
For example, I'd like something like
message ValidationError {
string field = 1;
string message = 2;
}
message RegisterUserResponse {
repeated ValidationError validation_errors = 1;
...
}
or similar.
Include additional error details in the response Metadata. However, still make sure to provide a useful status code and message. In this case, you can add RegisterUserResponse to the Metadata.
In gRPC Java, that would look like:
Metadata.Key<RegisterUserResponse> REGISTER_USER_RESPONSE_KEY =
ProtoUtils.keyForProto(RegisterUserResponse.getDefaultInstance());
...
Metadata metadata = new Metadata();
metadata.put(REGISTER_USER_RESPONSE_KEY, registerUserResponse);
responseObserver.onError(
Status.INVALID_ARGUMENT.withDescription("Email or password malformed")
.asRuntimeException(metadata));
Another option is to use the google.rpc.Status proto which includes an additional Any for details. Support is coming to each language to handle the type. In Java, it'd look like:
// This is com.google.rpc.Status, not io.grpc.Status
Status status = Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT.getNumber())
.setMessage("Email or password malformed")
.addDetails(Any.pack(registerUserResponse))
.build();
responseObserver.onError(StatusProto.toStatusRuntimeException(status));
google.rpc.Status is cleaner in some languages as the error details can be passed around as one unit. It also makes it clear what parts of the response are error-related. On-the-wire, it still uses Metadata to pass the additional information.
You may also be interested in error_details.proto which contains some common types of errors.
I discussed this topic during CloudNativeCon. You can check out the slides and linked recording on YouTube.
We have 3 different ways we could handle the errors in gRPC. For example lets assume the gRPC server does not accept values above 20 or below 2.
Option 1: Using gRPC status codes.
if(number < 2 || number > 20){
Status status = Status.FAILED_PRECONDITION.withDescription("Not between 2 and 20");
responseObserver.onError(status.asRuntimeException());
}
Option 2: Metadata (we can pass objects via metadata)
if(number < 2 || number > 20){
Metadata metadata = new Metadata();
Metadata.Key<ErrorResponse> responseKey = ProtoUtils.keyForProto(ErrorResponse.getDefaultInstance());
ErrorCode errorCode = number > 20 ? ErrorCode.ABOVE_20 : ErrorCode.BELOW_2;
ErrorResponse errorResponse = ErrorResponse.newBuilder()
.setErrorCode(errorCode)
.setInput(number)
.build();
// pass the error object via metadata
metadata.put(responseKey, errorResponse);
responseObserver.onError(Status.FAILED_PRECONDITION.asRuntimeException(metadata));
}
Option 3: Using oneof - we can also use oneof to send error response
oneof response {
SuccessResponse success_response = 1;
ErrorResponse error_response = 2;
}
}
client side:
switch (response.getResponseCase()){
case SUCCESS_RESPONSE:
System.out.println("Success Response : " + response.getSuccessResponse().getResult());
break;
case ERROR_RESPONSE:
System.out.println("Error Response : " + response.getErrorResponse().getErrorCode());
break;
}
Check here for the detailed steps - https://www.vinsguru.com/grpc-error-handling/
As mentioned by #Eric Anderson, you can use metadata to pass error detail. The problem with metadata is that it can contain, other attributes (example - content-type). To handle that you need to add custom logic to filter error metadata.
A much cleaner approach is of using google.rpc.Status proto (as Eric has mentioned).
If you can convert your gRPC server application to spring boot using yidongnan/grpc-spring-boot-starter, then you can write #GrpcAdvice, similar to Spring Boot #ControllerAdvice as
#GrpcAdvice
public class ExceptionHandler {
#GrpcExceptionHandler(ValidationErrorException.class)
public StatusRuntimeException handleValidationError(ValidationErrorException cause) {
List<ValidationError> validationErrors = cause.getValidationErrors();
RegisterUserResponse registerUserResponse =
RegisterUserResponse.newBuilder()
.addAllValidationErrors(validationErrors)
.build();
var status =
com.google.rpc.Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT.getNumber())
.setMessage("Email or password malformed")
.addDetails(Any.pack(registerUserResponse))
.build();
return StatusProto.toStatusRuntimeException(status);
}
}
On the client-side, you can catch this exception and unpack the registerUserResponse as:
as
} catch (StatusRuntimeException error) {
com.google.rpc.Status status = io.grpc.protobuf.StatusProto.fromThrowable(error);
RegisterUserResponse registerUserResponse = null;
for (Any any : status.getDetailsList()) {
if (!any.is(RegisterUserResponse.class)) {
continue;
}
registerUserResponse = any.unpack(ErrorInfo.class);
}
log.info(" Error while calling product service, reason {} ", registerUserResponse.getValidationErrorsList());
//Other action
}
In my opinion, this can be a much cleaner approach provided you can run your gRPC server application as Spring Boot.
I was struggling with similar questions - so I decided to compile everything in a blog post
Here is a test in GoLang:
func TestNewStatusError_WhenBuildingFromStatus_WithDetails(t *testing.T) {
details1 := &errdetails.BadRequest{}
details1.FieldViolations = append(details1.FieldViolations, &errdetails.BadRequest_FieldViolation{
Field: "site_id",
Description: "bad format, not an UUID",
})
details2 := &common.ResourceNotFound{
Title: "site",
Description: "not found",
}
statusErr := status.New(codes.Internal, "something went wrong")
statusErrWithDetails, err := statusErr.WithDetails(details1, details2)
require.Nil(t, err)
assert.EqualValues(t, codes.Internal, statusErrWithDetails.Code())
assert.EqualValues(t, "something went wrong", statusErrWithDetails.Message())
assert.EqualValues(t, 2, len(statusErrWithDetails.Details()))
}
When rendering something similar would look like:
{
"code": 3,
"message": "SiteID not valid: bad uuid",
"details": [
{
"#type": "type.googleapis.com/google.rpc.BadRequest",
"field_violations": [
{
"field": "site_id",
"description": "Site ID not valid"
}
]
}
]
}
Related
I've followed the Firestore documentation with relation to transactions, and I think I have it all sorted correctly, but in testing I am noticing issues with my documents not getting updated properly sometimes. It is possible that multiple versions of the document could be submitted to the function in a very short interval, but I am only interested in only ever keeping the most recent version.
My general logic is this:
New/Updated document is sent to cloud function
Check if document already exists in Firestore, and if not, add it.
If it does exist, check that it is "newer" than the instance in firestore, if it is, update it.
Otherwise, don't do anything.
Here is the code from my function that attempts to accomplish this...I would love some feedback if this is correct/best way to do this:
const ocsFlight = req.body;
const procFlight = processOcsFlightEvent(ocsFlight);
try {
const ocsFlightRef = db.collection(collection).doc(procFlight.fltId);
const originalFlight = await ocsFlightRef.get();
if (!originalFlight.exists) {
const response = await ocsFlightRef.set(procFlight);
console.log("Record Added: ", JSON.stringify(procFlight));
res.status(201).json(response); // 201 - Created
return;
}
await db.runTransaction(async (t) => {
const doc = await t.get(ocsFlightRef);
const flightDoc = doc.data();
if (flightDoc.recordModified <= procFlight.recordModified) {
t.update(ocsFlightRef, procFlight);
console.log("Record Updated: ", JSON.stringify(procFlight));
res.status(200).json("Record Updated");
return;
}
console.log("Record isn't newer, nothing changed.");
console.log("Record:", JSON.stringify("Same Flight:", JSON.stringify(procFlight)));
res.status(200).json("Record isn't newer, nothing done.");
return;
});
} catch (error) {
console.log("Error:", JSON.stringify(error));
res.status(500).json(error.message);
}
The Bugs
First, you are trusting the value of req.body to be of the correct shape. If you don't already have type assertions that mirror your security rules for /collection/someFlightId in processOcsFlightEvent, you should add them. This is important because any database operations from the Admin SDKs will bypass your security rules.
The next bug is sending a response to your function inside the transaction. Once you send a response back the client, your function is marked inactive - resources are severely throttled and any network requests may not complete or crash. As a transaction may be retried a handful of times if a database collision is detected, you should make sure to only respond to the client once the transaction has properly completed.
You use set to write the new flight to Firestore, this can lead to trouble when working with transactions as a set operation will cancel all pending transactions at that location. If two function instances are fighting over the same flight ID, this will lead to the problem where the wrong data can be written to the database.
In your current code, you return the result of the ocsFlightRef.set() operation to the client as the body of the HTTP 201 Created response. As the result of the DocumentReference#set() is a WriteResult object, you'll need to properly serialize it if you want to return it to the client and even then, I don't think it will be useful as you don't seem to use it for the other response types. Instead, a HTTP 201 Created response normally includes where the resource was written to as the Location header with no body, but here we'll pass the path in the body. If you start using multiple database instances, including the relevant database may also be useful.
Fixing
The correct way to achieve the desired result would be to do the entire read->check->write process inside of a transaction and only once the transaction has completed, then respond to the client.
So we can send the appropriate response to the client, we can use the return value of the transaction to pass data out of it. We'll pass the type of the change we made ("created" | "updated" | "aborted") and the recordModified value of what was stored in the database. We'll return these along with the resource's path and an appropriate message.
In the case of an error, we'll return a message to show the user as message and the error's Firebase error code (if available) or general message as the error property.
// if not using express to wrangle requests, assert the correct method
if (req.method !== "POST") {
console.log(`Denied ${req.method} request`);
res.status(405) // 405 - Method Not Allowed
.set("Allow", "POST")
.end();
return;
}
const ocsFlight = req.body;
try {
// process AND type check `ocsFlight`
const procFlight = processOcsFlightEvent(ocsFlight);
const ocsFlightRef = db.collection(collection).doc(procFlight.fltId);
const { changeType, recordModified } = await db.runTransaction(async (t) => {
const flightDoc = await t.get(ocsFlightRef);
if (!flightDoc.exists) {
t.set(ocsFlightRef, procFlight);
return {
changeType: "created",
recordModified: procFlight.recordModified
};
}
// only parse the field we need rather than everything
const storedRecordModified = flightDoc.get('recordModified');
if (storedRecordModified <= procFlight.recordModified) {
t.update(ocsFlightRef, procFlight);
return {
changeType: "updated",
recordModified: procFlight.recordModified
};
}
return {
changeType: "aborted",
recordModified: storedRecordModified
};
});
switch (changeType) {
case "updated":
console.log("Record updated: ", JSON.stringify(procFlight));
res.status(200).json({ // 200 - OK
path: ocsFlightRef.path,
message: "Updated",
recordModified,
changeType
});
return;
case "created":
console.log("Record added: ", JSON.stringify(procFlight));
res.status(201).json({ // 201 - Created
path: ocsFlightRef.path,
message: "Created",
recordModified,
changeType
});
return;
case "aborted":
console.log("Outdated record discarded: ", JSON.stringify(procFlight));
res.status(200).json({ // 200 - OK
path: ocsFlightRef.path,
message: "Record isn't newer, nothing done.",
recordModified,
changeType
});
return;
default:
throw new Error("Unexpected value for 'changeType': " + changeType);
}
} catch (error) {
console.log("Error:", JSON.stringify(error));
res.status(500) // 500 - Internal Server Error
.json({
message: "Something went wrong",
// if available, prefer a Firebase error code
error: error.code || error.message
});
}
References
Cloud Firestore Transactions
Cloud Firestore Node SDK Reference
HTTP Event Cloud Functions
I am developing a bot to link to my NodeJS application and am using quick replies to receive the user's email address and telephone number.
However, the reply contains a text and payload value that are the same, which makes catching the response and processing it impossible.. So I must be doing something wrong.
Here's what I send:
response = {
"text": "We need your phone number to match you with our records",
"quick_replies":[
{
"content_type":"user_phone_number",
"payload":"PHONE_NUMBER"
}
]
}
callSendAPI(sender_psid, response);
But when the user clicks their Quick Reply button I get:
{ sender: { id: '<some value>' },
recipient: { id: '<some value>' },
timestamp: 1622370102305,
message:
{ mid:
'<some value>',
text: 'me#example.com',
quick_reply: { payload: 'me#exmaple.com' }
}
}
How would I identify a specific Quick Reply response for processing?
With text replies I can assign a payload, then listen out for that payload being returned.
If the payload of a quick reply is dynamic, I don't see a way to process the user response since if (response.message.quick_reply.payload === 'PHONE_NUMBER') can't work here like the rest of the script.
Unfortunately, according to the docs, that's just how it is.
For an email/phone quick reply, the message.quick_reply.payload will either be the email or phone number as appropriate.
However, while the quick replies are available, the user can still manually type in a different email or phone number to what they have registered with Facebook - it's just for convenience. Because they can send back any free form text they like, you should be parsing the message.text property anyway.
parseResponseForEmailAndPhone(response) {
const text = response.message.text;
if (looksLikeAnEmail(text)) {
return { email: text };
} else if (looksLikeAPhoneNumber(text)) {
return { phone: text };
}
// TODO: handle other message
// unlikely, but could even be a sentence:
// - "my phone is +000000"
// - "my email is me#example.com"
// - "+000000 me#example.com"
// You also need to handle non-consent
// - "you don't need it"
// - "I don't have one"
// - "skip"
const result = {};
// please use a library for these instead,
// they are used here just as an example
const phoneMatches = /phoneRegEx/.exec(text);
const emailMatches = /emailRegEx/.exec(text);
if (phoneMatches) {
result.phone = phoneMatches[1];
}
if (emailMatches) {
result.email = emailMatches[1];
}
return result;
}
I am sending an HTTP PUT request to my Elasticsearch server using Fuel library on Kotlin. However, I cannot see an error body if the server returns 404 or 400. I am expecting to get an error message similar to the following:
{
"error": {
"root_cause": [
{
"type": "invalid_snapshot_name_exception",
"reason": "[snap1:kopya3]Invalid snapshot name [kopya3], snapshot with the same name already exists"
}
],
"type": "invalid_snapshot_name_exception",
"reason": "[snap1:kopya3]Invalid snapshot name [kopya3], snapshot with the same name already exists"
},
"status": 400
}
Here's my code:
val (request, response, result) = fullUrl
.httpPut()
.body(payload)
.responseString()
val (bytes, error) = result
print(error)
Instead what I see is:
HTTP Exception 400 Bad Request
com.github.kittinunf.fuel.core.FuelError$Companion.wrap(FuelError.kt:84)
com.github.kittinunf.fuel.core.DeserializableKt.response(Deserializable.kt:168)
com.github.kittinunf.fuel.core.requests.DefaultRequest.responseString(DefaultRequest.kt:475)
com.a.b.c.d.model.Cluster.createSnapshot(Cluster.kt:67)
com.a.b.c.d.model.Cluster.createSnapshot$default(Cluster.kt:57)
com.a.b.c.d.model.ClusterKt.main(Cluster.kt:85)
Caused by: HTTP Exception 400 Bad Request
com.github.kittinunf.fuel.core.FuelError$Companion.wrap(FuelError.kt:86)
Caused by: com.github.kittinunf.fuel.core.HttpException: HTTP Exception 400 Bad Request
com.github.kittinunf.fuel.core.requests.RequestTask.prepareResponse(RequestTask.kt:35)
com.github.kittinunf.fuel.core.requests.RequestTask.call(RequestTask.kt:47)
com.github.kittinunf.fuel.core.requests.RequestTask.call(RequestTask.kt:14)
com.github.kittinunf.fuel.core.DeserializableKt.response(Deserializable.kt:166)
com.github.kittinunf.fuel.core.requests.DefaultRequest.responseString(DefaultRequest.kt:475)
com.a.b.c.d.model.Cluster.createSnapshot(Cluster.kt:67)
com.a.b.c.d.model.Cluster.createSnapshot$default(Cluster.kt:57)
com.a.b.c.d.model.ClusterKt.main(Cluster.kt:85)
How can I see the actual error message? Thanks in advance.
I solved my problem by looking at response.data which is a bytes array. Converting it to string by String(response.data) I could see the error message.
It's really quite annoying that the default exception doesn't just print this.
I struggled with the generics on this one to pull the handler into its own function, so I thought I share my complete code here.
fun <T> genericErrorHandler(response: Response, result: Result.Failure<Exception>): T {
println("Request to url ${response.url} failed, server returned:")
println(String(response.data))
throw result.getException()
}
private fun someRequest(url: String, jsonBody: String): SomeDataFormatYouExpectOnSuccess {
val (_, response, result) = url.httpPost().header("Content-Type", "application/json; utf-8")
.header("Authorization", "") // adding some headers
.jsonBody(jsonBody)
.responseString()
return when (result) {
is Result.Failure -> genericErrorHandler(response, result)
is Result.Success -> {
val data = result.get()
gson.fromJson(data, SomeDataFormatYouExpectOnSuccess::class.java)
}
}
}
I am trying to query the Google Analytics Reporting API from a node.js application.
I think I have set up everything correctly on the google-side of things including a service account, but I must be missing a piece.
My application successfully sends usage-data to Google, I can see it come in in the realtime view. I can also query the data using the interactive API explorer.
In my node.js code I authenticate with the API at server startup like so:
var googleapis_key = require('./config/google-api-key.json');
var googleapis = require('googleapis');
var googleapis_jwtClient = new googleapis.auth.JWT(
googleapis_key.client_email,
null,
googleapis_key.private_key,
["https://www.googleapis.com/auth/analytics.readonly"],
null);
var googleapis_analyticsreporting = googleapis.analyticsreporting('v4');
googleapis_jwtClient.authorize(function(err, tokens) {
if (err) {
lStartup.error(err);
lStartup.error("Could not authenticate with google API. Analytics not available.");
} else {
lStartup.info("Successfully authenticated with google service-account.");
lStartup.debug(googleapis_jwtClient.credentials);
}
});
(where lStartup is a log4js logger). I get a positive response back from Google, err is not set and the credentials logged to the console look convincing.
Then later when the relevant client request comes in my server tries to ask google for the data:
var reportingrequests = {
"reportRequests": [
{
"viewID": "138122972",
"dateRanges": [{"startDate": "7daysAgo", "endDate": "yesterday"}],
"metrics": [{"expression": "ga:users"}]
}
]
};
logger.debug(JSON.stringify(reportingrequests));
googleapis_analyticsreporting.reports.batchGet(
{
"resource": reportingrequests,
"auth": googleapis_jwtClient,
},
function(err, response) {
if (err) {
// Failure. Log and report to the client.
console.error("Could not query the Google Analytics reporting API");
console.error(err);
res.writeHead(500, "Internal server error. (Google analytics:" + err + ")");
res.end(JSON.stringify(err));
} else {
// Success, just serve googles result to the client.
res.end(JSON.stringify(response));
}
}
);
The response is an error
[ { message: 'Invalid JSON payload received. Unknown name "view_id" at \'report_requests[0]\': Cannot find field.',
domain: 'global',
reason: 'badRequest' } ] }
What is it trying to tell me here? I do not have properties named view_id or report_requests in my JSON. Although they look suspiciously like mine de-camelcased.
I hate self-answering, but I love solutions!
"viewID": "138122972",
should be
"viewId": "138122972",
Note the lowercase "d".
Ironically the clue to this is in the camelCase to snake_case-conversion. If the parameter name was "viewID" it would propably have been snake_cased to "view_i_d", which is not what is in the error message.
I feel stupid, but also happy to be able to go on.
What is the best response to give, if a number of objects are sent to my web api controller, to be inserted into my database, where some may be successful, and some may fail? A normal HTTP response I don't think will suffice - would it be better to find some way of returning a JSON string of what has been successful, and what has not? If so, how would I do that?
My Post controller is shown below.
Thanks for any help,
Mark
public HttpResponseMessage PostBooking(Booking[] bookings)
{
if (ModelState.IsValid)
{
foreach (var booking in bookings)
{
// check if there are any bookings already with this HID and RID...
var checkbooking = db.Bookings.Where(h => h.HID == booking.HID && h.RID == booking.RID).ToList();
// If so, return a response of conflict
if (checkbooking.Count != 0 || checkbooking.Any())
{
throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.Conflict));
}
else
{
// If not add the booking to the database and return a response of Created
db.Bookings.Add(booking);
db.SaveChanges();
}
}
HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.Created);
return response;
}
else
{
// Model is not valid, so return BadRquest
return Request.CreateResponse(HttpStatusCode.BadRequest);
}
}
You could return a JSON list containing the ids of the objects that failed to be inserted:
{
"failedIds": [
4,
7,
9
]
}
500 HTTP response status code also seems appropriate as the request didn't complete successfully.
You could even bring that a step further and provide an explanation why insertion failed for each particular id:
{
"failed": [
{
"id": 4,
"reason": "database unavailable"
},
{
"id": 7,
"reason": "network cable unplugged"
},
{
"id": 9,
"reason": "a thief is currently running away with our server"
}
]
}