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

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;
}
@@ -217,8 +218,13 @@ export const getWateringData = async function( req: express.Request, res: expres
let coordinates: GeoCoordinates;
try {
coordinates = await resolveCoordinates( location );
} catch (err) {
res.send(`Error: Unable to resolve location (${err})`);
} catch ( 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.
@@ -344,7 +359,7 @@ export const getWateringData = async function( req: express.Request, res: expres
case "object":
// Convert objects to JSON.
value = JSON.stringify( value );
// Fallthrough.
// Fallthrough.
case "string":
/* URL encode strings. Since the OS firmware uses a primitive version of query string parsing and
decoding, only some characters need to be escaped and only spaces ("+" or "%20") will be decoded. */
@@ -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" );
};
/**