Skip to content

Commit

Permalink
feat(datastore): add error maps for error handler (aws-amplify#9918)
Browse files Browse the repository at this point in the history
* feat(datastore): add error map for newly required field

* feat(datastore): add error map for unauthorized create

* feat(datastore): add connection timeout error map

* feat(datastore): add server error map

* docs: add comment on error map util

* test: add error map unit tests
  • Loading branch information
dpilch authored Jun 9, 2022
1 parent 61d60c7 commit 3a27096
Show file tree
Hide file tree
Showing 4 changed files with 259 additions and 25 deletions.
93 changes: 90 additions & 3 deletions packages/datastore/__tests__/mutation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RestClient } from '@aws-amplify/api-rest';
import {
MutationProcessor,
safeJitteredBackoff,
Expand All @@ -22,6 +23,7 @@ let modelInstanceCreator: any;
let Model: PersistentModelConstructor<ModelType>;
let PostCustomPK: PersistentModelConstructor<PostCustomPKType>;
let PostCustomPKSort: PersistentModelConstructor<PostCustomPKSortType>;
let axiosError;

describe('Jittered retry', () => {
it('should progress exponentially until some limit', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -181,6 +261,7 @@ jest.mock('@aws-amplify/api', () => {
graphqlInstance.configure(awsconfig);

return {
...jest.requireActual('@aws-amplify/api'),
graphql: graphqlInstance.graphql.bind(graphqlInstance),
};
});
Expand All @@ -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/', () => ({
Expand Down Expand Up @@ -267,7 +350,7 @@ async function instantiateMutationProcessor() {
aws_appsync_apiKey: 'da2-xxxxxxxxxxxxxxxxxxxxxx',
},
() => null,
() => null
errorHandler
);

(mutationProcessor as any).observer = true;
Expand Down Expand Up @@ -297,7 +380,7 @@ async function createMutationEvent(model, opType): Promise<MutationEvent> {
}

// expected error when experiencing 100% packet loss
const axiosError = {
const timeoutError = {
message: 'timeout of 0ms exceeded',
name: 'Error',
stack:
Expand Down Expand Up @@ -346,3 +429,7 @@ const axiosError = {
},
code: 'ECONNABORTED',
};

beforeEach(() => {
axiosError = timeoutError;
});
134 changes: 125 additions & 9 deletions packages/datastore/__tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -326,7 +441,8 @@ function jitteredRetrySyncProcessorSetup({
testInternalSchema,
null, // syncPredicates
{ aws_appsync_authenticationType: 'userPools' },
defaultAuthStrategy
defaultAuthStrategy,
errorHandler
);

return SyncProcessor;
Expand Down
56 changes: 43 additions & 13 deletions packages/datastore/src/sync/processors/errorMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading

0 comments on commit 3a27096

Please sign in to comment.