From 3e28d6f4210aa508b36044ee40983cc0b0fed4d1 Mon Sep 17 00:00:00 2001 From: Brett Sun Date: Mon, 23 Nov 2015 17:12:47 +0100 Subject: [PATCH] Add functionality to match known errors Implemented as part of AD-1360 and AD-1378 as a way to match an error situation to a known class of errors so that we can modify behaviour or show pretty text to the user. --- js/constants/error_constants.js | 205 ++++++++++++++++++++++++++++++++ js/utils/error_utils.js | 5 +- js/utils/general_utils.js | 41 +++++++ 3 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 js/constants/error_constants.js diff --git a/js/constants/error_constants.js b/js/constants/error_constants.js new file mode 100644 index 00000000..81e04f04 --- /dev/null +++ b/js/constants/error_constants.js @@ -0,0 +1,205 @@ +'use strict' + +import { deepMatchObject } from '../utils/general_utils'; +import { getLangText } from '../utils/lang_utils'; + +/** + * ErrorClasses + * ============ + * Known error classes based on groupings (ie. where they happened, which component, etc). + * + * Error classes have a test object that can be used to test whether or not an error + * object matches that specific class. Properties in the test object will be recursively + * checked in the error object, and the error object is only matched to the class if all + * tests succeed. See testErrorAgainstClass() below for the implementation of the matching. + * + * ErrorClasses.default.default is the generic error for errors not identified under any + * grouping and class. + * + * Format: + * ErrorClasses = { + * 'errorGrouping': { + * 'errorClassName': ErrorClass + * ... + * }, + * ... + * 'default': { + * ... + * 'default': generic error for errors that don't fall under any grouping and class + * } + * } + * + * Each class is of the format: + * ErrorClass = { + * 'name': name of the class + * 'group': grouping of the error, + * 'prettifiedText': prettified text for the class + * 'test': { + * prop1: property in the error object to recursively match against using + * either === or, if the property is a string, substring match + * (ie. indexOf() >= 0) + * ... + * }, + * } + * + * Test object examples + * ==================== + * A class like this: + * + * 'errorClass': { + * 'test': { + * 'reason': 'Invalid server response', + * 'xhr': { + * 'response': 'Internal error', + * 'status': 500 + * } + * } + * } + * + * will match this error object: + * + * error = { + * 'reason': 'Invalid server response', + * 'xhr': { // Simplified version of the XMLHttpRequest object responsible for the failure + * 'response': 'Internal error', + * 'status': 500 + * } + * } + * + * but will *NOT* match this error object: + * + * error = { + * 'reason': 'Invalid server response', + * 'xhr': { + * 'response': 'Unauthorized', + * 'status': 401 + * } + * } + * + * A common use case is for the test to just be against the error.reason string. + * In these cases, setting the test object to be just a string will enforce this test, + * so something like this: + * + * 'errorClass': { + * 'test': { + * 'reason': 'Invalid server response' + * } + * } + * + * is the same as: + * + * 'errorClass': { + * 'test': 'Invalid server response' + * } + */ +const ErrorClasses = { + 'upload': { + 'requestTimeTooSkewed': { + 'prettifiedText': getLangText('It appears that the time set on your computer is too ' + + 'inaccurate compared to your current local time. As a security ' + + 'measure, we check to make sure that our users are not falsifying ' + + "their registration times. Please synchronize your computer's " + + 'clock and try again.'), + 'test': { + 'xhr': { + 'response': 'RequestTimeTooSkewed' + } + } + }, + 'chunkSignatureError': { + 'prettifiedText': getLangText('We are experiencing some problems with uploads at the moment and ' + + 'are working to resolve them. Please try again in a few hours.'), + 'test': 'Problem signing the chunk' + }, + }, + 'default': { + 'default': { + 'prettifiedText': getLangText("It looks like there's been a problem on our end. If you keep experiencing this error, please contact us.") + } + } +}; + +// Dynamically inject the name and group properties into the classes +Object.keys(ErrorClasses).forEach((errorGroup) => { + Object.keys(ErrorClasses[errorGroup]).forEach((errorClass) => { + errorClass.name = errorClass; + errorClass.group = errorGroup; + }); +}); + +/** + * Returns prettified text for a given error by trying to match it to + * a known error in ErrorClasses or the given class. + * + * One should provide a class (eg. ErrorClasses.upload.requestTimeTooSkewed) + * if they already have an error in mind that they want to match against rather + * than all the available error classes. + * + * @param {object} error An error with the following: + * @param {string} error.type Type of error + * @param {string} error.reason Reason of error + * @param {(XMLHttpRequest)} error.xhr XHR associated with the error + * @param {(any)} error.* Any other property as necessary + * + * @param {(object)} errorClass ErrorClass to match against the given error. + * Signature should be similar to ErrorClasses' classes (see above). + * @param {object|string} errorClass.test Test object to recursively match against the given error + * @param {string} errorClass.prettifiedText Prettified text to return if the test matches + * + * @return {string} Prettified error string. Returns the default error string if no + * error class was matched to the given error. + */ +function getPrettifiedError(error, errorClass) { + const matchedClass = errorClass ? testErrorAgainstClass(error, errorClass) : testErrorAgainstAll(error); + return (matchedClass && matchedClass.prettifiedText) || ErrorClasses.default.default.prettifiedText; +} + +/** + * Tests the given error against all items in ErrorClasses and returns + * the matching class if available. + * See getPrettifiedError() for the signature of @param error. + * @return {(object)} Matched error class + */ +function testErrorAgainstAll(error) { + const type = error.type != null ? error.type : 'default'; + const errorGroup = ErrorClasses[type]; + + return Object + .keys(errorGroup) + .reduce((result, key) => { + return result || testErrorAgainstClass(error, errorGroup[key]); + }, null); +} + +/** + * Tests the error against the class by recursively testing the + * class's test object against the error. + * Implements the test matching behaviour described in ErrorClasses. + * + * See getPrettifiedError() for the signatures of @param error and @param errorClass. + * @return {(object)} Returns the given class if the test succeeds. + */ +function testErrorAgainstClass(error, errorClass) { + // Automatically fail classes if no tests present + if (!errorClass.test) { + return; + } + + if (typeof errorClass.test === 'string') { + errorClass.test = { + reason: errorClass.test + }; + } + + return deepMatchObject(error, errorClass.test, (objProp, matchProp) => { + return (objProp === matchProp || (typeof objProp === 'string' && objProp.indexOf(matchProp) >= 0)); + }) ? errorClass : null; +} + +// Need to export with the clause syntax as we change ErrorClasses after its declaration. +export { + ErrorClasses, + getPrettifiedError, + testErrorAgainstAll, + testErrorAgainstClass +}; diff --git a/js/utils/error_utils.js b/js/utils/error_utils.js index 4e9de6e2..e80819dc 100644 --- a/js/utils/error_utils.js +++ b/js/utils/error_utils.js @@ -4,7 +4,6 @@ import Raven from 'raven-js'; import AppConstants from '../constants/application_constants'; - /** * Logs an error in to the console but also sends it to * Sentry. @@ -14,7 +13,6 @@ import AppConstants from '../constants/application_constants'; * @param {string} comment Will also be submitted to Sentry, but will not be logged */ function logGlobal(error, ignoreSentry, comment) { - console.error(error); if(!ignoreSentry) { @@ -24,7 +22,6 @@ function logGlobal(error, ignoreSentry, comment) { Raven.captureException(error); } } - } export function initLogging() { @@ -36,4 +33,4 @@ export function initLogging() { window.onerror = Raven.process; console.logGlobal = logGlobal; -} \ No newline at end of file +} diff --git a/js/utils/general_utils.js b/js/utils/general_utils.js index e717fa75..d690929e 100644 --- a/js/utils/general_utils.js +++ b/js/utils/general_utils.js @@ -178,6 +178,47 @@ export function omitFromObject(obj, filter) { } } +/** + * Recursively tests an object against a "match" object to see if the + * object is similar to the "match" object. In other words, this will + * deeply traverse the "match" object's properties and check them + * against the object by using the testFn. + * + * The object is considered a match if all primitive properties in the + * "match" object are found and accepted in the object by the testFn. + * + * @param {object} obj Object to test + * @param {object} match "Match" object to test against + * @param {(function)} testFn Function to use on each property test. + * Return true to accept the match. + * By default, applies strict equality using === + * @return {boolean} True if obj matches the "match" object + */ +export function deepMatchObject(obj, match, testFn) { + if (typeof match !== 'object') { + throw new Error('Your specified match argument was not an object'); + } + + if (typeof testFn !== 'function') { + testFn = (objProp, matchProp) => { + return objProp === matchProp; + }; + } + + return Object + .keys(match) + .reduce((result, matchKey) => { + if (!result) { return false; } + + const objProp = obj[matchKey]; + const matchProp = match[matchKey]; + + return (typeof matchProp === 'object') ? testObjAgainstMatch(objProp, matchProp, testFn) + : testFn(objProp, matchProp); + + }, true); +} + /** * Takes a string and breaks it at the supplied index and replaces it * with a (potentially) short string that also has been provided