Spaces:
Sleeping
Sleeping
Download src/api/errors.rs from SolverForge/solverforge-fsr: direct link, hf CLI and curl.
- Browser
- Download file 5.04 kB
-
https://huggingface.co/spaces/SolverForge/solverforge-fsr/resolve/main/src/api/errors.rs
- Command line
-
hf download hf://spaces/SolverForge/solverforge-fsr/src/api/errors.rs
-
curl -L -o errors.rs https://huggingface.co/spaces/SolverForge/solverforge-fsr/resolve/main/src/api/errors.rs
5.04 kB
| //! HTTP error responses for the field-service routing API. | |
| //! | |
| //! Most failures are fully described by their status code: `404` for an unknown | |
| //! job, `409` for a lifecycle transition the job cannot make. Road-network | |
| //! failures are different. `POST /jobs` and `GET /jobs/{id}/routes` download | |
| //! the Bergamo road network from the public Overpass API (overpass-api.de) the | |
| //! first time they run and cache it in `.osm_cache/`; when that download fails, | |
| //! a bare `503` tells a browser or `curl` user nothing. Those responses carry a | |
| //! JSON body `{"error": "..."}` that names the cause. | |
| use axum::http::StatusCode; | |
| use axum::response::{IntoResponse, Response}; | |
| use axum::Json; | |
| use serde::Serialize; | |
| use crate::data::DemoDataError; | |
| /// An HTTP status with an optional human-readable cause. | |
| /// | |
| /// Plain status codes convert into `ApiError` through `From`, so handlers can | |
| /// keep using `?` on the existing status-only helpers; only errors built with a | |
| /// message gain a JSON body. | |
| pub(super) struct ApiError { | |
| status: StatusCode, | |
| message: Option<String>, | |
| } | |
| struct ErrorBody<'a> { | |
| error: &'a str, | |
| } | |
| impl ApiError { | |
| fn with_message(status: StatusCode, message: String) -> Self { | |
| Self { | |
| status, | |
| message: Some(message), | |
| } | |
| } | |
| } | |
| impl From<StatusCode> for ApiError { | |
| fn from(status: StatusCode) -> Self { | |
| Self { | |
| status, | |
| message: None, | |
| } | |
| } | |
| } | |
| impl IntoResponse for ApiError { | |
| fn into_response(self) -> Response { | |
| match self.message { | |
| Some(message) => (self.status, Json(ErrorBody { error: &message })).into_response(), | |
| None => self.status.into_response(), | |
| } | |
| } | |
| } | |
| /// Maps a failed road-network preparation (`POST /jobs`) to `503` plus cause. | |
| pub(super) fn routing_data_unavailable(error: DemoDataError) -> ApiError { | |
| // Keep the server log line too: operators read stderr, users read the body. | |
| eprintln!("{error}"); | |
| let DemoDataError::Routing(cause) = error; | |
| ApiError::with_message( | |
| StatusCode::SERVICE_UNAVAILABLE, | |
| format!("routing data unavailable: {cause}"), | |
| ) | |
| } | |
| /// Maps a failed route-geometry build (`GET /jobs/{id}/routes`) to a status | |
| /// plus cause. | |
| pub(super) fn route_geometry_failed(error: solverforge_maps::RoutingError) -> ApiError { | |
| eprintln!("Bergamo route geometry failed: {error}"); | |
| let status = match error { | |
| solverforge_maps::RoutingError::InvalidCoordinate { .. } => StatusCode::BAD_REQUEST, | |
| solverforge_maps::RoutingError::Cancelled => StatusCode::REQUEST_TIMEOUT, | |
| solverforge_maps::RoutingError::Network(_) | |
| | solverforge_maps::RoutingError::Parse(_) | |
| | solverforge_maps::RoutingError::Io(_) | |
| | solverforge_maps::RoutingError::SnapFailed { .. } | |
| | solverforge_maps::RoutingError::NoPath { .. } => StatusCode::BAD_GATEWAY, | |
| }; | |
| ApiError::with_message(status, format!("route geometry unavailable: {error}")) | |
| } | |
| mod tests { | |
| use super::*; | |
| async fn body_json(response: Response) -> serde_json::Value { | |
| let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) | |
| .await | |
| .expect("error body should collect"); | |
| serde_json::from_slice(&bytes).expect("error body should be JSON") | |
| } | |
| async fn overpass_failure_returns_503_with_the_cause_in_the_body() { | |
| let error = DemoDataError::Routing(solverforge_maps::RoutingError::Network( | |
| "Overpass fetch failed after trying 1 endpoint(s): connection refused".to_string(), | |
| )); | |
| let response = routing_data_unavailable(error).into_response(); | |
| assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); | |
| let body = body_json(response).await; | |
| let message = body["error"].as_str().expect("error should be a string"); | |
| assert!( | |
| message.starts_with("routing data unavailable: ") | |
| && message.contains("Overpass fetch failed"), | |
| "unexpected error body: {body}" | |
| ); | |
| } | |
| async fn route_geometry_failure_keeps_its_status_and_names_the_cause() { | |
| let error = solverforge_maps::RoutingError::Network("Overpass fetch failed".to_string()); | |
| let response = route_geometry_failed(error).into_response(); | |
| assert_eq!(response.status(), StatusCode::BAD_GATEWAY); | |
| let body = body_json(response).await; | |
| assert!(body["error"] | |
| .as_str() | |
| .is_some_and(|message| message.contains("Overpass fetch failed"))); | |
| } | |
| async fn plain_status_errors_keep_an_empty_body() { | |
| let response = ApiError::from(StatusCode::NOT_FOUND).into_response(); | |
| assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) | |
| .await | |
| .expect("body should collect"); | |
| assert!(bytes.is_empty()); | |
| } | |
| } | |