diff --git a/src/config.rs b/src/config.rs index 923823d52..402cf3339 100644 --- a/src/config.rs +++ b/src/config.rs @@ -119,6 +119,8 @@ pub struct Config { pub(crate) build_cpu_limit: Option, pub(crate) build_default_memory_limit: Option, pub(crate) include_default_targets: bool, + + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub(crate) disable_memory_limit: bool, // automatic rebuild configuration diff --git a/src/web/releases.rs b/src/web/releases.rs index 2696f26c5..39b52b3ca 100644 --- a/src/web/releases.rs +++ b/src/web/releases.rs @@ -33,6 +33,21 @@ use url::form_urlencoded; use super::cache::CachePolicy; +// Introduce SearchError as new error type +#[derive(Debug, thiserror::Error)] +pub enum SearchError { + #[error("crates.io error: {0}")] + CratesIo(String), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl From for SearchError { + fn from(err: sqlx::Error) -> Self { + SearchError::Other(anyhow::Error::from(err)) + } +} + /// Number of release in home page const RELEASES_IN_HOME: i64 = 15; /// Releases in /releases page @@ -149,8 +164,13 @@ async fn get_search_results( conn: &mut sqlx::PgConnection, registry: &RegistryApi, query_params: &str, -) -> Result { - let crate::registry_api::Search { crates, meta } = registry.search(query_params).await?; +) -> Result { + // Capture responses returned by registry + let result = registry.search(query_params).await; + let crate::registry_api::Search { crates, meta } = match result { + Ok(results_from_search_request) => results_from_search_request, + Err(err) => return handle_registry_error(err), + }; let names = Arc::new( crates @@ -233,6 +253,35 @@ async fn get_search_results( }) } +// Categorize errors from registry +fn handle_registry_error(err: anyhow::Error) -> Result { + // Capture crates.io API error + if let Some(registry_request_error) = err.downcast_ref::() + && let Some(status) = registry_request_error.status() + && (status.is_client_error() || status.is_server_error()) + { + return Err(SearchError::CratesIo(format!( + "crates.io returned {status}: {registry_request_error}" + ))); + } + // Move all other error types to this wrapper + Err(SearchError::Other(err)) +} + +//Error message to gracefully display +fn create_search_error_response(query: String, sort_by: String, error_message: String) -> Search { + Search { + title: format!("Search service is not currently available: {error_message}"), + releases: vec![], + search_query: Some(query), + search_sort_by: Some(sort_by), + previous_page_link: None, + next_page_link: None, + release_type: ReleaseType::Search, + status: http::StatusCode::SERVICE_UNAVAILABLE, + } +} + #[derive(Template)] #[template(path = "core/home.html")] #[derive(Debug, Clone, PartialEq, Eq)] @@ -589,7 +638,7 @@ pub(crate) async fn search_handler( } } - get_search_results(&mut conn, ®istry, query_params).await? + get_search_results(&mut conn, ®istry, query_params).await } else if !query.is_empty() { let query_params: String = form_urlencoded::Serializer::new(String::new()) .append_pair("q", &query) @@ -597,11 +646,24 @@ pub(crate) async fn search_handler( .append_pair("per_page", &RELEASES_IN_RELEASES.to_string()) .finish(); - get_search_results(&mut conn, ®istry, &query_params).await? + get_search_results(&mut conn, ®istry, &query_params).await } else { return Err(AxumNope::NoResults); }; + let search_result = match search_result { + Ok(result) => result, + Err(SearchError::CratesIo(error_message)) => { + // Return a user-friendly error response + return Ok(create_search_error_response(query, sort_by, error_message).into_response()); + } + Err(SearchError::Other(err)) => { + // For other errors, propagate them normally + // NOTE - Errrors that are not 400x or 500x will be logged to Sentry + return Err(err.into()); + } + }; + let title = if search_result.results.is_empty() { format!("No results found for '{query}'") } else { @@ -1251,7 +1313,7 @@ mod tests { .await .get("/releases/search?query=doesnt_matter_here") .await?; - assert_eq!(response.status(), 500); + assert_eq!(response.status(), 503); assert!(response.text().await?.contains(&format!("{status}"))); Ok(()) @@ -2231,4 +2293,43 @@ mod tests { Ok(()) }); } + + #[test] + fn test_create_search_error_response() { + let response = create_search_error_response( + "test_query".to_string(), + "relevance".to_string(), + "Service temporarily unavailable".to_string(), + ); + assert_eq!( + response.title, + "Search service is not currently available: Service temporarily unavailable" + ); + assert_eq!(response.status, http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.release_type, ReleaseType::Search); + } + + #[test] + fn crates_io_search_returns_status_code_5xx() { + async_wrapper(|env| async move { + let mut crates_io = mockito::Server::new_async().await; + env.override_config(|config| { + config.registry_api_host = crates_io.url().parse().unwrap(); + }); + + crates_io + .mock("GET", "/api/v1/crates") + .with_status(500) + .create_async() + .await; + + let response = env + .web_app() + .await + .get("/releases/search?query=anything_goes_here") + .await?; + assert_eq!(response.status(), 503); + Ok(()) + }) + } }