Add error codes to watering data errors

This commit is contained in:
Matthew Oslan
2019-08-24 19:02:41 -04:00
parent ebf78eb677
commit 9b99b993ab
10 changed files with 159 additions and 55 deletions

80
errors.ts Normal file
View File

@@ -0,0 +1,80 @@
export enum ErrorCode {
/** An error was not properly handled and assigned a more specific error code. */
UnexpectedError = 0,
/** The watering scale could not be calculated due to a problem with the weather information. */
BadWeatherData = 1,
/** Data for a full 24 hour period was not available. */
InsufficientWeatherData = 10,
/** A necessary field was missing from weather data returned by the API. */
MissingWeatherField = 11,
/** An HTTP or parsing error occurred when retrieving weather information. */
WeatherApiError = 12,
/** The specified location name could not be resolved. */
LocationError = 2,
/** An HTTP or parsing error occurred when resolving the location. */
LocationServiceApiError = 20,
/** No matches were found for the specified location name. */
NoLocationFound = 21,
/** The location name was specified in an invalid format (e.g. a PWS ID). */
InvalidLocationFormat = 22,
/** An Error related to personal weather stations. */
PwsError = 3,
/** The PWS ID did not use the correct format. */
InvalidPwsId = 30,
/** The PWS API key did not use the correct format. */
InvalidPwsApiKey = 31,
// TODO use this error code.
/** The PWS API returned an error because a bad API key was specified. */
PwsAuthenticationError = 32,
/** A PWS was specified but the data for the specified AdjustmentMethod cannot be retrieved from a PWS. */
PwsNotSupported = 33,
/** A PWS is required by the WeatherProvider but was not provided. */
NoPwsProvided = 34,
/** An error related to AdjustmentMethods or watering restrictions. */
AdjustmentMethodError = 4,
/** The WeatherProvider is incompatible with the specified AdjustmentMethod. */
UnsupportedAdjustmentMethod = 40,
/** An invalid AdjustmentMethod ID was specified. */
InvalidAdjustmentMethod = 41,
/** An error related to adjustment options (wto). */
AdjustmentOptionsError = 5,
/** The adjustment options could not be parsed. */
MalformedAdjustmentOptions = 50,
/** A required adjustment option was not provided. */
MissingAdjustmentOption = 51
}
/** An error with a numeric code that can be used to identify the type of error. */
export class CodedError {
public readonly errCode: ErrorCode;
public readonly message: string;
public constructor( errCode: ErrorCode, message: string ) {
this.errCode = errCode;
this.message = message;
}
}
/**
* Returns a CodedError representing the specified error. This function can be used to ensure that errors caught in try-catch
* statements have an error code and do not contain any sensitive information in the error message. If `err` is a
* CodedError, the same object will be returned. If `err` is not a CodedError, it is assumed that the error wasn't
* properly handled, so a CodedError with a generic message and an "UnexpectedError" code will be returned. This ensures
* that the user will only be sent errors that were initially raised by the OpenSprinkler weather service and have
* had any sensitive information (like API keys) removed from the error message.
* @param err Any error caught in a try-catch statement.
* @return A CodedError representing the error that was passed to the function.
*/
export function makeCodedError( err: any ): CodedError {
if ( err instanceof CodedError ) {
return err;
} else {
// Include the current timestamp in the error message so the full error can easily be found in the logs.
return new CodedError( ErrorCode.UnexpectedError, "Unexpected error occurred at timestamp " + Date.now() );
}
}

View File

@@ -1,5 +1,6 @@
import { BaseWateringData, GeoCoordinates, PWS } from "../../types";
import { WeatherProvider } from "../weatherProviders/WeatherProvider";
import { ErrorCode } from "../../errors";
export interface AdjustmentMethod {
@@ -14,7 +15,7 @@ export interface AdjustmentMethod {
* of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead.
* @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if
* the watering scale cannot be calculated.
* @throws An error message can be thrown if an error occurs while calculating the watering scale.
* @throws A CodedError may be thrown if an error occurs while calculating the watering scale.
*/
calculateWateringScale(
adjustmentOptions: AdjustmentOptions,
@@ -43,7 +44,6 @@ export interface AdjustmentMethodResponse {
* watering.
*/
rainDelay?: number;
// TODO consider removing this field and breaking backwards compatibility to handle all errors consistently.
/**
* An message to send to the OS firmware to indicate that an error occurred while calculating the watering
* scale and the returned scale either defaulted to some reasonable value or was calculated with incomplete data.
@@ -51,7 +51,9 @@ export interface AdjustmentMethodResponse {
* but newer firmware versions may be able to alert the user that an error occurred and/or default to a
* user-configured watering scale instead of using the one returned by the AdjustmentMethod.
*/
errorMessage?: string;
errMessage?: string;
/** A code describing the type of error that occurred (if one occurred). */
errCode?: ErrorCode;
/** The data that was used to calculate the watering scale, or undefined if no data was used. */
wateringData: BaseWateringData;
}

View File

@@ -3,6 +3,7 @@ import * as moment from "moment";
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod";
import { BaseWateringData, GeoCoordinates, PWS } from "../../types";
import { WeatherProvider } from "../weatherProviders/WeatherProvider";
import { CodedError, ErrorCode } from "../../errors";
/**
@@ -17,7 +18,7 @@ async function calculateEToWateringScale(
): Promise< AdjustmentMethodResponse > {
if ( pws ) {
throw "ETo adjustment method does not support personal weather stations through WUnderground.";
throw new CodedError( ErrorCode.PwsNotSupported, "ETo adjustment method does not support personal weather stations through WUnderground." );
}
// Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future.
@@ -30,7 +31,7 @@ async function calculateEToWateringScale(
}
*/
// This will throw an error message if ETo data cannot be retrieved.
// This will throw a CodedError if ETo data cannot be retrieved.
const etoData: EToData = await weatherProvider.getEToData( coordinates );
let baseETo: number;
@@ -40,7 +41,7 @@ async function calculateEToWateringScale(
if ( adjustmentOptions && "baseETo" in adjustmentOptions ) {
baseETo = adjustmentOptions.baseETo
} else {
throw "A baseline potential ETo must be provided.";
throw new CodedError( ErrorCode.MissingAdjustmentOption, "A baseline potential ETo must be provided." );
}
if ( adjustmentOptions && "elevation" in adjustmentOptions ) {

View File

@@ -2,6 +2,7 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from ".
import { GeoCoordinates, PWS, ZimmermanWateringData } from "../../types";
import { validateValues } from "../weather";
import { WeatherProvider } from "../weatherProviders/WeatherProvider";
import { ErrorCode } from "../../errors";
/**
@@ -41,7 +42,8 @@ async function calculateZimmermanWateringScale(
return {
scale: 100,
rawData: rawData,
errorMessage: "Necessary field(s) were missing from ZimmermanWateringData.",
errCode: ErrorCode.MissingWeatherField,
errMessage: "Necessary field(s) were missing from ZimmermanWateringData.",
wateringData: wateringData
};
}

View File

@@ -13,6 +13,8 @@ import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod";
import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod";
import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod";
import EToAdjustmentMethod from "./adjustmentMethods/EToAdjustmentMethod";
import { CodedError, ErrorCode, makeCodedError } from "../errors";
const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )();
const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )();
@@ -39,16 +41,16 @@ const cache = new WateringScaleCache();
* Resolves a location description to geographic coordinates.
* @param location A partial zip/city/country or a coordinate pair.
* @return A promise that will be resolved with the coordinates of the best match for the specified location, or
* rejected with an error message if unable to resolve the location.
* rejected with a CodedError if unable to resolve the location.
*/
export async function resolveCoordinates( location: string ): Promise< GeoCoordinates > {
if ( !location ) {
throw "No location specified";
throw new CodedError( ErrorCode.InvalidLocationFormat, "No location specified" );
}
if ( filters.pws.test( location ) ) {
throw "PWS ID must be specified in the pws parameter.";
throw new CodedError( ErrorCode.InvalidLocationFormat, "PWS ID must be specified in the pws adjustment option." );
} else if ( filters.gps.test( location ) ) {
const split: string[] = location.split( "," );
return [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ];
@@ -62,7 +64,7 @@ export async function resolveCoordinates( location: string ): Promise< GeoCoordi
data = await httpJSONRequest( url );
} catch (err) {
// If the request fails, indicate no data was found.
throw "An API error occurred while attempting to resolve location";
throw new CodedError( ErrorCode.LocationServiceApiError, "An API error occurred while attempting to resolve location" );
}
// Check if the data is valid
@@ -73,7 +75,7 @@ export async function resolveCoordinates( location: string ): Promise< GeoCoordi
} else {
// Otherwise, indicate no data was found
throw "No match found for specified location";
throw new CodedError( ErrorCode.NoLocationFound, "No match found for specified location" );
}
}
}
@@ -194,7 +196,7 @@ export const getWateringData = async function( req: express.Request, res: expres
remoteAddress = remoteAddress.split( "," )[ 0 ];
if ( !adjustmentMethod ) {
res.send( "Error: Unknown AdjustmentMethod ID" );
sendWateringData( res, { errCode: ErrorCode.InvalidAdjustmentMethod, errMessage: "Invalid AdjustmentMethod ID" } );
return;
}
@@ -207,9 +209,8 @@ export const getWateringData = async function( req: express.Request, res: expres
// Reconstruct JSON string from deformed controller output
adjustmentOptions = JSON.parse( "{" + adjustmentOptionsString + "}" );
} catch ( err ) {
// If the JSON is not valid then abort the claculation
res.send(`Error: Unable to parse options (${err})`);
// If the JSON is not valid then abort the calculation
sendWateringData( res, { errCode: ErrorCode.MalformedAdjustmentOptions, errMessage: `Unable to parse options (${ err })` } );
return;
}
@@ -218,7 +219,12 @@ export const getWateringData = async function( req: express.Request, res: expres
try {
coordinates = await resolveCoordinates( location );
} catch ( err ) {
res.send(`Error: Unable to resolve location (${err})`);
let codedError: CodedError = makeCodedError( err );
if ( codedError.errCode === ErrorCode.UnexpectedError ) {
console.error( `An unexpected error occurred during location resolution for "${ req.url }": `, err );
}
sendWateringData( res, { errCode: codedError.errCode, errMessage: `Unable to resolve location "${ location }" (${ codedError.message })` } );
return;
}
@@ -235,11 +241,11 @@ export const getWateringData = async function( req: express.Request, res: expres
// Make sure that the PWS ID and API key look valid.
if ( !pwsId ) {
res.send("Error: PWS ID does not appear to be valid.");
sendWateringData( res, { errCode: ErrorCode.InvalidPwsId, errMessage: "PWS ID does not appear to be valid" } );
return;
}
if ( !apiKey ) {
res.send("Error: PWS API key does not appear to be valid.");
sendWateringData( res, { errCode: ErrorCode.InvalidPwsApiKey, errMessage: "PWS API key does not appear to be valid" } );
return;
}
@@ -256,7 +262,7 @@ export const getWateringData = async function( req: express.Request, res: expres
sunset: timeData.sunset,
eip: ipToInt( remoteAddress ),
rawData: undefined,
error: undefined
errMessage: undefined
};
let cachedScale: CachedScale;
@@ -277,22 +283,17 @@ export const getWateringData = async function( req: express.Request, res: expres
adjustmentOptions, coordinates, weatherProvider, pws
);
} catch ( err ) {
if ( typeof err != "string" ) {
/* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are
missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error
thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message
since it may contain sensitive information. */
res.send( "Error: an unexpected error occurred." );
console.error( `An unexpected error occurred for ${ req.url }: `, err );
} else {
res.send( "Error: " + err );
let codedError: CodedError = makeCodedError( err );
if ( codedError.errCode === ErrorCode.UnexpectedError ) {
console.error( `An unexpected error occurred during watering scale calculation for "${ req.url }": `, err );
}
sendWateringData( res, { errCode: codedError.errCode, errMessage: codedError.message } );
return;
}
data.scale = adjustmentMethodResponse.scale;
data.error = adjustmentMethodResponse.errorMessage;
data.errMessage = adjustmentMethodResponse.errMessage;
data.rd = adjustmentMethodResponse.rainDelay;
data.rawData = adjustmentMethodResponse.rawData;
@@ -303,7 +304,12 @@ export const getWateringData = async function( req: express.Request, res: expres
try {
wateringData = await weatherProvider.getWateringData( coordinates );
} catch ( err ) {
res.send( "Error: " + err );
let codedError: CodedError = makeCodedError( err );
if ( codedError.errCode === ErrorCode.UnexpectedError ) {
console.error( `An unexpected error occurred during restriction checks for "${ req.url }": `, err );
}
sendWateringData( res, { errCode: codedError.errCode, errMessage: codedError.message } );
return;
}
}
@@ -315,7 +321,7 @@ export const getWateringData = async function( req: express.Request, res: expres
}
// Cache the watering scale if caching is enabled and no error occurred.
if ( weatherProvider.shouldCacheWateringScale() && !data.error ) {
if ( weatherProvider.shouldCacheWateringScale() && !data.errMessage ) {
cache.storeWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions, {
scale: data.scale,
rawData: data.rawData,
@@ -324,8 +330,17 @@ export const getWateringData = async function( req: express.Request, res: expres
}
}
// Return the response to the client in the requested format
if ( outputFormat === "json" ) {
sendWateringData( res, data, outputFormat === "json" );
};
/**
* Sends a response to an HTTP request with a 200 status code.
* @param res The Express Response object to send the response through.
* @param data An object containing key/value pairs that should be formatted in the response body.
* @param useJson Indicates if the response body should use a JSON format instead of a format similar to URL query strings.
*/
function sendWateringData( res: express.Response, data: object, useJson: boolean = false ) {
if ( useJson ) {
res.json( data );
} else {
// Return the data formatted as a URL query string.
@@ -356,7 +371,7 @@ export const getWateringData = async function( req: express.Request, res: expres
}
res.send( formatted );
}
};
}
/**
* Makes an HTTP/HTTPS GET request to the specified URL and returns the response body.

View File

@@ -4,6 +4,7 @@ import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types"
import { httpJSONRequest } from "../weather";
import { WeatherProvider } from "./WeatherProvider";
import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmentMethods/EToAdjustmentMethod";
import { CodedError, ErrorCode } from "../../errors";
export default class DarkSkyWeatherProvider extends WeatherProvider {
@@ -28,11 +29,11 @@ export default class DarkSkyWeatherProvider extends WeatherProvider {
yesterdayData = await httpJSONRequest( yesterdayUrl );
} catch ( err ) {
console.error( "Error retrieving weather information from Dark Sky:", err );
throw "An error occurred while retrieving weather information from Dark Sky."
throw new CodedError( ErrorCode.WeatherApiError, "An error occurred while retrieving weather information from Dark Sky." );
}
if ( !yesterdayData.hourly || !yesterdayData.hourly.data ) {
throw "Necessary field(s) were missing from weather information returned by Dark Sky.";
throw new CodedError( ErrorCode.MissingWeatherField, "Necessary field(s) were missing from weather information returned by Dark Sky." );
}
const samples = [
@@ -41,7 +42,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider {
// Fail if not enough data is available.
if ( samples.length !== 24 ) {
throw "Insufficient data was returned by Dark Sky.";
throw new CodedError( ErrorCode.InsufficientWeatherData, "Insufficient data was returned by Dark Sky." );
}
const totals = { temp: 0, humidity: 0, precip: 0 };
@@ -122,7 +123,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider {
try {
historicData = await httpJSONRequest( historicUrl );
} catch (err) {
throw "An error occurred while retrieving weather information from Dark Sky."
throw new CodedError( ErrorCode.WeatherApiError, "An error occurred while retrieving weather information from Dark Sky." );
}
const cloudCoverInfo: CloudCoverInfo[] = historicData.hourly.data.map( ( hour ): CloudCoverInfo => {

View File

@@ -3,6 +3,7 @@ import { httpJSONRequest } from "../weather";
import { WeatherProvider } from "./WeatherProvider";
import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmentMethods/EToAdjustmentMethod";
import * as moment from "moment";
import { CodedError, ErrorCode } from "../../errors";
export default class OWMWeatherProvider extends WeatherProvider {
@@ -25,12 +26,12 @@ export default class OWMWeatherProvider extends WeatherProvider {
forecast = await httpJSONRequest( forecastUrl );
} catch ( err ) {
console.error( "Error retrieving weather information from OWM:", err );
throw "An error occurred while retrieving weather information from OWM."
throw new CodedError( ErrorCode.WeatherApiError, "An error occurred while retrieving weather information from OWM." );
}
// Indicate watering data could not be retrieved if the forecast data is incomplete.
if ( !forecast || !forecast.list ) {
throw "Necessary field(s) were missing from weather information returned by OWM.";
throw new CodedError( ErrorCode.MissingWeatherField, "Necessary field(s) were missing from weather information returned by OWM." );
}
let totalTemp = 0,
@@ -111,12 +112,12 @@ export default class OWMWeatherProvider extends WeatherProvider {
forecast = await httpJSONRequest( forecastUrl );
} catch (err) {
console.error( "Error retrieving ETo information from OWM:", err );
throw "An error occurred while retrieving ETo information from OWM."
throw new CodedError( ErrorCode.WeatherApiError, "An error occurred while retrieving ETo information from OWM." );
}
// Indicate ETo data could not be retrieved if the forecast data is incomplete.
if ( !forecast || !forecast.list || forecast.list.length < 8 ) {
throw "Insufficient data available from OWM."
throw new CodedError( ErrorCode.InsufficientWeatherData, "Insufficient data available from OWM." );
}
// Take a sample over 24 hours.

View File

@@ -1,12 +1,13 @@
import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types";
import { WeatherProvider } from "./WeatherProvider";
import { httpJSONRequest } from "../weather";
import { CodedError, ErrorCode } from "../../errors";
export default class WUnderground extends WeatherProvider {
async getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > {
if ( !pws ) {
throw "WUnderground WeatherProvider requires a PWS to be specified.";
throw new CodedError( ErrorCode.NoPwsProvided, "WUnderground WeatherProvider requires a PWS to be specified." );
}
const url = `https://api.weather.com/v2/pws/observations/hourly/7day?stationId=${ pws.id }&format=json&units=e&apiKey=${ pws.apiKey }`;
@@ -15,7 +16,7 @@ export default class WUnderground extends WeatherProvider {
data = await httpJSONRequest( url );
} catch ( err ) {
console.error( "Error retrieving weather information from WUnderground:", err );
throw "An error occurred while retrieving weather information from WUnderground."
throw new CodedError( ErrorCode.WeatherApiError, "An error occurred while retrieving weather information from WUnderground." );
}
// Take the 24 most recent observations.
@@ -23,7 +24,7 @@ export default class WUnderground extends WeatherProvider {
// Fail if not enough data is available.
if ( samples.length !== 24 ) {
throw "Insufficient data was returned by WUnderground.";
throw new CodedError( ErrorCode.InsufficientWeatherData, "Insufficient data was returned by WUnderground." );
}
const totals = { temp: 0, humidity: 0, precip: 0 };

View File

@@ -1,5 +1,6 @@
import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types";
import { EToData } from "../adjustmentMethods/EToAdjustmentMethod";
import { CodedError, ErrorCode } from "../../errors";
export class WeatherProvider {
/**
@@ -8,11 +9,11 @@ export class WeatherProvider {
* @param pws The PWS to retrieve the weather from, or undefined if a PWS should not be used. If the implementation
* of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead.
* @return A Promise that will be resolved with the ZimmermanWateringData if it is successfully retrieved,
* or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider
* does not support this method.
* or rejected with a CodedError if an error occurs while retrieving the ZimmermanWateringData (or the WeatherProvider
* does not support this method).
*/
getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > {
throw "Selected WeatherProvider does not support getWateringData";
throw new CodedError( ErrorCode.UnsupportedAdjustmentMethod, "Selected WeatherProvider does not support getWateringData" );
}
/**
@@ -29,12 +30,11 @@ export class WeatherProvider {
/**
* Retrieves the data necessary for calculating potential ETo.
* @param coordinates The coordinates to retrieve the data for.
* @return A Promise that will be resolved with the EToData if it is successfully retrieved,
* or rejected with an error message if an error occurs while retrieving the EToData or the WeatherProvider does
* not support this method.
* @return A Promise that will be resolved with the EToData if it is successfully retrieved, or rejected with a
* CodedError if an error occurs while retrieving the EToData (or the WeatherProvider does not support this method).
*/
getEToData( coordinates: GeoCoordinates ): Promise< EToData > {
throw "Selected WeatherProvider does not support getEToData";
throw new CodedError( ErrorCode.UnsupportedAdjustmentMethod, "Selected WeatherProvider does not support getEToData" );
};
/**

View File

@@ -8,6 +8,7 @@
"skipLibCheck": true
},
"include": [
"errors.ts",
"server.ts",
"types.ts",
"routes/**/*"