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
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

View File

@ -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<Vec<BuienradarSample>>,
/// Any errors that occurred.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
errors: BTreeMap<Metric, String>,
}
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()
}
}

View File

@ -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<T, E = Error> = std::result::Result<T, E>;
@ -73,11 +89,11 @@ async fn forecast_address(
address: String,
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Option<Json<Forecast>> {
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
) -> Result<Json<Forecast>> {
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<MapsHandle>,
) -> Option<PngImageData> {
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
) -> Result<PngImageData> {
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<MapsHandle>,
) -> Option<PngImageData> {
) -> Result<PngImageData> {
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