diff --git a/packages/datastore/__tests__/mutation.test.ts b/packages/datastore/__tests__/mutation.test.ts index 166b9fa9809..972420ee79c 100644 --- a/packages/datastore/__tests__/mutation.test.ts +++ b/packages/datastore/__tests__/mutation.test.ts @@ -1,3 +1,4 @@ +import { RestClient } from '@aws-amplify/api-rest'; import { MutationProcessor, safeJitteredBackoff, @@ -22,6 +23,7 @@ let modelInstanceCreator: any; let Model: PersistentModelConstructor; let PostCustomPK: PersistentModelConstructor; let PostCustomPKSort: PersistentModelConstructor; +let axiosError; describe('Jittered retry', () => { it('should progress exponentially until some limit', () => { @@ -146,6 +148,84 @@ describe('MutationProcessor', () => { }); }); +describe('error handler', () => { + let mutationProcessor: MutationProcessor; + const errorHandler = jest.fn(); + + beforeEach(async () => { + errorHandler.mockClear(); + mutationProcessor = await instantiateMutationProcessor({ errorHandler }); + }); + + test('newly required field', async () => { + axiosError = { + message: "Variable 'name' has coerced Null value for NonNull type", + name: 'Error', + code: '', + errorType: '', + }; + await mutationProcessor.resume(); + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'Create', + process: 'mutate', + errorType: 'BadRecord', + }) + ); + }); + + test('connection timout', async () => { + axiosError = { + message: 'Connection failed: Connection Timeout', + name: 'Error', + code: '', + errorType: '', + }; + await mutationProcessor.resume(); + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'Create', + process: 'mutate', + errorType: 'Transient', + }) + ); + }); + + test('server error', async () => { + axiosError = { + message: 'Error: Request failed with status code 500', + name: 'Error', + code: '', + errorType: '', + }; + await mutationProcessor.resume(); + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'Create', + process: 'mutate', + errorType: 'Transient', + }) + ); + }); + + test('no auth decorator', async () => { + axiosError = { + message: 'Request failed with status code 401', + name: 'Error', + code: '', + errorType: '', + }; + await mutationProcessor.resume(); + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'Create', + process: 'mutate', + errorType: 'Unauthorized', + }) + ); + }); +}); + // Mocking restClient.post to throw the error we expect // when experiencing poor network conditions jest.mock('@aws-amplify/api-rest', () => { @@ -181,6 +261,7 @@ jest.mock('@aws-amplify/api', () => { graphqlInstance.configure(awsconfig); return { + ...jest.requireActual('@aws-amplify/api'), graphql: graphqlInstance.graphql.bind(graphqlInstance), }; }); @@ -202,7 +283,9 @@ jest.mock('@aws-amplify/core', () => { // 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() { +async function instantiateMutationProcessor({ + errorHandler = () => null, +} = {}) { let schema: InternalSchema = internalTestSchema(); jest.doMock('../src/sync/', () => ({ @@ -267,7 +350,7 @@ async function instantiateMutationProcessor() { aws_appsync_apiKey: 'da2-xxxxxxxxxxxxxxxxxxxxxx', }, () => null, - () => null + errorHandler ); (mutationProcessor as any).observer = true; @@ -297,7 +380,7 @@ async function createMutationEvent(model, opType): Promise { } // expected error when experiencing 100% packet loss -const axiosError = { +const timeoutError = { message: 'timeout of 0ms exceeded', name: 'Error', stack: @@ -346,3 +429,7 @@ const axiosError = { }, code: 'ECONNABORTED', }; + +beforeEach(() => { + axiosError = timeoutError; +}); diff --git a/packages/datastore/__tests__/sync.test.ts b/packages/datastore/__tests__/sync.test.ts index 82c5ea65e1a..3fd43b445d7 100644 --- a/packages/datastore/__tests__/sync.test.ts +++ b/packages/datastore/__tests__/sync.test.ts @@ -25,10 +25,7 @@ const sessionStorageMock = (() => { Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock, }); - -describe('Sync', () => { - describe('jitteredRetry', () => { - const defaultQuery = `query { +const defaultQuery = `query { syncPosts { items { id @@ -42,11 +39,13 @@ describe('Sync', () => { startedAt } }`; - const defaultVariables = {}; - const defaultOpName = 'syncPosts'; - const defaultModelDefinition = { name: 'Post' }; - const defaultAuthMode = GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS; +const defaultVariables = {}; +const defaultOpName = 'syncPosts'; +const defaultModelDefinition = { name: 'Post' }; +const defaultAuthMode = GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS; +describe('Sync', () => { + describe('jitteredRetry', () => { beforeEach(() => { window.sessionStorage.clear(); jest.resetModules(); @@ -283,16 +282,132 @@ describe('Sync', () => { }); }); }); + + describe('error handler', () => { + const errorHandler = jest.fn(); + const data = { + syncPosts: { + items: [ + { + id: '1', + title: 'Item 1', + }, + null, + { + id: '3', + title: 'Item 3', + }, + ], + }, + }; + + beforeEach(async () => { + window.sessionStorage.clear(); + jest.resetModules(); + jest.resetAllMocks(); + errorHandler.mockClear(); + window.sessionStorage.setItem('datastorePartialData', 'true'); + }); + + test('bad record', async () => { + const syncProcessor = jitteredRetrySyncProcessorSetup({ + errorHandler, + rejectResponse: { + data, + errors: [ + { + message: 'Cannot return boolean for string type', + }, + ], + }, + }); + + await syncProcessor.jitteredRetry({ + query: defaultQuery, + variables: defaultVariables, + opName: defaultOpName, + modelDefinition: defaultModelDefinition, + }); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'syncPosts', + process: 'sync', + errorType: 'BadRecord', + }) + ); + }); + + test('connection timeout', async () => { + const syncProcessor = jitteredRetrySyncProcessorSetup({ + errorHandler, + rejectResponse: { + data, + errors: [ + { + message: 'Connection failed: Connection Timeout', + }, + ], + }, + }); + + await syncProcessor.jitteredRetry({ + query: defaultQuery, + variables: defaultVariables, + opName: defaultOpName, + modelDefinition: defaultModelDefinition, + }); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'syncPosts', + process: 'sync', + errorType: 'Transient', + }) + ); + }); + + test('server error', async () => { + const syncProcessor = jitteredRetrySyncProcessorSetup({ + errorHandler, + rejectResponse: { + data, + errors: [ + { + message: 'Error: Request failed with status code 500', + }, + ], + }, + }); + + await syncProcessor.jitteredRetry({ + query: defaultQuery, + variables: defaultVariables, + opName: defaultOpName, + modelDefinition: defaultModelDefinition, + }); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'syncPosts', + process: 'sync', + errorType: 'Transient', + }) + ); + }); + }); }); function jitteredRetrySyncProcessorSetup({ rejectResponse, resolveResponse, coreMocks, + errorHandler = () => null, }: { rejectResponse?: any; resolveResponse?: any; coreMocks?: object; + errorHandler?: () => null; }) { jest.mock('@aws-amplify/api', () => ({ ...jest.requireActual('@aws-amplify/api'), @@ -326,7 +441,8 @@ function jitteredRetrySyncProcessorSetup({ testInternalSchema, null, // syncPredicates { aws_appsync_authenticationType: 'userPools' }, - defaultAuthStrategy + defaultAuthStrategy, + errorHandler ); return SyncProcessor; diff --git a/packages/datastore/src/sync/processors/errorMaps.ts b/packages/datastore/src/sync/processors/errorMaps.ts index 2747cf66555..85ab7fabfaa 100644 --- a/packages/datastore/src/sync/processors/errorMaps.ts +++ b/packages/datastore/src/sync/processors/errorMaps.ts @@ -4,35 +4,65 @@ export type ErrorMap = Partial<{ [key in ErrorType]: (error: Error) => boolean; }>; +const connectionTimeout = error => + /^Connection failed: Connection Timeout/.test(error.message); + +const serverError = error => + /^Error: Request failed with status code 5\d\d/.test(error.message); + export const mutationErrorMap: ErrorMap = { - BadRecord: error => /^Cannot return \w+ for [\w-_]+ type/.test(error.message), + BadModel: () => false, + BadRecord: error => { + const { message } = error; + return ( + /^Cannot return \w+ for [\w-_]+ type/.test(message) || + /^Variable '.+' has coerced Null value for NonNull type/.test(message) + ); // newly required field, out of date client + }, ConfigError: () => false, - Transient: () => false, - Unauthorized: () => false, + Transient: error => connectionTimeout(error) || serverError(error), + Unauthorized: error => + /^Request failed with status code 401/.test(error.message), }; export const subscriptionErrorMap: ErrorMap = { + BadModel: () => false, BadRecord: () => false, ConfigError: () => false, - Transient: () => false, - Unauthorized: (givenError: any) => { - const { - error: { errors: [{ message = '' } = {}] } = { - errors: [], - }, - } = givenError; - const regex = /Connection failed.+Unauthorized/; - return regex.test(message); + Transient: observableError => { + const error = unwrapObservableError(observableError); + return connectionTimeout(error) || serverError(error); + }, + Unauthorized: observableError => { + const error = unwrapObservableError(observableError); + return /Connection failed.+Unauthorized/.test(error.message); }, }; export const syncErrorMap: ErrorMap = { + BadModel: () => false, BadRecord: error => /^Cannot return \w+ for [\w-_]+ type/.test(error.message), ConfigError: () => false, - Transient: () => false, + Transient: error => connectionTimeout(error) || serverError(error), Unauthorized: () => false, }; +/** + * Get the first error reason of an observable. + * Allows for error maps to be easily applied to observable errors + * + * @param observableError an error from ZenObservable subscribe error callback + */ +function unwrapObservableError(observableError: any) { + const { + error: { errors: [error] } = { + errors: [], + }, + } = observableError; + + return error; +} + export function getMutationErrorType(error: Error): ErrorType { return mapErrorToType(mutationErrorMap, error); } diff --git a/packages/datastore/src/types.ts b/packages/datastore/src/types.ts index 19293215b92..5e1a2c017d1 100644 --- a/packages/datastore/src/types.ts +++ b/packages/datastore/src/types.ts @@ -790,6 +790,7 @@ export type SyncError = { export type ErrorType = | 'ConfigError' + | 'BadModel' | 'BadRecord' | 'Unauthorized' | 'Transient'