-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(@aws-amplify/datastore): Retry mutation after GraphQL request tim…
…eout due to bad network condition. (#6542) Co-authored-by: Ivan Artemiev <29709626+iartemiev@users.noreply.github.com>
- Loading branch information
Showing
4 changed files
with
245 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters