From 014ca5a1517296a309f32be99bc54545c65ca272 Mon Sep 17 00:00:00 2001 From: Paul van Tilburg Date: Mon, 6 Jun 2022 16:28:24 +0200 Subject: [PATCH] Handle errors on the API side * The map endpoints return an HTTP 404 error in case of unknown or out-of-bound locations * The forecast endpoint with an address returns an HTTP 404 with error JSON in case geocoding fails * The forecast endpoints return the errors per metric in the `errors` field of the forecast * Implement `Display` for `Metric` * Use a `BTreeMap` to have an ordered `errors` field/object * Also log the errors to the console * Update the tests * Document the errors that can occur --- README.md | 37 ++++++++++++++++++++++++-- src/forecast.rs | 70 ++++++++++++++++++++++++++++++++++++++++++------- src/lib.rs | 46 +++++++++++++++++++++----------- 3 files changed, 127 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 49b79eb..3a7c9e6 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ The PAQI (pollen/air quality index) metric is a special combined metric. If selected, it merges items from the AQI and pollen metric into `PAQI` by selecting the maximum value for each hour: -``` json +```json { "lat": 52.0905169, "lon": 5.1109709, @@ -158,6 +158,38 @@ selecting the maximum value for each hour: } ``` +#### Errors + +If geocoding of an address is requested but fails, a not found error is returned (HTTP 404). +with the following body (this will change in the future): + +```json +{ + "error": { + "code": 404, + "reason": "Not Found", + "description": "The requested resource could not be found." + } +} +``` + +If for any specific metric an error occurs, the list with forecast items will be absent. +However, the `errors` field will contain the error message for each failed metric. +For example, say Buienradar is down and precipitation forecast items can not be +retrieved: + +```json +{ + "lat": 52.0905169, + "lon": 5.1109709, + "time": 1654524574, + ... + "errors": { + "precipitation": "HTTP request error: error sending request for url (https://gpsgadget.buienradar.nl/data/raintext?lat=52.09&lon=5.11): error trying to connect: tcp connect error: Connection refused (os error 111)" + } +} +``` + ## Map API endpoint The `/map` API endpoint basically only exists for debugging purposes. Given an @@ -182,7 +214,8 @@ GET /map?lat=52.0902&lon=5.1114&metric=pollen The response is a PNG image with a crosshair drawn on the map. If geocoding of an address fails or if the position is out of bounds of the map, nothing is -returned (HTTP 404). +returned (HTTP 404). If the maps cannot/have not been downloaded or cached yet, +a service unavailable error is returned (HTTP 503). ## License diff --git a/src/forecast.rs b/src/forecast.rs index 267bed4..841f4ab 100644 --- a/src/forecast.rs +++ b/src/forecast.rs @@ -3,20 +3,21 @@ //! This module is used to construct a [`Forecast`] for the given position by retrieving data for //! the requested metrics from their providers. +use std::collections::BTreeMap; +use std::fmt; + use rocket::serde::Serialize; use crate::maps::MapsHandle; use crate::position::Position; -use crate::providers; use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample}; use crate::providers::combined::Item as CombinedItem; use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem; +use crate::{providers, Error}; /// The current forecast for a specific location. /// /// Only the metrics asked for are included as well as the position and current time. -/// -// TODO: Fill in missing data (#4) #[derive(Debug, Default, Serialize)] #[serde(crate = "rocket::serde")] pub(crate) struct Forecast { @@ -60,6 +61,10 @@ pub(crate) struct Forecast { /// The UV index (when asked for). #[serde(rename = "UVI", skip_serializing_if = "Option::is_none")] uvi: Option>, + + /// Any errors that occurred. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + errors: BTreeMap, } impl Forecast { @@ -72,13 +77,21 @@ impl Forecast { ..Default::default() } } + + fn log_error(&mut self, metric: Metric, error: Error) { + eprintln!("💥 Encountered error during forecast: {}", error); + self.errors.insert(metric, error.to_string()); + } } /// The supported forecast metrics. /// /// This is used for selecting which metrics should be calculated & returned. #[allow(clippy::upper_case_acronyms)] -#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, rocket::FromFormField)] +#[derive( + Copy, Clone, Debug, Eq, Hash, Ord, PartialOrd, PartialEq, Serialize, rocket::FromFormField, +)] +#[serde(crate = "rocket::serde")] pub(crate) enum Metric { /// All metrics. #[field(value = "all")] @@ -94,7 +107,9 @@ pub(crate) enum Metric { /// The particulate matter in the air. PM10, /// The pollen in the air. + #[serde(rename(serialize = "pollen"))] Pollen, + #[serde(rename(serialize = "precipitation"))] /// The precipitation. Precipitation, /// The UV index. @@ -110,6 +125,22 @@ impl Metric { } } +impl fmt::Display for Metric { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Metric::All => write!(f, "All"), + Metric::AQI => write!(f, "AQI"), + Metric::NO2 => write!(f, "NO2"), + Metric::O3 => write!(f, "O3"), + Metric::PAQI => write!(f, "PAQI"), + Metric::PM10 => write!(f, "PM10"), + Metric::Pollen => write!(f, "pollen"), + Metric::Precipitation => write!(f, "precipitation"), + Metric::UVI => write!(f, "UVI"), + } + } +} + /// Calculates and returns the forecast. /// /// The provided list `metrics` determines what will be included in the forecast. @@ -131,32 +162,53 @@ pub(crate) async fn forecast( for metric in metrics { match metric { // This should have been expanded to all the metrics matched below. - // FIXME: Handle the errors! Metric::All => unreachable!("The all metric should have been expanded"), - Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await.ok(), - Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await.ok(), - Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await.ok(), + Metric::AQI => { + forecast.aqi = providers::luchtmeetnet::get(position, metric) + .await + .map_err(|err| forecast.log_error(metric, err)) + .ok() + } + Metric::NO2 => { + forecast.no2 = providers::luchtmeetnet::get(position, metric) + .await + .map_err(|err| forecast.log_error(metric, err)) + .ok() + } + Metric::O3 => { + forecast.o3 = providers::luchtmeetnet::get(position, metric) + .await + .map_err(|err| forecast.log_error(metric, err)) + .ok() + } Metric::PAQI => { forecast.paqi = providers::combined::get(position, metric, maps_handle) .await + .map_err(|err| forecast.log_error(metric, err)) .ok() } Metric::PM10 => { - forecast.pm10 = providers::luchtmeetnet::get(position, metric).await.ok() + forecast.pm10 = providers::luchtmeetnet::get(position, metric) + .await + .map_err(|err| forecast.log_error(metric, err)) + .ok() } Metric::Pollen => { forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle) .await + .map_err(|err| forecast.log_error(metric, err)) .ok() } Metric::Precipitation => { forecast.precipitation = providers::buienradar::get_items(position, metric) .await + .map_err(|err| forecast.log_error(metric, err)) .ok() } Metric::UVI => { forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle) .await + .map_err(|err| forecast.log_error(metric, err)) .ok() } } diff --git a/src/lib.rs b/src/lib.rs index 3aee86a..bc7ab02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,13 +10,13 @@ use std::sync::{Arc, Mutex}; use rocket::fairing::AdHoc; +use rocket::http::Status; use rocket::response::Responder; use rocket::serde::json::Json; -use rocket::{get, routes, Build, Rocket, State}; +use rocket::{get, routes, Build, Request, Rocket, State}; -pub(crate) use self::forecast::Metric; -use self::forecast::{forecast, Forecast}; -pub(crate) use self::maps::{mark_map, Maps, MapsHandle}; +use self::forecast::{forecast, Forecast, Metric}; +use self::maps::{mark_map, Error as MapsError, Maps, MapsHandle}; use self::position::{resolve_address, Position}; pub(crate) mod forecast; @@ -56,10 +56,26 @@ pub(crate) enum Error { NoPositionFound, /// Encountered an unsupported metric. - #[error("Encountered an unsupported metric: {0:?}")] + #[error("Encountered an unsupported metric: {0}")] UnsupportedMetric(Metric), } +impl<'r, 'o: 'r> rocket::response::Responder<'r, 'o> for Error { + fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> { + eprintln!("💥 Encountered error during request: {}", self); + + let status = match self { + Error::NoPositionFound => Status::NotFound, + Error::Maps(MapsError::NoMapsYet) => Status::ServiceUnavailable, + Error::Maps(MapsError::OutOfBoundCoords(_, _)) => Status::NotFound, + Error::Maps(MapsError::OutOfBoundOffset(_)) => Status::NotFound, + _ => Status::InternalServerError, + }; + + Err(status) + } +} + /// Result type that defaults to [`Error`] as the default error type. pub(crate) type Result = std::result::Result; @@ -73,11 +89,11 @@ async fn forecast_address( address: String, metrics: Vec, maps_handle: &State, -) -> Option> { - let position = resolve_address(address).await.ok()?; // FIXME: Handle error! +) -> Result> { + let position = resolve_address(address).await?; let forecast = forecast(position, metrics, maps_handle).await; - Some(Json(forecast)) + Ok(Json(forecast)) } /// Handler for retrieving the forecast for a geocoded position. @@ -103,11 +119,11 @@ async fn map_address( address: String, metric: Metric, maps_handle: &State, -) -> Option { - let position = resolve_address(address).await.ok()?; // FIXME: Handle error! +) -> Result { + let position = resolve_address(address).await?; let image_data = mark_map(position, metric, maps_handle).await; - image_data.map(PngImageData).ok() // FIXME: Handle the error! + image_data.map(PngImageData) } /// Handler for showing the current map with the geocoded position for a specific metric. @@ -119,11 +135,11 @@ async fn map_geo( lon: f64, metric: Metric, maps_handle: &State, -) -> Option { +) -> Result { let position = Position::new(lat, lon); let image_data = mark_map(position, metric, maps_handle).await; - image_data.map(PngImageData).ok() // FIXME: Handle the error! + image_data.map(PngImageData) } /// Sets up Rocket. @@ -270,7 +286,7 @@ mod tests { let response = client .get("/map?address=eindhoven&metric=pollen") .dispatch(); - assert_eq!(response.status(), Status::NotFound); + assert_eq!(response.status(), Status::ServiceUnavailable); // Load some dummy map. let mut maps = maps_handle_clone @@ -307,7 +323,7 @@ mod tests { // No maps available yet. let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); - assert_eq!(response.status(), Status::NotFound); + assert_eq!(response.status(), Status::ServiceUnavailable); // Load some dummy map. let mut maps = maps_handle_clone