Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- S3 storage errors now propagate instead of silently returning empty site — misconfigured or unreachable S3 returns proper error messages to the Backstage plugin and 503 responses from the HTTP server

## [0.1.18] - 2026-03-23

### Added
Expand Down
15 changes: 10 additions & 5 deletions crates/rw-napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,16 @@ pub fn create_site(config: SiteConfig) -> Result<RwSite> {
#[allow(clippy::needless_pass_by_value)]
impl RwSite {
#[napi(js_name = "getNavigation")]
pub fn get_navigation(&self, section_ref: Option<String>) -> NavigationResponse {
let nav = self.site.navigation(section_ref.as_deref());
NavigationResponse {
pub fn get_navigation(&self, section_ref: Option<String>) -> Result<NavigationResponse> {
let nav = self
.site
.navigation(section_ref.as_deref())
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(NavigationResponse {
items: nav.items.into_iter().map(convert_nav_item).collect(),
scope: nav.scope.map(convert_scope_info),
parent_scope: nav.parent_scope.map(convert_scope_info),
}
})
}

#[napi]
Expand All @@ -189,7 +192,9 @@ fn build_page_response(site: &Site, path: &str) -> Result<PageResponse> {
let source_mtime = UNIX_EPOCH + Duration::from_secs_f64(result.source_mtime);
let last_modified: DateTime<Utc> = source_mtime.into();
let last_modified = last_modified.to_rfc3339();
let section_ref = site.get_section_ref(path);
let section_ref = site
.get_section_ref(path)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;

let (description, page_kind, vars) = if let Some(ref meta) = result.metadata {
(
Expand Down
24 changes: 24 additions & 0 deletions crates/rw-server/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ pub(crate) enum HandlerError {
/// I/O error.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),

/// Storage backend unavailable.
#[error("Storage error: {0}")]
Storage(#[from] rw_storage::StorageError),
}

impl IntoResponse for HandlerError {
Expand All @@ -54,8 +58,28 @@ impl IntoResponse for HandlerError {
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": e.to_string()}),
),
Self::Storage(e) => (
StatusCode::SERVICE_UNAVAILABLE,
json!({"error": "Storage unavailable", "detail": e.to_string()}),
),
};

(status, axum::Json(body)).into_response()
}
}

#[cfg(test)]
mod tests {
use super::*;
use axum::response::IntoResponse;

#[test]
fn test_storage_error_returns_503() {
let err = HandlerError::Storage(
rw_storage::StorageError::new(rw_storage::StorageErrorKind::Unavailable)
.with_backend("S3"),
);
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
}
9 changes: 5 additions & 4 deletions crates/rw-server/src/handlers/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use axum::extract::{Query, State};
use rw_site::{NavItem, ScopeInfo, Section};
use serde::{Deserialize, Serialize};

use crate::error::HandlerError;
use crate::handlers::to_url_path;
use crate::state::AppState;

Expand Down Expand Up @@ -89,18 +90,18 @@ impl From<NavItem> for NavItemResponse {
pub(crate) async fn get_navigation(
Query(query): Query<NavigationQuery>,
State(state): State<Arc<AppState>>,
) -> Json<NavigationResponse> {
let scoped_nav = state.site.navigation(query.section_ref.as_deref());
) -> Result<Json<NavigationResponse>, HandlerError> {
let scoped_nav = state.site.navigation(query.section_ref.as_deref())?;

Json(NavigationResponse {
Ok(Json(NavigationResponse {
items: scoped_nav
.items
.into_iter()
.map(NavItemResponse::from)
.collect(),
scope: scoped_nav.scope.map(ScopeInfoResponse::from),
parent_scope: scoped_nav.parent_scope.map(ScopeInfoResponse::from),
})
}))
}

#[cfg(test)]
Expand Down
11 changes: 6 additions & 5 deletions crates/rw-server/src/handlers/pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,11 @@ fn get_page_impl(
headers: HeaderMap,
) -> Result<impl IntoResponse, HandlerError> {
// Render the page using unified Site API (path is already without leading slash)
let result = state
.site
.render(&path)
.map_err(|_| HandlerError::PageNotFound(path.clone()))?;
let result = state.site.render(&path).map_err(|e| match e {
rw_site::RenderError::PageNotFound(p) => HandlerError::PageNotFound(p),
rw_site::RenderError::Storage(se) => HandlerError::Storage(se),
other => HandlerError::Render(other),
})?;

// Log warnings in verbose mode
if state.verbose && !result.warnings.is_empty() {
Expand Down Expand Up @@ -169,7 +170,7 @@ fn get_page_impl(
};

// Get section ref for this page's section
let section_ref = state.site.get_section_ref(&path);
let section_ref = state.site.get_section_ref(&path)?;

let response = PageResponse {
meta: PageMeta {
Expand Down
4 changes: 2 additions & 2 deletions crates/rw-server/src/live_reload/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,15 @@ impl LiveReloadManager {
}
StorageEventKind::Created => {
site.invalidate();
if site.has_page(&event.path) {
if site.has_page(&event.path).unwrap_or(false) {
let _ = broadcaster.send(ReloadEvent {
event_type: ReloadEventType::Structure,
path: url_path,
});
}
}
StorageEventKind::Removed => {
let known = site.has_page(&event.path);
let known = site.has_page(&event.path).unwrap_or(false);
site.invalidate();
if known {
let _ = broadcaster.send(ReloadEvent {
Expand Down
2 changes: 1 addition & 1 deletion crates/rw-site/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
//! let site = Arc::new(Site::new(storage, cache, config));
//!
//! // Get navigation (root)
//! let nav = site.navigation(None);
//! let nav = site.navigation(None)?;
//!
//! // Render a page
//! let result = site.render("guide")?;
Expand Down
10 changes: 5 additions & 5 deletions crates/rw-site/src/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ pub enum RenderError {
/// I/O error reading source file.
#[error("I/O error: {0}")]
Io(#[source] std::io::Error),
/// Storage backend error (e.g., S3 unavailable).
#[error("Storage error: {0}")]
Storage(#[source] StorageError),
}

impl From<StorageError> for RenderError {
Expand All @@ -109,7 +112,7 @@ impl From<StorageError> for RenderError {
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
),
_ => Self::Io(std::io::Error::other(e.to_string())),
_ => Self::Storage(e),
}
}
}
Expand Down Expand Up @@ -189,10 +192,7 @@ impl PageRenderer {
meta_include_source: Option<Arc<dyn MetaIncludeSource>>,
sections: &Arc<Sections>,
) -> Result<PageRenderResult, RenderError> {
let source_mtime = self
.storage
.mtime(path)
.map_err(|_| RenderError::FileNotFound(path.to_owned()))?;
let source_mtime = self.storage.mtime(path).map_err(RenderError::from)?;

let metadata = self.load_metadata(path);

Expand Down
Loading
Loading