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
This commit is contained in:
Paul van Tilburg 2022-06-06 16:28:24 +02:00
parent 8a2a6d769d
commit 014ca5a151
Signed by: paul
GPG Key ID: C6DE073EDA9EEC4D
3 changed files with 127 additions and 26 deletions

View File

@ -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 If selected, it merges items from the AQI and pollen metric into `PAQI` by
selecting the maximum value for each hour: selecting the maximum value for each hour:
``` json ```json
{ {
"lat": 52.0905169, "lat": 52.0905169,
"lon": 5.1109709, "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 ## Map API endpoint
The `/map` API endpoint basically only exists for debugging purposes. Given an 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 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 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 ## License

View File

@ -3,20 +3,21 @@
//! This module is used to construct a [`Forecast`] for the given position by retrieving data for //! This module is used to construct a [`Forecast`] for the given position by retrieving data for
//! the requested metrics from their providers. //! the requested metrics from their providers.
use std::collections::BTreeMap;
use std::fmt;
use rocket::serde::Serialize; use rocket::serde::Serialize;
use crate::maps::MapsHandle; use crate::maps::MapsHandle;
use crate::position::Position; use crate::position::Position;
use crate::providers;
use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample}; use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample};
use crate::providers::combined::Item as CombinedItem; use crate::providers::combined::Item as CombinedItem;
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem; use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
use crate::{providers, Error};
/// The current forecast for a specific location. /// The current forecast for a specific location.
/// ///
/// Only the metrics asked for are included as well as the position and current time. /// 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)] #[derive(Debug, Default, Serialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub(crate) struct Forecast { pub(crate) struct Forecast {
@ -60,6 +61,10 @@ pub(crate) struct Forecast {
/// The UV index (when asked for). /// The UV index (when asked for).
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")] #[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
uvi: Option<Vec<BuienradarSample>>, uvi: Option<Vec<BuienradarSample>>,
/// Any errors that occurred.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
errors: BTreeMap<Metric, String>,
} }
impl Forecast { impl Forecast {
@ -72,13 +77,21 @@ impl Forecast {
..Default::default() ..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. /// The supported forecast metrics.
/// ///
/// This is used for selecting which metrics should be calculated & returned. /// This is used for selecting which metrics should be calculated & returned.
#[allow(clippy::upper_case_acronyms)] #[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 { pub(crate) enum Metric {
/// All metrics. /// All metrics.
#[field(value = "all")] #[field(value = "all")]
@ -94,7 +107,9 @@ pub(crate) enum Metric {
/// The particulate matter in the air. /// The particulate matter in the air.
PM10, PM10,
/// The pollen in the air. /// The pollen in the air.
#[serde(rename(serialize = "pollen"))]
Pollen, Pollen,
#[serde(rename(serialize = "precipitation"))]
/// The precipitation. /// The precipitation.
Precipitation, Precipitation,
/// The UV index. /// 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. /// Calculates and returns the forecast.
/// ///
/// The provided list `metrics` determines what will be included in 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 { for metric in metrics {
match metric { match metric {
// This should have been expanded to all the metrics matched below. // 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::All => unreachable!("The all metric should have been expanded"),
Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await.ok(), Metric::AQI => {
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await.ok(), forecast.aqi = providers::luchtmeetnet::get(position, metric)
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await.ok(), .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 => { Metric::PAQI => {
forecast.paqi = providers::combined::get(position, metric, maps_handle) forecast.paqi = providers::combined::get(position, metric, maps_handle)
.await .await
.map_err(|err| forecast.log_error(metric, err))
.ok() .ok()
} }
Metric::PM10 => { 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 => { Metric::Pollen => {
forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle) forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle)
.await .await
.map_err(|err| forecast.log_error(metric, err))
.ok() .ok()
} }
Metric::Precipitation => { Metric::Precipitation => {
forecast.precipitation = providers::buienradar::get_items(position, metric) forecast.precipitation = providers::buienradar::get_items(position, metric)
.await .await
.map_err(|err| forecast.log_error(metric, err))
.ok() .ok()
} }
Metric::UVI => { Metric::UVI => {
forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle) forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle)
.await .await
.map_err(|err| forecast.log_error(metric, err))
.ok() .ok()
} }
} }

View File

@ -10,13 +10,13 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use rocket::fairing::AdHoc; use rocket::fairing::AdHoc;
use rocket::http::Status;
use rocket::response::Responder; use rocket::response::Responder;
use rocket::serde::json::Json; 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, Metric};
use self::forecast::{forecast, Forecast}; use self::maps::{mark_map, Error as MapsError, Maps, MapsHandle};
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
use self::position::{resolve_address, Position}; use self::position::{resolve_address, Position};
pub(crate) mod forecast; pub(crate) mod forecast;
@ -56,10 +56,26 @@ pub(crate) enum Error {
NoPositionFound, NoPositionFound,
/// Encountered an unsupported metric. /// Encountered an unsupported metric.
#[error("Encountered an unsupported metric: {0:?}")] #[error("Encountered an unsupported metric: {0}")]
UnsupportedMetric(Metric), 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. /// Result type that defaults to [`Error`] as the default error type.
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>; pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
@ -73,11 +89,11 @@ async fn forecast_address(
address: String, address: String,
metrics: Vec<Metric>, metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>, maps_handle: &State<MapsHandle>,
) -> Option<Json<Forecast>> { ) -> Result<Json<Forecast>> {
let position = resolve_address(address).await.ok()?; // FIXME: Handle error! let position = resolve_address(address).await?;
let forecast = forecast(position, metrics, maps_handle).await; let forecast = forecast(position, metrics, maps_handle).await;
Some(Json(forecast)) Ok(Json(forecast))
} }
/// Handler for retrieving the forecast for a geocoded position. /// Handler for retrieving the forecast for a geocoded position.
@ -103,11 +119,11 @@ async fn map_address(
address: String, address: String,
metric: Metric, metric: Metric,
maps_handle: &State<MapsHandle>, maps_handle: &State<MapsHandle>,
) -> Option<PngImageData> { ) -> Result<PngImageData> {
let position = resolve_address(address).await.ok()?; // FIXME: Handle error! let position = resolve_address(address).await?;
let image_data = mark_map(position, metric, maps_handle).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. /// Handler for showing the current map with the geocoded position for a specific metric.
@ -119,11 +135,11 @@ async fn map_geo(
lon: f64, lon: f64,
metric: Metric, metric: Metric,
maps_handle: &State<MapsHandle>, maps_handle: &State<MapsHandle>,
) -> Option<PngImageData> { ) -> Result<PngImageData> {
let position = Position::new(lat, lon); let position = Position::new(lat, lon);
let image_data = mark_map(position, metric, maps_handle).await; 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. /// Sets up Rocket.
@ -270,7 +286,7 @@ mod tests {
let response = client let response = client
.get("/map?address=eindhoven&metric=pollen") .get("/map?address=eindhoven&metric=pollen")
.dispatch(); .dispatch();
assert_eq!(response.status(), Status::NotFound); assert_eq!(response.status(), Status::ServiceUnavailable);
// Load some dummy map. // Load some dummy map.
let mut maps = maps_handle_clone let mut maps = maps_handle_clone
@ -307,7 +323,7 @@ mod tests {
// No maps available yet. // No maps available yet.
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); 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. // Load some dummy map.
let mut maps = maps_handle_clone let mut maps = maps_handle_clone