Conflict Detection with Optimistic Concurrency does not return the current version of the object
How did you install the Amplify CLI?
npm
If applicable, what version of Node.js are you using?
20.9.0
Amplify CLI Version
12.10.1
What operating system are you using?
Mac
Did you make any manual changes to the cloud resources managed by Amplify? Please describe the changes made.
No manual changes made
Describe the bug
When using an Amplify Graphql API with conflict detection enabled and set to Optimistic Concurrency the generated resolver only returns the error message ConflictUnhandled. It does not return the current version of the object. Without it, the client cannot recover from conflict.
The response appears as:
{
{
"data": null
},
"errors": [
{
"path": [
"updateMyItem"
],
"data": null,
"errorType": "ConflictUnhandled",
"errorInfo": null,
"locations": [
{
"line": 2,
"column": 3,
"sourceName": null
}
],
"message": "Conflict resolver rejects mutation."
}
]
}
Expected behavior
I would expect the resolver to return the error message AND the current version of the object in the database as this is what is described in the AppSync documentation.
Optimistic Concurrency Optimistic Concurrency is a conflict resolution strategy that AWS AppSync provides for versioned data sources. When the conflict resolver is set to Optimistic Concurrency, if an incoming mutation is detected to have a version that differs from the actual version of the object, the conflict handler will simply reject the incoming request. Inside the GraphQL response, the existing item on the server that has the latest version will be provided. The client is then expected to handle this conflict locally and retry the mutation with the updated version of the item.
Source: AppSync Docs - Conflict Detection https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html#conflict-detection-and-resolution
I would therefore expect the response to be:
{
"data": {
"updateMyItem": {
"id": "XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX",
"_version": 51
}
},
"errors": [
{
"path": [
"updateMyItem"
],
"data": null,
"errorType": "ConflictUnhandled",
"errorInfo": null,
"locations": [
{
"line": 2,
"column": 3,
"sourceName": null
}
],
"message": "Conflict resolver rejects mutation."
}
]
}
I would expect it to return $ctx.result alongside the error when the rejection is due to the conflict (and not something else like an auth error).
Reproduction steps
- Create and deploy any basic schema
- Enable Conflict Detection, choose Optimistic Concurrency
- Observe that the generated resolvers call
$util.error()and does not return the result - Create and Item then try to update it with an incorrect
_version. Observe that the current version of the item is not returned.
Project Identifier
No response
Log output
No response
Additional information
Workaround:
The current workaround is to manually override each response template to use appendError() and to return the result.
## [Start] ResponseTemplate. **
$util.qr($ctx.result.put("__operation", "Mutation"))
#if( $ctx.error )
$util.error($ctx.error.message, $ctx.error.type, $ctx.result)
#else
$util.toJson($ctx.result)
#end
## [End] ResponseTemplate. **
to:
## [Start] ResponseTemplate. **
$util.qr($ctx.result.put("__operation", "Mutation"))
#if( $ctx.error )
$util.appendError($ctx.error.message, $ctx.error.type, $ctx.result)
$util.toJson($ctx.result)
#else
$util.toJson($ctx.result)
#end
## [End] ResponseTemplate. **
Possible cause:
I suspect that this section of the resolver is being generated by:
/**
* Generate common response template used by most of the resolvers.
* Append operation if response is coming from a mutation, this is to protect field resolver for subscriptions
*/
export const generateDefaultResponseMappingTemplate = (isSyncEnabled: boolean, mutation = false): string => {
const statements: Expression[] = [];
if (mutation) statements.push(qref(methodCall(ref('ctx.result.put'), str(OPERATION_KEY), str('Mutation'))));
if (isSyncEnabled) {
statements.push(
ifElse(
ref('ctx.error'),
/* here >>>>>> */ methodCall(ref('util.error'), ref('ctx.error.message'), ref('ctx.error.type'), ref('ctx.result')),
/* here >>>>>> */ toJson(ref('ctx.result')),
),
);
} else {
statements.push(
ifElse(ref('ctx.error'), methodCall(ref('util.error'), ref('ctx.error.message'), ref('ctx.error.type')), toJson(ref('ctx.result'))),
);
}
return printBlock('ResponseTemplate')(compoundExpression(statements));
};
Proposed solution:
The solution would be to check to check if the error is ConflictUnhandled only and return the result only in that case.
Before submitting, please confirm:
- [X] I have done my best to include a minimal, self-contained set of instructions for consistently reproducing the issue.
- [X] I have removed any sensitive information from my code snippets and submission.
I currently have too many override resolvers that are used not to add special functionality but just to patch little issues. Each of these I will then have to manage for the life of my projects. For example the isssue #1345 requires me to update the generated resolvers just to call parseJson().
The version of the code that I used as the base for the override had set the $adminRoles variable, which a later update of the cli moved to the beforeMapping template. If this change had gone unnoticed I would have a pipeline behaving differently from what the current version expects. This means that my overrides would not benefit from security or feature improvements pushed out with new versions or, even worse, cause a security issue.
In one project alone I will have to now have to create about 20 new resolvers to implement this, which I then have to manage going forward.
Hi @naedx 👋 thanks for raising this issue, providing context, repro steps and even a workaround!
We will reproduce this internally and report back soon
So, according to the AppSync documentation on optimistic concurrency, the resolver should return both the error message and the current version of the object in the database. This allows clients to handle the conflict locally and retry the mutation with the updated version.
When the conflict resolver is set to Optimistic Concurrency, if an incoming mutation is detected to have a version that differs from the actual version of the object, the conflict handler will simply reject the incoming request. Inside the GraphQL response, the existing item on the server that has the latest version will be provided. The client is then expected to handle this conflict locally and retry the mutation with the updated version of the item.
Your proposed workaround is correct. You need to manually override each response template to use $util.appendError() instead of $util.error() and return the result alongside the error. The key difference is:
-
$util.error()halts processing and only returns the error -
$util.appendError()adds an error to the error list but continues processing, allowing you to also return data
This change will allow the resolver to return both the error and the current version of the object, enabling proper conflict resolution on the client side.
As you correctly identified, the issue is in the code that generates the default response mapping template. The generateDefaultResponseMappingTemplate function uses util.error() instead of util.appendError() when a conflict is detected.
This seems to be a discrepancy between the documented behavior in the AppSync documentation and the actual implementation in the Amplify CLI generated resolvers.
I've labeled this issue as a bug for the team to investigate further.