Skip to content

Commit

Permalink
fix(@aws-amplify/datastore): Retry mutation after GraphQL request tim…
Browse files Browse the repository at this point in the history
…eout due to bad network condition. (#6542)

Co-authored-by: Ivan Artemiev <29709626+iartemiev@users.noreply.github.com>
  • Loading branch information
nubpro and iartemiev authored Apr 2, 2021
1 parent 99d3c55 commit 9fe6b7f
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 3 deletions.
2 changes: 1 addition & 1 deletion .github/actions/bundle-size-action/webpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
},
{
"path": "dist/withSSRContext.js.min.js",
"maxSize": "145kB"
"maxSize": "146kB"
},
{
"path": "dist/withSSRContext+Storage.js.min.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/api-graphql/src/GraphQLAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export class GraphQLAPIClass {
}
response = {
data: {},
errors: [new GraphQLError(err.message)],
errors: [new GraphQLError(err.message, null, null, null, null, err)],
};
}

Expand Down
237 changes: 237 additions & 0 deletions packages/datastore/__tests__/mutation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { MutationProcessor } from '../src/sync/processors/mutation';
import { Model as ModelType, testSchema, internalTestSchema } from './helpers';
import {
PersistentModelConstructor,
InternalSchema,
OpType,
} from '../src/types';
import { createMutationInstanceFromModelOperation } from '../src/sync/utils';
import { MutationEvent } from '../src/sync/';

let syncClasses: any;
let modelInstanceCreator: any;

describe('MutationProcessor', () => {
// Test for this PR: https://github.com/aws-amplify/amplify-js/pull/6542
describe('100% Packet Loss Axios Error', () => {
let mutationProcessor: MutationProcessor;

beforeAll(async () => {
mutationProcessor = await instantiateMutationProcessor();
});

it('Should result in Network Error and get handled without breaking the Mutation Processor', async () => {
const mutationProcessorSpy = jest.spyOn(mutationProcessor, 'resume');

await mutationProcessor.resume();

expect(mockJitteredExponentialRetry.mock.results).toHaveLength(1);

await expect(
mockJitteredExponentialRetry.mock.results[0].value
).rejects.toEqual(new Error('Network Error'));

expect(mutationProcessorSpy).toHaveBeenCalled();

// MutationProcessor.resume exited successfully, i.e., did not throw
await expect(mutationProcessorSpy.mock.results[0].value).resolves.toEqual(
undefined
);
});
});
afterAll(() => {
jest.restoreAllMocks();
});
});

// Mocking restClient.post to throw the error we expect
// when experiencing poor network conditions
jest.mock('@aws-amplify/api-rest', () => {
return {
...jest.requireActual('@aws-amplify/api-rest'),
RestClient() {
return {
post: jest.fn().mockImplementation(() => {
return Promise.reject(axiosError);
}),
getCancellableToken: () => {},
updateRequestToBeCancellable: () => {},
isCancel: () => false,
};
},
};
});

// Configuring the API category so that API.graphql can be used
// by the MutationProcessor
jest.mock('@aws-amplify/api', () => {
const awsconfig = {
aws_project_region: 'us-west-2',
aws_appsync_graphqlEndpoint:
'https://xxxxxxxxxxxxxxxxxxxxxx.appsync-api.us-west-2.amazonaws.com/graphql',
aws_appsync_region: 'us-west-2',
aws_appsync_authenticationType: 'API_KEY',
aws_appsync_apiKey: 'da2-xxxxxxxxxxxxxxxxxxxxxx',
};

const { GraphQLAPIClass } = jest.requireActual('@aws-amplify/api-graphql');
const graphqlInstance = new GraphQLAPIClass(null);
graphqlInstance.configure(awsconfig);

return {
graphql: graphqlInstance.graphql.bind(graphqlInstance),
};
});

// mocking jitteredExponentialRetry to prevent it from retrying
// endlessly in the mutation processor and so that we can expect the thrown result in our test
// should throw a Network Error
let mockJitteredExponentialRetry;
jest.mock('@aws-amplify/core', () => {
mockJitteredExponentialRetry = jest
.fn()
.mockImplementation(async (fn, args) => {
await fn(...args);
});
return {
...jest.requireActual('@aws-amplify/core'),
jitteredExponentialRetry: mockJitteredExponentialRetry,
};
});

// Mocking just enough dependencies for us to be able to
// instantiate a working MutationProcessor
// includes functional mocked outbox containing a single MutationEvent
async function instantiateMutationProcessor() {
const schema: InternalSchema = internalTestSchema();

jest.doMock('../src/sync/', () => ({
SyncEngine: {
getNamespace: () => schema.namespaces['sync'],
},
}));

const { initSchema, DataStore } = require('../src/datastore/datastore');
const classes = initSchema(testSchema());
let Model: PersistentModelConstructor<ModelType>;

({ Model } = classes as {
Model: PersistentModelConstructor<ModelType>;
});

const userClasses = {};
userClasses['Model'] = Model;

await DataStore.start();
({ syncClasses } = require('../src/datastore/datastore'));
({ modelInstanceCreator } = (DataStore as any).storage.storage);

const newModel = new Model({
field1: 'Some value',
dateCreated: new Date().toISOString(),
});

const newMutationEvent = createMutationEvent(newModel, OpType.INSERT);
// mocking mutation queue with a single event
const mutationQueue = [newMutationEvent];

const outbox = {
peek: () => {
return mutationQueue[0];
},
dequeue: () => {
mutationQueue.pop();
},
};

const storage = {
runExclusive: fn => fn(),
};

const mutationProcessor = new MutationProcessor(
schema,
storage as any,
userClasses,
outbox as any,
modelInstanceCreator,
{} as any
);

(mutationProcessor as any).observer = true;

return mutationProcessor;
}

// Creates MutationEvent instance that can be added to the outbox
async function createMutationEvent(model, opType): Promise<MutationEvent> {
const MutationEventConstructor = syncClasses[
'MutationEvent'
] as PersistentModelConstructor<MutationEvent>;

const modelConstructor = (Object.getPrototypeOf(model) as Object)
.constructor as PersistentModelConstructor<any>;

return createMutationInstanceFromModelOperation(
undefined,
undefined,
opType,
modelConstructor,
model,
{},
MutationEventConstructor,
modelInstanceCreator
);
}

// expected error when experiencing 100% packet loss
const axiosError = {
message: 'timeout of 0ms exceeded',
name: 'Error',
stack:
'Error: timeout of 0ms exceeded\n at createError (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:265622:17)\n at EventTarget.handleTimeout (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:265537:16)\n at EventTarget.dispatchEvent (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:32460:27)\n at EventTarget.setReadyState (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:31623:20)\n at EventTarget.__didCompleteResponse (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:31443:16)\n at http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:31553:47\n at RCTDeviceEventEmitter.emit (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:7202:37)\n at MessageQueue.__callFunction (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:2813:31)\n at http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:2545:17\n at MessageQueue.__guard (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:2767:13)',
config: {
url:
'https://xxxxxxxxxxxxxxxxxxxxxx.appsync-api.us-west-2.amazonaws.com/graphql',
method: 'post',
data:
'{"query":"mutation operation($input: UpdatePostInput!, $condition: ModelPostConditionInput) { updatePost(input: $input, condition: $condition) { id title rating status _version _lastChangedAt _deleted blog { id _deleted } }}","variables":{"input":{"id":"86e8f2c1-b002-4ff2-92a2-3dad37933477","status":"INACTIVE","_version":1},"condition":null}}',
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json; charset=UTF-8',
'User-Agent': 'aws-amplify/3.8.21 react-native',
'X-Api-Key': 'da2-xxxxxxxxxxxxxxxxxxxxxx',
'x-amz-user-agent': 'aws-amplify/3.8.21 react-native',
},
transformRequest: [null],
transformResponse: [null],
timeout: 0,
responseType: 'json',
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
maxContentLength: -1,
maxBodyLength: -1,
cancelToken: {
promise: {
_U: 1,
_V: 0,
_W: null,
_X: {
onRejected: null,
promise: {
_U: 0,
_V: 0,
_W: null,
_X: null,
},
},
},
},
host: 'xxxxxxxxxxxxxxxxxxxxxx.appsync-api.us-west-2.amazonaws.com',
path: '/graphql',
signerServiceInfo: {
service: 'appsync',
region: 'us-west-2',
},
},
code: 'ECONNABORTED',
};
7 changes: 6 additions & 1 deletion packages/datastore/src/sync/processors/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,12 @@ class MutationProcessor {
} catch (err) {
if (err.errors && err.errors.length > 0) {
const [error] = err.errors;
if (error.message === 'Network Error') {
const { originalError: { code = null } = {} } = error;

if (
error.message === 'Network Error' ||
code === 'ECONNABORTED' // refers to axios timeout error caused by device's bad network condition
) {
if (!this.processing) {
throw new NonRetryableError('Offline');
}
Expand Down

0 comments on commit 9fe6b7f

Please sign in to comment.