Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(@aws-amplify/datastore): Retry mutation if GraphQL timeout due to bad network condition. #6542

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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